- 轻量化,小巧方便,专注水印功能
- 注重可移植性,提供跨平台支持
- 重视性能,力求快速
- 易用性高,轻松上手
- 更多水印模板
- 性能优化
- 操作细节、操作手感优化
- 移动端版本
Watermarker 采用 Tauri v2 混合架构:Rust 后端处理文件 I/O 和图片编解码,Vue 3 前端负责 UI 展示和 Canvas 渲染。水印的实际绘制逻辑大部分在前端 Canvas 完成,以保证预览和导出的 WYSIWYG 一致性。
┌──────────────────────────────────────────────────────┐
│ Vue 3 Frontend │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ LeftPanel │ │ CenterCanvas │ │ RightPanel │ │
│ │ (文件/EXIF) │ │ (Canvas预览) │ │ (容器+子面板) │ │
│ └─────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────────────────┐ │
│ │ watermark/TextWatermark │ │
│ │ watermark/LogoWatermark │ │
│ │ watermark/ExifWatermark │ │
│ │ export/ExportSection │ │
│ └──────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐│
│ │ BatchPanel (批处理) ││
│ └──────────────────────────────────────────────────┘│
│ │ invoke() ▲ │
│ ▼ │ │
│ ┌──────────────────────────────────────────────────┐│
│ │ useWatermarkDrawing.ts (纯绘制函数) ││
│ │ · renderFullRes() — 全分辨率导出 ││
│ │ · renderOffscreen() — 批处理离屏渲染 ││
│ │ · renderWatermarkStatic() — 水印分发 ││
│ │ · drawTextWatermarkStatic() — 文字水印 ││
│ │ · drawExifWatermarkStatic() — EXIF水印 ││
│ │ · drawLogoWatermarkStatic() — Logo水印 ││
│ └──────────────────────────────────────────────────┘│
│ ┌──────────────────────────────────────────────────┐│
│ │ useCanvas.ts (composable, 重新导出上述函数) ││
│ │ useImageCache.ts (LRU图片缓存 + 预加载进度) ││
│ │ useFontLoader.ts (系统字体+自定义字体加载) ││
│ │ utils/colorConvert.ts (rgbToHex/hexToRgb) ││
│ │ utils/tradeMarks.ts (商标Logo预加载+品牌匹配) ││
│ └──────────────────────────────────────────────────┘│
│ ┌──────────────────────────────────────────────────┐│
│ │ Pinia Stores (状态管理) ││
│ │ · imageStore — 图片数据 & renderedBase64 ││
│ │ · watermarkStore — 水印配置 ││
│ │ · batchStore — 批处理队列 ││
│ └──────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────┘
│ ▲
invoke() return
│ │
▼ │
┌──────────────────────────────────────────────────────┐
│ Rust Backend (Tauri) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ commands/ │ │ commands/ │ │ commands/ │ │
│ │ image.rs │ │ exif.rs │ │ watermark.rs │ │
│ │ · load_image │ │ · read_exif │ │ · apply_text │ │
│ │ · export_file│ │ │ │ · apply_logo │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────────────────────────────────────────┐│
│ │ engine/ (渲染引擎) ││
│ │ image.rs — 解码/编码/格式转换 ││
│ │ exif.rs — EXIF 元数据解析 ││
│ │ text.rs — ab_glyph + imageproc 文字水印 ││
│ │ overlay.rs — alpha blending Logo 叠加 ││
│ └──────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────┘
用户选择图片
│
▼
[Rust] load_image() → 解码图片 → JPEG base64
│
▼
[Vue] imageStore.setImage(info)
│
▼
[Canvas] renderPreview()
│ 1. 检查 useImageCache → 命中则直接用缓存的 HTMLImageElement
│ 2. 未命中 → loadImageFromBase64() → HTMLImageElement
│ 3. 计算缩放比例 (适应容器)
│ 4. ctx.drawImage() → Canvas
│ 5. renderWatermarkStatic(ctx, canvas, scale, store) [useWatermarkDrawing.ts]
│ └─ text: drawTextWatermarkStatic()
│ └─ logo: drawLogoWatermarkStatic()
│ └─ exif: drawExifWatermarkStatic()
└─ camera_model 匹配Canon/Nikon/Sony → 绘制商标Logo替代文字
│ 6. canvas.toDataURL() → imageStore.renderedBase64
▼
实时预览完成 (响应式更新)
用户点击"导出单张图片"
│
▼
[Canvas] renderFullRes(format)
│ 1. 调用 load_image_raw(filePath) → 原始文件字节(避免二次 JPEG 压缩)
│ 2. 创建离屏 Canvas (原始尺寸, scale=1.0)
│ 3. ctx.drawImage() → 原图
│ 4. renderWatermarkStatic(ctx, canvas, 1.0, store)
│ └─ 水印按原始分辨率绘制
│ 5. canvas.toDataURL(mime, quality) → base64
▼
[Rust] export_file(base64, outputPath)
│ Base64 解码 → 直接写入文件
▼
导出完成 (WYSIWYG)
用户添加文件 → 选择输出目录 → 点击"开始批处理"
│
▼
[BarchPanel] for each entry:
│ 1. [Rust] loadImageRaw(filePath) → 原始文件字节 → base64
│ 2. [Rust] readExif(filePath) → EXIF数据 (exif模式)
│ 3. [Canvas] renderOffscreenWithConfig(rawBase64, format, entry.config, mime, exif)
│ └─ 每张图片使用各自的独立水印配置 (BatchWatermarkConfig)
│ 4. [Rust] export_file(renderedBase64, outPath)
▼
逐文件渲染进度反馈
问题:最初使用 Rust 引擎渲染水印导出,前端 Canvas 渲染预览,两者实现不同导致导出结果与预览不一致。
解决方案:
- 将水印渲染逻辑统一到前端 Canvas
renderFullRes()和renderOffscreen()共享同一个renderWatermarkStatic()函数- 导出时使用
scale=1.0在离屏 Canvas 上以原始分辨率绘制 imageStore.renderedBase64存储预览渲染结果,确保"所见即所得"
问题:最初 Logo 缩放是相对 Logo 自身的倍数(0.1–5×),预览和导出需额外 renderScale 参数校正。
解决方案 (v2):改为照片宽度百分比(5–100%)。
logoW = canvas.width * (config.scale / 100)canvas.width在预览和导出中自动反映当前渲染比例- 无需
renderScale参数,天然 WYSIWYG
文字水印的旋转逻辑在 drawTextWatermarkStatic 中实现:
- 单次模式:
ctx.translate(x, y)+ctx.rotate(angle)→ 以指定位置为中心旋转 - 平铺模式:每个瓦片独立
save/translate/rotate/fillText/restore,网格保持正交排列 - 角度范围 0–360°,步进 1°
字体通过 FontFace API 注册到浏览器:
load_image_raw(path)读取 TTF/OTF 原始字节 → base64 data URLnew FontFace(familyName, 'url(data:font/ttf;base64,...)')→await fontFace.load()document.fonts.add(fontFace)注册全局可用- Canvas 通过
ctx.font = '...px "familyName", Arial, sans-serif'使用
系统字体列表由 Rust list_system_fonts 命令扫描平台字体目录获取,在 useFontLoader.ts 的 loadSystemFonts() 中调用并填充 Pinia store。
watermarkStore 使用 localStorage:
- 300ms 防抖自动保存(watcher 深度监听 store 变化)
- 初始化时从 localStorage 恢复
logo_base64排除在持久化之外(可能数 MB)
问题:CenterCanvas 通过 provide("renderPreview", renderPreview) 注入函数,LeftPanel 通过 inject 获取。但 RightPanel 和 CenterCanvas 是兄弟组件,provide 无法跨兄弟传递。
解决方案:
- 将
renderedBase64移入 Pinia Store (imageStore) - 将
renderFullRes()和renderOffscreen()提取为模块级函数(位于useWatermarkDrawing.ts,由useCanvas.ts重新导出) - 导出组件(
RightPanel、BatchPanel)直接从模块导入,无需依赖注入
问题:JPEG 编码器不支持 RGBA8 颜色类型(只有 3 通道)。
解决方案:
rgba_to_rgb()函数将 RGBA 字节转换为 RGB(丢弃 Alpha 通道)- 所有 JPEG 编码前先调用此转换
- 添加了边界检查防止越界 panic
- 技术选型:Tauri (Rust + WebView) 替代 Electron,避免打包完整 Chromium
- 编译优化:Rust release 模式启用优化,
windows_subsystem = "windows"隐藏控制台 - 目标体积:安装包 < 10MB(Windows NSIS)、< 5MB(macOS DMG)
App.vue
├── LeftPanel ─────────────────────────────┐
│ · 调用 loadImage/exif 命令 │
│ · 支持多文件选择 → 加入批处理队列 │
│ · 文件加载后由 Pinia watcher 自动触发 │
│ 渲染(无需手动调用 renderPreview) │
│ │
├── CenterCanvas ──────────────────────────┤
│ · 持有 <canvas> ref │
│ · provide("renderPreview", fn) ────────┘
│ · 加载中遮罩层(spinner + 文字) │
│ · watch(imageStore.currentImage) │
│ watch(watermarkStore.$state) │
│ watch(imageStore.exifData) │
│ → 自动触发 renderPreview() │
│ · renderPreview() 自动查 useImageCache │
│ 命中则跳过 loadImageFromBase64() │
│ │
├── RightPanel (容器) ─────────────────────┘
│ · 水印类型切换 + 启用/禁用
│ ├── TextWatermarkPanel · 修改 textConfig → 触发重绘
│ ├── LogoWatermarkPanel · 修改 logoConfig
│ ├── ExifWatermarkPanel · 修改 exifConfig
│ └── ExportSection · 直接 import renderFullRes (模块级)
│
└── BatchPanel
· 直接 import renderOffscreen (模块级)
· 添加文件后自动后台预加载到 useImageCache
· 切换文件优先查缓存,命中则跳过 Rust 调用
· 显示预加载进度条 (preloadProgress)
· 遍历文件列表逐文件处理
┌──────────────┐
│ imageStore │
│ currentImage │ ← loadImage (Rust)
│ renderedBase64│ ← renderPreview (Canvas)
│ exifData │ ← readExif (Rust)
└──────────────┘
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
LeftPanel CenterCanvas RightPanel
(EXIF显示) (预览渲染) (导出操作)
│
▼
renderFullRes()
→ exportFile (Rust)
→ 写入磁盘
┌──────────────┐
│watermarkStore │
│ watermarkType │
│ textConfig │ ← 用户调整
│ logoConfig │
│ enabled │
│ fontPath │
└──────────────┘
│
watch 深度监听 ──→ 自动触发 renderPreview()