No description
  • TypeScript 68%
  • Svelte 26.5%
  • CSS 2.3%
  • JavaScript 2.2%
  • HTML 1%
Find a file
予纾 8cf2d11183 fix(ui): 明水印配置归并到「明水印高级设置」一处,旧高级选项只留非明水印项
v1.1 加了「明水印高级设置」折叠面板,但 v1.0 时代的明水印配置项没跟着挪,
位置留在顶层、透明度/字号还留在通用「高级选项」里,同类配置散在两三处。

- 位置、字号、透明度并入「明水印高级设置」,面板紧跟在「明水印文字」后面,
  明水印相关的东西从上到下连成一块;面板标题补上位置/字号便于找。
- 面板内标签去掉冗余的「明水印」前缀(明水印透明度 → 透明度)。
- 「高级选项」只剩非明水印项:作者/版权、最大边长、导出质量、暗水印强度、
  恢复默认设置。
- 纯界面调整:字段名和 localStorage key(pic-guard:settings:v2)都没动,
  用户已存的设置照常读回来。README 的面板说明同步更新。

npm test 64/64 通过,svelte-check 0 errors 0 warnings,vite build 正常。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 13:11:30 +08:00
public feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
scripts feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
src fix(ui): 明水印配置归并到「明水印高级设置」一处,旧高级选项只留非明水印项 2026-07-27 13:11:30 +08:00
test feat: v1.1 —— PNG 透明输出、alpha 感知暗水印、明水印完整排版、Artist/Copyright 拆分 2026-07-27 12:49:46 +08:00
.gitignore feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
index.html feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
package-lock.json feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
package.json test:attack 改 bun 直跑:本机 node 是 bun shim,tsx 的 CJS 加载在其下报错 2026-07-27 11:58:30 +08:00
README.md fix(ui): 明水印配置归并到「明水印高级设置」一处,旧高级选项只留非明水印项 2026-07-27 13:11:30 +08:00
sw-template.js feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
tsconfig.json feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
tsconfig.node.json feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
vite.config.ts feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00
vitest.config.ts feat: 图片守护 pic-guard 首次交付 2026-07-27 11:37:40 +08:00

图片守护 pic-guard

面向创作者(画师/约稿玩家)的图片发布预处理工具。拖入图片,本地完成 降采样 → 明水印 → 隐形暗水印 → 有损压缩去元数据(可选写入 Artist/ Copyright,一键下载。纯静态 PWA全程在浏览器本地处理,不上传、 不联网。输出可选 JPEG(体积小,需要铺底色)或 PNG(保留透明 通道,体积较大)——画师圈常见的光效/水感/液态质感这类半透明元素,铺 白底会被冲灰,选 PNG 就不会。

项目名 pic-guard 是占位名。改名需要同步改三处:src/lib/constants.tsAPP_NAME/APP_SHORT_NAMEpublic/manifest.webmanifestindex.html 里的 <title>。manifest 是静态 JSON没法从常量文件引用 这是唯一没法"改一处全改"的地方。

信任承诺是怎么自证的

  • 页面顶部常驻提示"图片全程在你的浏览器本地处理,不会上传到任何服务器"。
  • index.html 里有一条严格的 CSPconnect-src 'self'(不是外部服务器, 唯一联网行为是 Service Worker 缓存本页自身的静态资源,用于离线可用)、 script-src 'self'unsafe-inline/unsafe-eval、跨域一律不在白名单。 应用代码本身从头到尾没有一次 fetch/XMLHttpRequest 调用——不是"没触发" 是代码里根本不存在这条路径。断网也能正常处理图片,可以自己试。
  • 依赖全部锁定精确版本(见 package.json,没有 ^/~),没有 CDN 引用。

快速开始

npm install
npm run dev       # 本地开发http://localhost:5173
npm run build     # 类型检查 + 产出 dist/
npm run preview   # 本地起一个静态服务器预览 dist/
npm test          # 单元测试
npm run test:attack   # 暗水印攻击实测报告(见下文"攻击实测方法"

环境提示:如果你机器上 node 实际指向的是 bun 提供的 shim常见于 装了 bun 又没另外调整 PATH 的情况——node --version 会报错而不是打印 版本号),npm install/npm run dev/npm run build/npm test 都不受 影响vite/vitest/tsc 在这个 shim 下正常),唯独 test:attack 脚本本来 用 tsx 直接跑 TS 文件tsx 依赖 的 ESM 加载钩子(module.register)在 bun 的 node shim 下会报错——所以 test:attack 改成了 bun test/node/run-watermark-attack-report.ts 直接 用 bun 跑bun 原生认 TS/ESM不需要 tsxsharp 这类原生插件在 bun 下 实测正常)。如果你自己要另外写 Node 侧脚本,同样的坑绕开的办法就是要么 用 bun 直接跑,要么确认 node 真的指向官方 Node 而不是某个 shim。

输出格式JPEG / PNG

JPEG默认 PNG
透明通道 不支持,合成到「铺底色」上(默认白,可选黑/自定义色) 完整保留
体积 明显更大(无损格式的通性,跟这个工具无关)
版权元数据Artist/Copyright 支持(写 EXIF+XMP 不支持v1.1 没做,见下面「已知限制」)
暗水印 全图参与投票 只在近不透明块(块内最小 alpha ≥ 240里嵌入透明/半透明区域整块跳过

PNG 模式下暗水印为什么要跳过透明/半透明块:全透明像素的 RGB 数值本来就是 "幽灵值"(很多工具重新编码时会清空或做预乘改写,嵌在这种像素上的水印必死 无疑);半透明像素的数值会随着下游怎么合成而变,票不可靠。近不透明块的 判定标准嵌入和提取用的是同一套规则(ALPHA_OPAQUE_THRESHOLD src/lib/core/blockWatermark.tsPNG 提取时 alpha 通道还在,能重新按 同样规则判一遍;如果图片中途被下游铺底转成了 JPEGalpha 丢了),解码 回来的像素 alpha 全部是 255同一套判定规则会让所有块自动变成"参与" 自然退化成全块投票——原本近不透明块的数值在铺底后几乎不变,这些块的票 依然可靠,配合冗余投票兜底,见下面「攻击实测数据表」里的透明图退化路径 实测数据。

部署

npm run build 产出的 dist/ 是纯静态文件,扔到任意静态文件服务器/VPS 目录/对象存储都能用,没有后端、没有环境变量、没有构建期之外的服务端依赖。

  • 静态目录 / Nginx / Caddy:把 dist/ 内容原样拷到站点根目录即可。 Service Workersw.js)要求跟 index.html 同源同路径下可访问。
  • HTTPSmanifest.webmanifest 里配了 share_target(系统分享面板 "分享到 pic-guard"),这个能力要求 HTTPS 部署(或 localhost 本机调试) —— 浏览器规范如此HTTP 明文站点上 Service Worker 和 share_target 都不会 注册生效其余处理功能不受影响纯前端逻辑HTTP 也能跑。
  • 开源仓库:委托人后续发布后在这里补链接(src/App.svelte 页脚也留了 占位文案)。

建议的服务端响应头(可选,比 meta 标签 CSP 更强)

index.html<meta http-equiv="Content-Security-Policy"> 自带了 CSP 静态托管场景下没法额外配响应头也完全够用。如果部署环境能设置 HTTP 响应头 Nginx/Caddy/对象存储的 CDN 规则),建议再加一份等价的 Content-Security-Policy 响应头——不是因为 meta 标签不够强,是双重声明能防住"页面某处不小心被改出一段 在 meta 标签解析之前执行的内容"这种边缘情况,纵深防御。

使用说明

处理页:拖图或点击选择(支持多选)→ 有实时预览(用第一张选中的图, 改任何明水印参数都会立刻重绘,不用真的跑一遍处理)→ 按需改「明水印文字」 「暗水印内容」「输出格式」(都可留空/用默认)→ 点「开始处理」→ 下载 (多张图会额外提供打包下载 zip。高级选项折叠在两个面板里默认值不改 也能用:

  • 「明水印高级设置」——明水印的配置全在这一个面板里:位置(角标/中部大字/ 全图平铺)、字号、透明度、字体(苹方/宋体/楷体/圆体/黑体预设,或自定义 字体名)、字重、平铺密度、倾斜角度、文字颜色、描边宽度/颜色。
  • 「高级选项」——跟明水印无关的其余项:作者/版权(分开填,见下一段)、 最大边长、导出质量JPEG、暗水印强度以及「恢复默认设置」。

作者 Artist 和版权 Copyright 是两个独立字段:约稿场景里画师(作者)和 委托方(权利方)通常是两个人,圈内惯例分开署名。「作者」写 EXIF Artist + XMP dc:creator;「版权」写 EXIF Copyright + XMP dc:rights。两个都 可以空,互不影响,只有 JPEG 输出支持PNG 见上面「输出格式」表格)。

明水印角标避空:位置选「角标」时,如果默认的右下角刚好落在图片的 透明/镂空区域PNG 模式),会自动换到画面里比较"实"的一角(依次试 左下/右上/左上,都不够实就选最实的那个将就用)。「全图平铺」目前不做 这个(成本高,见已知限制),「中部大字」本来就在正中央,不受这个影响。

验印页:拖入一张图 → 自动尝试提取暗水印文字,同时分别显示图片里的 Artist 和 Copyright如果写过。提取不到会显示"未检出"并注明局限(见 下面「已知限制」),不会瞎猜。

参数记忆:上面提到的所有参数改了就自动存 localStorage,下次打开 自动恢复;「高级选项」面板里有「恢复默认设置」按钮。只存这些参数, 绝不存图片数据或处理结果——跟"图片不离开浏览器"的承诺一致。清浏览器 数据/隐私模式下会退回默认值,不影响处理功能本身。

分享进来的图片HTTPS 部署后,手机/电脑上其他 App 的系统分享面板能 直接"分享到 pic-guard",图片会自动出现在处理页等待处理(原理见下方 "分享目标")。

技术路线(暗水印)

详细的路线选择理由、攻击实测数据表、逐参数调优过程见交付报告正文;这里 只放摘要,完整依据都在代码注释里(尤其是 src/lib/core/blockWatermark.tshash.ts 顶部)。

  • 路线:自研纯 TS 的 8x8 块 DCT 中频/低中频系数关系量化盲水印Koch-Zhao 类方法)+跨块冗余投票,路线 b。评估路线 aOpenCV.js 复刻 DWT-DCT-SVD 后判断实现/验证风险不划算就没走那条路——OpenCV 本身不提供小波变换, 真要走 a 也得自己手写 DWT只是把 DCT+SVD 交给 OpenCV.js却要为此背上 一个 8-10MB 的 WASM 下载和 Embind 内存管理的调试成本,对一次性交付、有 硬性数值门槛的任务来说不划算。
  • 消息帧:定长 280 位8 位长度 + 32 字节负载 + 16 位 CRC 校验), 每一位靠哈希把图片里的每个 8x8 块分配到某个消息位上投票,不依赖任何 边信道信息。
  • 缩放攻击的应对8x8 块 DCT 对块边界相位极其敏感——图片缩放后新旧 网格对不齐,中频系数的大小关系会被基本随机化(实测跌到接近随机水平, 不是"变弱"而是"错位")。验印页因此内置了一个重同步搜索:先按图片原生 分辨率提取一次失败就假设图片是从本工具几个常见预设长边1600/2000/ 1200/…)缩放来的,反推目标尺寸重新采样再试。这不是通用的抗缩放方案, 依赖"图片确实来自本工具的常见预设"这个前提,报告里如实说明了适用范围。
  • v1.1alpha 感知的块参与判定——PNG 保留透明通道时8x8 块按"块内 最小 alpha ≥ 240"分参与/不参与,只在近不透明块上嵌入/投票,原理和已知 限制见上面「输出格式」一节。
  • v1.1:提取投票从"取符号"改成"按幅度加权(夹到 ±1"——原来 Math.sign(diff),每块要么 +1 要么 -1不管差距多大都算一票现在 clamp(diff/DEFAULT_DELTA, -1, 1),真被嵌过的块(差值被强制拉到 ≥delta依然稳稳夹到 ±1 封顶,没被嵌过、只是碰巧参与投票的块(典型场景: 透明图铺底转 JPEG 后全图退化成全块投票,见「输出格式」一节)差值是自然 图像统计意义上的小值,加权后贡献远小于 ±1——相当于给"真信号"和"环境 噪声"按置信度区别对待。改这个是因为调透明图退化路径时发现纯符号投票 逐位准确率只有约 96%(不够撑起 150+ 位的整帧成功率,实测退化路径成功率 接近 0%),换成幅度加权后逐位准确率变成 100%,退化路径实测成功率也从 0% 变成 100%(见下面攻击实测数据表)。改动前后把 v1.0 已经过关的三项 门槛q0.6 重压缩、缩放 80%、裁边)完整重新跑过一遍确认没有回归。

攻击实测方法

npm run test:attacktest/node/run-watermark-attack-report.ts

  1. 程序生成 12 张测试图(渐变/噪声/图形混合三种混合,没有用任何真实 图片,种子固定、可复现)。
  2. 用生产同款代码(src/lib/core/blockWatermark.ts,不是另外抄一份) 嵌入水印,导出成 quality 0.85 的 JPEG对齐流水线默认值作为"下载 到手的文件"这个基准。
  3. 对基准分别做三种攻击JPEG 重压缩到 quality 0.6;等比缩放到 80% 再以 quality 0.85 重新压缩;四边各裁 5%。攻击变换用 sharp libvips在 Node 侧完成——这是本项目唯一一处"不是浏览器原生同款代码" 的地方(生产环境走 Canvas 原生 drawImage/toBlob),选 sharp 是因为 它的编解码质量比手搓重采样更接近真实图像编辑器/相册 App 的行为。
  4. 用同一套提取代码(含验印页会用到的重同步搜索)解水印,跟原文比对, 统计成功率。

test/unit/attacks.test.ts 是同一套方法的小样本版本,跟着 npm test 一起跑,作为改动后的常规回归防护;完整的达标判定以 npm run test:attack ≥10 张图)为准。

最新一次 npm run test:attack 结果(DEFAULT_DELTA=20

攻击 成功率 门槛
无攻击(基准) 100%
JPEG 重压缩至 q0.6 100% ≥90%
缩放到 80% + 重压缩 100% ≥80%
四边各裁 5% 0% 无门槛,已知局限

v1.1:透明图两条链路实测

同一个脚本额外生成 12 张带透明通道的测试图(中心不透明"主体" + 半透明"光效"过渡环 + 全透明背景,模拟光效/水感构图,同样不用任何真实 图片),测两条链路,都不设硬门槛,如实报告数字:

链路 说明 成功率
PNG 往返 嵌入后不铺底,直接从(还带 alpha 的)结果提取——对应"输出选 PNG" 100%
铺白底退化 嵌入后铺白底、导出 JPEG q0.85、提取——对应"透明图最终被下游转成了不透明 JPEG" 100%

退化路径这 100% 是换成幅度加权投票之后的结果(改动前用原来的取符号投票, 这条链路实测成功率接近 0%——不是"差一点过",是原来的设计压根扛不住透明 图退化场景详细原因见上面「技术路线」v1.1 那两条)。测试图里"参与投票 的块数/总块数"典型比例在 20%-30%(其余是透明/半透明区域),具体数字 npm run test:attack 的输出表格里逐张列了。

已知限制

  • 大幅裁剪 / 截图重拍会破坏暗水印。 裁剪会打乱 8x8 块的网格对齐(详见 上面"缩放攻击的应对"一节的原理说明),实测四边各裁 5% 就已经完全失效 (见上表);截图重拍本质是"重新拍摄+可能带透视变形+二次压缩"的复合攻击, 比测过的任何单项都狠,同样防不住。这是块状 DCT 盲水印这一类方法的通性 限制,不是这个实现独有的问题。
  • 验印页的缩放重同步是启发式搜索,不是通用抗缩放。 只在"图片确实是从 本工具常见预设尺寸缩放来的"这个前提下才可能命中,任意比例缩放/非本工具 产物不保证能找回来。
  • 暗水印不是在所有内容上都"肉眼无感",纯色/极简内容上能看出细纹路。 默认强度(DEFAULT_DELTA=20)是拿嵌入前后的像素直接(不经 JPEG 压缩) 目视对比调出来的:纹理丰富的内容(照片、带光影层次的插画、噪声类测试图) 基本看不出变化;大面积纯色/极简渐变(比如简单配色的插画、纯色背景角色图) 仔细看能看到一层淡淡的斜纹路PSNR 实测约 38dB。调高强度纹路会更明显但 更抗压缩,调低能减轻纹路但会更接近抗性门槛的边界——高级面板里的"暗水印 强度"就是留给这个取舍的,默认值没有为了凑"绝对看不出来"去牺牲抗性余量, 也没有为了抗性余量而假装纯色图上完全看不见,两头都如实说。测试图刻意选 了纯色图形这种最不利情形,真实照片/带光影插画的实际观感会好得多。
  • 暗水印会明显增大文件体积,且跟原图内容强相关。 实测同一张 1600x1200 图,纹理丰富(噪声类)的图加水印前后体积几乎不变(约 -1%);大面积纯色/ 平滑渐变的图(比如简单配色的插画、纯色背景角色图)加水印后体积可能涨 2-5 倍。原因跟上一条一样:嵌入会在几乎每个 8x8 块上叠加中低频扰动,对本来 极易压缩的平滑区域影响更明显,对本来就复杂的纹理区域几乎不增加信息量。
  • 中文版权字段的 EXIF 兼容性。 EXIF 的 Artist/Copyright 字段标准 只支持 ASCII中文按 UTF-8 字节直接写入是很多真实工具的常见做法,但不 保证所有读取端都正确显示Windows 属性面板这类老实现可能出现乱码)。 同时写了 XMP 的 dc:creator/dc:rights(原生 UTF-8无此问题验印页 和多数现代工具Adobe 系列、exiftool、大多数看图软件)都优先读 XMP 建议以 XMP 结果为准。EXIF/XMP 元数据在很多社交平台上传时会被平台自己 的处理流程整体剥离——这不是这个工具能控制的。
  • PNG 输出不支持写版权元数据v1.1 新增限制,范围控制不是疏漏)。 PNG 的元数据机制(eXIf/iTXt chunk跟 JPEG 的 APP1 段是完全不同的 二进制格式,core/exif.tscore/xmp.ts 都是照 JPEG 结构写的,这次没 为 PNG 另外实现一遍。选 PNG 输出时作者/版权输入框会被禁用并提示原因, 不会让你填了却发现没写进去。
  • 明水印"全图平铺"不做透明区域避让。 角标位置有避空(见「使用说明」), 平铺模式没做——要做到"只在不透明区域平铺"需要按 alpha 逐格判定该不该画 这个字,计算量和实现复杂度都明显更高,这次评估后没做,跟委托方沟通过、 按"成本高可不做"的范围界定处理。平铺模式贴在透明区域上的文字理论上还是 会画出来(半透明区域会跟着透明度变化,全透明区域文字本身也是半透明的, 不会特别突兀,但也没有刻意躲避)。
  • Canvas 相关代码没有自动化测试覆盖。 图片解码/降采样/明水印绘制/ 透明合成这几步依赖真实 Canvas 2D本机没有能装的无头 Canvas 环境 (试过 node-canvas,编译时缺 pixman/cairo 系统库),这部分靠 TypeScript 类型检查(svelte-check 覆盖所有 .svelte/.ts 文件)加 人工代码审查兜底不是自动化测试。核心算法DCT、盲水印嵌入/提取、 alpha 参与判定、文本编解码、EXIF/XMP 读写、攻击变换)有完整单元测试 覆盖,见 npm test;角标避空的"选哪个角"决策逻辑抽成了纯函数 chooseCornerIndex),单独测了,真正调用 Canvas 采样的部分仍然只有 类型检查+人工审查。
  • 主线程处理,没有用 Web Worker。 大图或大批量处理时DCT 运算在主 线程跑,处理过程中页面可能短暂卡顿;批量处理逐张进行且每张之间让出一帧, 尽量给进度条/交互留响应空间,但没有做到完全不卡。实时预览额外做了一层 "世代计数器"防止连续改参数时旧的异步渲染结果覆盖新的,但预览本身也在 主线程画,快速连续拖动滑杆时可能有轻微卡顿。

分享目标share_target原理

manifest.webmanifest 声明了 share_targetPOST + multipart/form-data 接收 images 文件字段)。因为是纯静态站点、没有后端能接收 POST实际是 sw.js 里的 Service Worker 拦截这个 POST 请求,把文件暂存进一个专用 Cachepic-guard-share-inbox303 重定向回 /?shared=1;处理页加载时 检测到这个查询参数,从 Cache 里取出文件、清空暂存、把地址栏 URL 还原干净。 全程不经过任何服务器,暂存的也只是本地 Cache不是真的"上传"。

项目结构

src/
  lib/core/         核心算法,纯 TS、不碰 DOMDCT、色彩空间、盲水印嵌入/
                     提取(含 alpha 感知、文本编解码、EXIF/XMP 读写、哈希。
                     浏览器和 Node 测试脚本共用同一份源码。
  lib/pipeline/      流水线编排 + Canvas 相关代码(浏览器专属):降采样、
                     铺底色/保留 alpha 两条路径、明水印排版绘制、JPEG/PNG
                     导出。
  lib/fonts.ts       明水印字体预设列表(系统字体栈,不打包字体文件)。
  lib/components/    Svelte 组件(含 ui/ 下的 shadcn 风格基础组件、
                     ColorPicker/Select 这两个 v1.1 新增的表单控件、
                     WatermarkPreview 实时预览)。
  lib/views/         处理页 / 验印页两个顶层视图。
  lib/stores/        Svelte 5 rune 状态(设置持久化)。
test/unit/           vitest 单元测试。
test/node/           Node 侧测试工具:测试图生成器(含带透明通道的)、
                     攻击模拟sharp、攻击实测报告脚本、耗时/体积实测脚本。
scripts/             一次性构建工具脚本(图标生成),不参与运行时。
sw-template.js       Service Worker 源模板,构建时注入实际的预缓存文件列表。

隐私 / 安全一句话总结

图片数据只存在于内存和你主动点「下载」时写入的文件里;localStorage 只 存界面参数水印文字这类不存图片Service Worker 缓存的是这个网页 自己的代码文件,不是你的图片;没有任何分析、埋点、错误上报。