- TypeScript 68%
- Svelte 26.5%
- CSS 2.3%
- JavaScript 2.2%
- HTML 1%
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> |
||
|---|---|---|
| public | ||
| scripts | ||
| src | ||
| test | ||
| .gitignore | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| sw-template.js | ||
| tsconfig.json | ||
| tsconfig.node.json | ||
| vite.config.ts | ||
| vitest.config.ts | ||
图片守护 pic-guard
面向创作者(画师/约稿玩家)的图片发布预处理工具。拖入图片,本地完成 降采样 → 明水印 → 隐形暗水印 → 有损压缩去元数据(可选写入 Artist/ Copyright),一键下载。纯静态 PWA,全程在浏览器本地处理,不上传、 不联网。输出可选 JPEG(体积小,需要铺底色)或 PNG(保留透明 通道,体积较大)——画师圈常见的光效/水感/液态质感这类半透明元素,铺 白底会被冲灰,选 PNG 就不会。
项目名
pic-guard是占位名。改名需要同步改三处:src/lib/constants.ts的APP_NAME/APP_SHORT_NAME、public/manifest.webmanifest、index.html里的<title>。manifest 是静态 JSON,没法从常量文件引用, 这是唯一没法"改一处全改"的地方。
信任承诺是怎么自证的
- 页面顶部常驻提示"图片全程在你的浏览器本地处理,不会上传到任何服务器"。
index.html里有一条严格的 CSP:connect-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,不需要 tsx;sharp 这类原生插件在 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.ts),PNG 提取时 alpha 通道还在,能重新按
同样规则判一遍;如果图片中途被下游铺底转成了 JPEG(alpha 丢了),解码
回来的像素 alpha 全部是 255,同一套判定规则会让所有块自动变成"参与",
自然退化成全块投票——原本近不透明块的数值在铺底后几乎不变,这些块的票
依然可靠,配合冗余投票兜底,见下面「攻击实测数据表」里的透明图退化路径
实测数据。
部署
npm run build 产出的 dist/ 是纯静态文件,扔到任意静态文件服务器/VPS
目录/对象存储都能用,没有后端、没有环境变量、没有构建期之外的服务端依赖。
- 静态目录 / Nginx / Caddy:把
dist/内容原样拷到站点根目录即可。 Service Worker(sw.js)要求跟index.html同源同路径下可访问。 - HTTPS:
manifest.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.ts
和 hash.ts 顶部)。
- 路线:自研纯 TS 的 8x8 块 DCT 中频/低中频系数关系量化盲水印(Koch-Zhao 类方法)+跨块冗余投票,路线 b。评估路线 a(OpenCV.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.1:alpha 感知的块参与判定——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:attack 跑 test/node/run-watermark-attack-report.ts:
- 程序生成 12 张测试图(渐变/噪声/图形混合三种混合,没有用任何真实 图片,种子固定、可复现)。
- 用生产同款代码(
src/lib/core/blockWatermark.ts,不是另外抄一份) 嵌入水印,导出成 quality 0.85 的 JPEG(对齐流水线默认值),作为"下载 到手的文件"这个基准。 - 对基准分别做三种攻击:JPEG 重压缩到 quality 0.6;等比缩放到 80% 再以
quality 0.85 重新压缩;四边各裁 5%。攻击变换用 sharp
(libvips)在 Node 侧完成——这是本项目唯一一处"不是浏览器原生同款代码"
的地方(生产环境走 Canvas 原生
drawImage/toBlob),选 sharp 是因为 它的编解码质量比手搓重采样更接近真实图像编辑器/相册 App 的行为。 - 用同一套提取代码(含验印页会用到的重同步搜索)解水印,跟原文比对, 统计成功率。
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/iTXtchunk)跟 JPEG 的 APP1 段是完全不同的 二进制格式,core/exif.ts、core/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_target(POST + multipart/form-data,
接收 images 文件字段)。因为是纯静态站点、没有后端能接收 POST,实际是
sw.js 里的 Service Worker 拦截这个 POST 请求,把文件暂存进一个专用
Cache(pic-guard-share-inbox),303 重定向回 /?shared=1;处理页加载时
检测到这个查询参数,从 Cache 里取出文件、清空暂存、把地址栏 URL 还原干净。
全程不经过任何服务器,暂存的也只是本地 Cache,不是真的"上传"。
项目结构
src/
lib/core/ 核心算法,纯 TS、不碰 DOM:DCT、色彩空间、盲水印嵌入/
提取(含 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 缓存的是这个网页
自己的代码文件,不是你的图片;没有任何分析、埋点、错误上报。