diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8eb1cbf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.github/ +.idea/ +.vscode/ +dist/ +node_modules/ +src-wasm/target/ +.git/ +*.md +LICENSE +stats.html +vercel.json +.dockerignore \ No newline at end of file diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..fa16c3c --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,34 @@ +name: Docker Build and Push + +on: + workflow_dispatch: + inputs: + version: + description: The version to build + + push: + tags: + - '*.*' + - '*.*.*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKER_USERNAME }}/picseal:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d10feb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:22 AS build + +RUN useradd -m picseal + +USER picseal + +ENV HOME=/home/picseal + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + +ENV PATH="$HOME/.cargo/bin:$PATH" + +RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y + +WORKDIR /app + +COPY public/ scripts/ src/ src-wasm/ eslint.config.js index.html package.json tsconfig.app.json tsconfig.json tsconfig.node.json vite.config.ts ./ + +RUN npm install + +RUN npm run build + +FROM nginx:alpine AS production + +COPY --from=build /app/dist /usr/share/nginx/html + +COPY ./nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 2dc6888..446dc64 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ 生成类似小米照片风格的莱卡水印照片。支持佳能、尼康、苹果、华为、小米、DJI 等设备的水印生成,可自动识别,也可自定义处理。 -[English](./README_en.md) 中文 - ## 在线演示 在线试用地址: @@ -13,6 +11,27 @@ ![应用截图](./public/screenshot.png) +## 技术实现 + +### EXIF 解析 + +使用了 Rust 库 `kamadak-exif` 从图片中提取得到 EXIF 信息并借助 WASM 技术嵌入前端 JavaScript 使用。 + +### 水印生成 + +通过 HTML 和 CSS 生成水印样式,能够做到动态调整实时预览。 + +### 图片生成 + +导出的图片是通过 `dom-to-image` JavaScript 库来将 DOM 转 JPEG/PNG 等格式图片,请注意这种实现生成的是和原图完全不一样的图片,可以看作屏幕截图的方式。 + +目前针对 JPEG 格式图片新增了复制原图 EXIF 信息嵌进导出的图片中,目前的实现方式比较简单粗暴,直接从原图二进制数据提取 EXIF 部分的数据,再同样以二进制格式进行拼接,不能确保稳定。 + +### 改进 + +- [ ] 改用 Rust `little_exif` 库来实现对图片 EXIF 信息的读取和编辑。 +- [ ] 改用 Canvas 来实现水印,支持高度自定义。 + ## 部署方法 ### 使用 Vercel 部署 @@ -77,6 +96,20 @@ npm run pages ``` +### 使用 Docker 部署 + +1. 拉取镜像 + ```bash + docker pull zhiweio/picseal:latest + ``` + +2. 启动容器 + ```bash + docker run -d -p 8080:80 picseal + ``` + +3. 访问 http://localhost:8080 + ## 作者 - [@Wang Zhiwei](https://github.com/zhiweio) diff --git a/README_en.md b/README_en.md deleted file mode 100644 index 2dbfdaa..0000000 --- a/README_en.md +++ /dev/null @@ -1,90 +0,0 @@ -# Picseal - -Generate Leica-style watermark photos inspired by Xiaomi's photo style. Picseal supports automatic or custom watermark generation for Canon, Nikon, Apple, Huawei, Xiaomi, DJI, and more. - -English [中文](./README.md) - -## Online Demo - -Try it online: -- [picseal.vercel.app](https://picseal.vercel.app) -- [picseal.zhiweio.me](https://picseal.zhiweio.me) -- [zhiweio.github.io/picseal](https://zhiweio.github.io/picseal/) - -![App Screenshot](./public/screenshot.png) - -## Deployment - -### Deploy with Vercel - -| Deploy with Vercel | -| :-------------------------------------: | -| [![][deploy-button-image]][deploy-link] | - -### Deploy Locally - -1. **Clone the repository**: - ```bash - git clone https://github.com/zhiweio/picseal - ``` - -2. **Install dependencies**: - ```bash - # Install Rustup (compiler) - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - - # Install wasm-pack - curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -y - ``` - -3. **Build and run**: - ```bash - npm install - npm run build - npm run preview - ``` - -### Deploy with GitHub Pages - -1. Configure the `base` in `vite.config.ts` to match your GitHub Pages URL (e.g., `https://.github.io//`): - ```javascript - import wasm from 'vite-plugin-wasm' - - export default defineConfig({ - plugins: [ - react(), - wasm(), - topLevelAwait(), - visualizer({ open: true }), - ], - server: { - port: 3000, - }, - build: { - outDir: 'dist', - target: 'esnext', - }, - optimizeDeps: { - exclude: ['picseal'], - }, - base: 'https://zhiweio.github.io/picseal/', - }) - ``` - -2. **Build and deploy**: - ```bash - npm install - npm run pages - ``` - -## Authors - -- [@Wang Zhiwei](https://github.com/zhiweio) - -## License - -[MIT](https://choosealicense.com/licenses/mit/) - - -[deploy-button-image]: https://vercel.com/button -[deploy-link]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzhiweio%2Fpicseal&project-name=picseal&repository-name=picseal diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..900de63 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,24 @@ +server { + listen 80; + server_name _;; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } + + location ~* \.(?:ico|css|js|woff|woff2|ttf|otf|eot|svg|jpg|jpeg|png|gif|webp|avif|mp4|webm|ogv|ogg|mp3|m4a|wav|flac)$ { + expires 6M; + access_log off; + add_header Cache-Control "public, max-age=15768000, immutable"; + } + + error_page 404 /index.html; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/package.json b/package.json index 50a46cf..e635d92 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "eslint": "^9.15.0", + "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", "gh-pages": "^6.2.0", "rollup-plugin-visualizer": "^5.12.0", diff --git a/public/exhibition/xiaomi.jpg b/public/exhibition/xiaomi.jpg index 1b5e498..7173edc 100644 Binary files a/public/exhibition/xiaomi.jpg and b/public/exhibition/xiaomi.jpg differ diff --git a/src-wasm/Cargo.lock b/src-wasm/Cargo.lock index 7e1b12b..d66841c 100644 --- a/src-wasm/Cargo.lock +++ b/src-wasm/Cargo.lock @@ -2,42 +2,12 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - [[package]] name = "bumpalo" version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" -[[package]] -name = "cc" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" -dependencies = [ - "shlex", -] - [[package]] name = "cfg-if" version = "0.1.10" @@ -50,20 +20,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "chrono" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-targets", -] - [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -74,25 +30,16 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "gen_brand_photo_pictrue" version = "0.0.1" dependencies = [ - "chrono", "console_error_panic_hook", "gloo-utils", "kamadak-exif", - "once_cell", "serde", "serde_json", "wasm-bindgen", - "web-sys", "wee_alloc", ] @@ -109,29 +56,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "iana-time-zone" -version = "0.1.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "itoa" version = "1.0.13" @@ -186,15 +110,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b" -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "once_cell" version = "1.20.2" @@ -257,12 +172,6 @@ dependencies = [ "serde", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "syn" version = "2.0.89" @@ -380,76 +289,3 @@ name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/src-wasm/Cargo.toml b/src-wasm/Cargo.toml index c080f2d..e3556f9 100644 --- a/src-wasm/Cargo.toml +++ b/src-wasm/Cargo.toml @@ -19,14 +19,11 @@ default = [ [dependencies] wasm-bindgen = { version = "0.2.81", features = [ "serde-serialize" ] } gloo-utils = "0.2" -chrono = "0.4" console_error_panic_hook = { version = "0.1.7", optional = true } wee_alloc = { version = "0.4.5", optional = true } -web-sys = { version = "0.3.58", features = [] } kamadak-exif = "0.5.4" serde = { version = "1.0.138", features = [ "derive" ] } serde_json = "1.0.133" -once_cell = "1.20.2" [profile.release] opt-level = "s" diff --git a/src-wasm/src/lib.rs b/src-wasm/src/lib.rs index b90c6d9..bb2b0d9 100644 --- a/src-wasm/src/lib.rs +++ b/src-wasm/src/lib.rs @@ -1,10 +1,7 @@ mod utils; -use chrono::{DateTime, Local}; use gloo_utils::format::JsValueSerdeExt; -use once_cell::sync::Lazy; use serde::Serialize; -use serde_json::{json, Value}; use wasm_bindgen::prelude::*; #[cfg(feature = "wee_alloc")] @@ -23,144 +20,16 @@ pub fn run() { utils::set_panic_hook(); } -fn get_current_time() -> String { - let now: DateTime = Local::now(); - now.format("%Y-%m-%d %H:%M:%S").to_string() -} - -static DEFAULT_EXIF_INFO: Lazy> = Lazy::new(|| { - vec![ - json!({ - "tag": "Make", - "value": "Leica", - "value_with_unit": "Leica" - }), - json!({ - "tag": "Model", - "value": "XIAOMI 13 ULTRA", - "value_with_unit": "XIAOMI 13 ULTRA" - }), - json!({ - "tag": "DateTime", - "value": get_current_time(), - "value_with_unit": get_current_time() - }), - json!({ - "tag": "DateTimeOriginal", - "value": get_current_time(), - "value_with_unit": get_current_time() - }), - json!({ - "tag": "GPSLatitude", - "value": "41 deg 12 min 47.512 sec", - "value_with_unit": "41 deg 12 min 47.512 sec N" - }), - json!({ - "tag": "GPSLatitudeRef", - "value": "N", - "value_with_unit": "N" - }), - json!({ - "tag": "GPSLongitude", - "value": "124 deg 0 min 16.376 sec", - "value_with_unit": "124 deg 0 min 16.376 sec W" - }), - json!({ - "tag": "GPSLongitudeRef", - "value": "W", - "value_with_unit": "W" - }), - json!({ - "tag": "ImageLength", - "value": "4096", - "value_with_unit": "4096 pixels" - }), - json!({ - "tag": "FocalLength", - "value": "8.7", - "value_with_unit": "8.7 mm" - }), - json!({ - "tag": "FocalLengthIn35mmFilm", - "value": "75", - "value_with_unit": "75 mm" - }), - json!({ - "tag": "PhotographicSensitivity", - "value": "800", - "value_with_unit": "800" - }), - json!({ - "tag": "FNumber", - "value": "1.8", - "value_with_unit": "f/1.8" - }), - json!({ - "tag": "ExposureMode", - "value": "auto exposure", - "value_with_unit": "auto exposure" - }), - json!({ - "tag": "ExposureTime", - "value": "1/33", - "value_with_unit": "1/33 s" - }), - ] -}); - -fn create_exif_data(exif_info: &[Value]) -> Vec { - let mut exif_data = Vec::new(); - - for item in exif_info { - let tag = item["tag"].as_str().unwrap().to_string(); - let value = item["value"].as_str().unwrap().to_string(); - let value_with_unit = item["value_with_unit"].as_str().unwrap().to_string(); - - exif_data.push(ExifData { - tag, - value, - value_with_unit, - }); - } - - exif_data -} - #[wasm_bindgen] pub fn get_exif(raw: Vec) -> JsValue { let mut exif_data: Vec = Vec::new(); - let exifreader = exif::Reader::new(); + let exif_reader = exif::Reader::new(); let mut bufreader = std::io::Cursor::new(raw.as_slice()); - // Try to read EXIF data, fallback to DEFAULT_EXIF_INFO if it fails - match exifreader.read_from_container(&mut bufreader) { + // Try to read EXIF data, fallback to empty if it fails + match exif_reader.read_from_container(&mut bufreader) { Ok(exif) => { for field in exif.fields() { - if let Some(_) = field.tag.to_string().find("Tag(Exif") { - continue; - } - - if ["Make", "Model"].contains(&field.tag.to_string().as_str()) { - exif_data.push(ExifData { - tag: field.tag.to_string(), - value: field - .display_value() - .to_string() - .replace( - |item: char| ["\"", ","].contains(&item.to_string().as_str()), - "", - ) - .trim() - .to_string(), - value_with_unit: field - .display_value() - .with_unit(&exif) - .to_string() - .replace('"', ""), - }); - continue; - } - exif_data.push(ExifData { tag: field.tag.to_string(), value: field.display_value().to_string(), @@ -169,8 +38,7 @@ pub fn get_exif(raw: Vec) -> JsValue { } } Err(_) => { - // Use default EXIF data if parsing fails - exif_data = create_exif_data(&DEFAULT_EXIF_INFO); + // Use empty EXIF data if parsing fails } } diff --git a/src/App.tsx b/src/App.tsx index 2f01282..e056fd7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,10 @@ import type { ExifParamsForm } from './types' import { createFromIconfontCN, DownloadOutlined, PlusOutlined } from '@ant-design/icons' -import { Button, Divider, Flex, Form, Input, Select, Slider, Space, Typography, Upload } from 'antd' +import { Button, Divider, Flex, Form, Input, Select, Slider, Space, Switch, Tooltip, Typography, Upload } from 'antd' import { useEffect, useRef, useState } from 'react' import { useImageHandlers } from './hooks/useImageHandlers' -import { BrandsList, getBrandUrl } from './utils/BrandUtils' +import { BrandsList, getBrandUrl } from './utils/BrandUtils.ts' import { DefaultPictureExif, getRandomImage, @@ -24,6 +24,7 @@ function App() { const formRef = useRef() const { imgRef, imgUrl, setImgUrl, formValue, setFormValue, handleAdd, handleDownload, handleFormChange, handleFontSizeChange, handleFontWeightChange, handleFontFamilyChange, handleScaleChange, handleExhibitionClick } = useImageHandlers(formRef, DefaultPictureExif) const [wasmLoaded, setWasmLoaded] = useState(false) + const [exifEnable, setExifEnable] = useState(false) const formValueRef = useRef(DefaultPictureExif) @@ -155,7 +156,7 @@ function App() { type="primary" shape="round" icon={} - onClick={handleDownload} + onClick={() => handleDownload(exifEnable)} > 导出照片 @@ -168,6 +169,15 @@ function App() { 参数
+ + 导出 EXIF + + setExifEnable(!exifEnable)} + /> + +
(initialFormValue) const [imgUrl, setImgUrl] = useState(getRandomImage()) const imgRef = useRef(null) + const [uploadImgType, setUploadImgType] = useState() + const [exifBlob, setExifBlob] = useState(null) // 处理文件上传 const handleAdd = (file: RcFile): false => { @@ -23,13 +26,16 @@ export function useImageHandlers(formRef: any, initialFormValue: ExifParamsForm) const updatedFormValue = { ...formValue, ...parsedExif, - brand_url: getBrandUrl(parsedExif.brand), // Use getBrandUrl from utils + brand_url: getBrandUrl(parsedExif.brand), } console.log('original EXIF data: ', exifData) console.log('parsed EXIF data: ', parsedExif) formRef.current.setFieldsValue(updatedFormValue) setFormValue(updatedFormValue) setImgUrl(URL.createObjectURL(new Blob([file], { type: file.type }))) + const parsedExifBlob = await extractExifRaw(new Blob([file])) + setExifBlob(parsedExifBlob) + setUploadImgType(file.type) } catch (error) { console.error('Error parsing EXIF data:', error) @@ -41,29 +47,56 @@ export function useImageHandlers(formRef: any, initialFormValue: ExifParamsForm) } // 导出图片 - const handleDownload = (): void => { + const handleDownload = async (exifEnable: boolean): Promise => { const previewDom = document.getElementById('preview') const zoomRatio = 4 - domtoimage - .toJpeg(previewDom, { - quality: 1.0, - width: previewDom.clientWidth * zoomRatio, - height: previewDom.clientHeight * zoomRatio, - style: { transform: `scale(${zoomRatio})`, transformOrigin: 'top left' }, - }) - .then((dataUrl) => { - const link = document.createElement('a') - link.download = `${Date.now()}.jpg` + try { + let dataUrl: string + if (uploadImgType === 'image/png') { + console.log('dom to png') + dataUrl = await domtoimage.toPng(previewDom, { + quality: 1.0, + width: previewDom.clientWidth * zoomRatio, + height: previewDom.clientHeight * zoomRatio, + style: { transform: `scale(${zoomRatio})`, transformOrigin: 'top left' }, + }) + } + else { + dataUrl = await domtoimage.toJpeg(previewDom, { + quality: 1.0, + width: previewDom.clientWidth * zoomRatio, + height: previewDom.clientHeight * zoomRatio, + style: { transform: `scale(${zoomRatio})`, transformOrigin: 'top left' }, + }) + } + + const link = document.createElement('a') + if (exifEnable && exifBlob) { + if (uploadImgType === 'image/jpeg' || uploadImgType === 'image/jpg') { + console.log('embed exif in jpg') + const imgBlob = dataURLtoBlob(dataUrl) + const downloadImg = await embedExifRaw(exifBlob, imgBlob) + link.href = URL.createObjectURL(downloadImg) + } + else { + console.warn('EXIF blob data can only be embedded in JPEG or JPG images.') + link.href = dataUrl + } + } + else { link.href = dataUrl - document.body.appendChild(link) - link.click() - link.remove() - }) - .catch((err) => { - console.error('Export Error:', err) - message.error('导出失败,请重试') - }) + } + const fileExt: string = (uploadImgType || 'jpg').replace(/image\//g, '') + link.download = `${Date.now()}.${fileExt}` + document.body.appendChild(link) + link.click() + link.remove() + } + catch (error) { + console.error('Download Error:', error) + message.error('导出失败,请重试') + } } // 处理表单更新 diff --git a/src/styles/App.css b/src/styles/App.css index 8794bd1..9d20611 100644 --- a/src/styles/App.css +++ b/src/styles/App.css @@ -230,3 +230,37 @@ background-color: #0056b3; /* Darker blue on hover */ transform: translateY(-2px); /* Lift effect on hover */ } + +/* Switch Styles */ +.switch-title { + color: #aaa; /* Light gray color for the title */ + font-family: var(--current-font-family), system-ui; + font-size: 12px; /* Font size for the title */ +} + +.ant-switch { + border-radius: 20px; /* Rounded corners for a modern look */ + background-color: #e0e0e0; /* Light gray background color */ + transition: background-color 0.3s ease; /* Smooth transition for background color */ +} + +.ant-switch-checked { + background-color: #a0c4ff; /* Light blue background color when checked */ +} + +.ant-switch-checked:hover { + background-color: #8ab8ff; /* Lighter shade on hover when checked */ +} + +/* Tooltip Styles */ +.ant-tooltip { + border-radius: 8px; /* Rounded corners for tooltips */ + background-color: #f0f0f0; /* Light background for better contrast */ + color: #333; /* Dark text for readability */ + padding: 8px; /* Padding for better spacing */ + font-size: 14px; /* Font size for tooltip text */ +} + +.ant-tooltip-arrow { + border-color: #f0f0f0; /* Match arrow color with tooltip background */ +} diff --git a/src/utils/BrandUtils.tsx b/src/utils/BrandUtils.ts similarity index 100% rename from src/utils/BrandUtils.tsx rename to src/utils/BrandUtils.ts diff --git a/src/utils/ImageUtils.tsx b/src/utils/ImageUtils.ts similarity index 83% rename from src/utils/ImageUtils.tsx rename to src/utils/ImageUtils.ts index d6d9e15..30e3405 100644 --- a/src/utils/ImageUtils.tsx +++ b/src/utils/ImageUtils.ts @@ -62,6 +62,14 @@ export function formatExposureTime(exposureTime: string | undefined): string { // 解析 EXIF 数据 export function parseExifData(data: ExifData[]): Partial { + const exifValues = new Map(data.map(item => [item.tag, item.value])) + const exifValuesWithUnit = new Map(data.map(item => [item.tag, item.value_with_unit])) + const make: string = (exifValues.get('Make') || '').replace(/[",]/g, '') + const brand: string = formatBrand(make || 'unknow') + if (brand === 'unknow') { + return DefaultPictureExif + } + const exif = { GPSLatitude: '', GPSLatitudeRef: '', @@ -76,9 +84,8 @@ export function parseExifData(data: ExifData[]): Partial { Make: '', DateTimeOriginal: '', } - const exifValues = new Map(data.map(item => [item.tag, item.value])) - const exifValuesWithUnit = new Map(data.map(item => [item.tag, item.value_with_unit])) - + exif.Make = make + exif.Model = `${(exifValues.get('Model') || '').replace(/[",]/g, '')}` exif.GPSLatitude = exifValues.get('GPSLatitude') || '' exif.GPSLatitudeRef = exifValues.get('GPSLatitudeRef') || '' exif.GPSLongitude = exifValues.get('GPSLongitude') || '' @@ -88,8 +95,6 @@ export function parseExifData(data: ExifData[]): Partial { exif.FNumber = exifValuesWithUnit.get('FNumber') || '' exif.ExposureTime = exifValues.get('ExposureTime') || '' exif.PhotographicSensitivity = exifValues.get('PhotographicSensitivity') || '' - exif.Model = exifValues.get('Model') || '' - exif.Make = exifValues.get('Make') || '' exif.DateTimeOriginal = exifValues.get('DateTimeOriginal') || '' const gps = `${formatGPS(exif.GPSLatitude, exif.GPSLatitudeRef)} ${formatGPS(exif.GPSLongitude, exif.GPSLongitudeRef)}` @@ -102,11 +107,11 @@ export function parseExifData(data: ExifData[]): Partial { .filter(Boolean) .join(' ') return { - model: exif.Model || 'Unknown Model', + model: exif.Model || 'PICSEAL', date: exif.DateTimeOriginal || moment().format('YYYY.MM.DD HH:mm:ss'), gps, device, - brand: `${formatBrand(exif.Make)}`, + brand, } } @@ -115,3 +120,14 @@ export function getRandomImage() { const randomIndex = Math.floor(Math.random() * ExhibitionImages.length) return ExhibitionImages[randomIndex] } + +export function dataURLtoBlob(dataURL: string): Blob { + const byteString: string = atob(dataURL.split(',')[1]) + const mimeString: string = dataURL.split(',')[0].split(':')[1].split(';')[0] + const ab = new ArrayBuffer(byteString.length) + const ia = new Uint8Array(ab) + for (let i: number = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i) + } + return new Blob([ab], { type: mimeString }) +} diff --git a/src/utils/JpegExifUtils.ts b/src/utils/JpegExifUtils.ts new file mode 100644 index 0000000..0b7df63 --- /dev/null +++ b/src/utils/JpegExifUtils.ts @@ -0,0 +1,39 @@ +const SOS = 0xFFDA +const APP1 = 0xFFE1 +const EXIF = 0x45786966 +const JPEG = 0xFFD8 // JPEG start marker + +export function extractExifRaw(raw: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = async (e) => { + const buffer = e.target?.result + if (!buffer) + return reject(new Error('Failed to read raw image data')) + + const view = new DataView(buffer) + let offset = 0 + if (view.getUint16(offset) !== JPEG) + return reject(new Error('not a valid jpeg')) + offset += 2 + + while (offset < view.byteLength) { + const marker = view.getUint16(offset) + if (marker === SOS) + break + const size = view.getUint16(offset + 2) + if (marker === APP1 && view.getUint32(offset + 4) === EXIF) + return resolve(raw.slice(offset, offset + 2 + size)) + offset += 2 + size + } + return resolve(new Blob()) + } + reader.readAsArrayBuffer(raw) + }) +} + +export function embedExifRaw(exifRaw: Blob, targetImg: Blob): Blob { + return new Blob([targetImg.slice(0, 2), exifRaw, targetImg.slice(2)], { + type: 'image/jpeg', + }) +} diff --git a/vercel.json b/vercel.json index 2083ff2..b473833 100644 --- a/vercel.json +++ b/vercel.json @@ -3,6 +3,7 @@ "buildCommand": "source $HOME/.cargo/env && npm run build", "git": { "deploymentEnabled": { + "feature/update": false, "gh-pages": false } },