上传、等进度条、下载压缩包,这是在线加水印的标准三步。
我把这三步全砍了。图片不出浏览器,水印用 Canvas 画,导出直接落盘。
30 张图混着 800×600 到 3840×2160 一起跑,四次实测分别是 4.57 秒、4.66 秒、4.89 秒和 4.57 秒。
页面自带的三张示例图更快,0.53 秒就结束了。

在线案例与源码:atomgit.com/deli007/dem... 成品只有一个 index.html,双击就能用,不装任何依赖。
想自己看一遍:git clone 仓库后双击 index.html,或者用 python -m http.server 8000 起个静态服务。
一、为什么值得做:在线加水印的两段等待,本地一段都不用等
搜「图片加水印」,排在前几页的基本都是在线工具。
它们的工作流高度一致:先把图传上去,等服务器处理,再回来下载。
原图动辄好几 MB,几十张一起传,卡在进度条上的时间比处理本身还长。
更让人犹豫的是隐私。合同扫描件、证件复印件、还没上架的商品图,一旦上传就离开了自己的电脑。
本地 Canvas 的好处很直接:图片从来没离开过浏览器,快慢只取决于这台机器的 CPU。
这个页面就是照这个思路做的,单文件 1239 行,没有外链,没有 CDN。
二、准备环境:打开码道 Web,把需求写成一段话
浏览器打开码道 Web 版:devcloud.cn-north-4.huaweicloud.com/chat?source... ,登录后在对话框里写需求,就能拿到一个能跑的单文件应用。
码道有三种使用方式:WebUI(浏览器对话)、TUI(终端命令行)和桌面 IDE(IDE 插件)。本文用 WebUI 版演示。
需求我写了 7 条。事后看,最值钱的是第 1 条和第 6 条。
第 1 条把「本地处理、不上传服务器」写进了产品定位,第 6 条要求页面自带三张示例图。
有了示例图,打开页面不用翻硬盘找素材,点一下就能试。
三、需求原文(可以照着复制)
text
做一个纯前端的「批量图片加水印」网页工具,只要一个 index.html,零依赖,所有处理都在浏览器本地完成、图片不上传服务器。功能要求:
1. 支持拖拽或点击选择多张图片(png/jpg/webp),左侧是批量队列,每张缩略图显示文件名、尺寸和状态;
2. 水印设置面板:类型可切换「文字水印 / 图片水印」;文字水印可设内容、字号、颜色、不透明度、旋转角度、平铺间距;图片水印可上传一张 PNG 作为 logo,并设缩放比例和不透明度;
3. 布局模式两种:九宫格单点位置(九个位置可选)和全图平铺;
4. 中间大画布实时预览选中图片加水印后的效果;
5. 一键导出:按原分辨率批量导出并逐个下载,显示处理总耗时;
6. 顶部提供「载入 3 张示例图片」按钮,用 canvas 直接生成三张不同尺寸的彩色示例图(1200x800、1920x1080、800x800),方便没有图片时直接试用;
7. 输入校验:拖入非图片文件、水印文字为空、字号或间距为 0 或负数时,必须给出明确的错误提示,不要静默失败。
界面要完整好看,中文界面,深色渐变背景,布局清晰。
四、页面长什么样:左队列、中预览、右设置
打开就是三栏。左边是批量队列,中间是实时预览画布,右边是水印设置。
设置面板有两条分叉:水印类型分「文字水印」和「图片水印」,布局模式分「九宫格单点」和「全图平铺」。

平铺模式靠两个 for 循环就铺满了,用的还是同一套参数。

图片水印换成一张 PNG logo,缩放比例 18%。logo 的宽度按原图宽度算,换图不会跑偏。

五、实测:30 张图导出到底要多久
我造了一批不同尺寸的图丢进去,参数统一:56px 文字水印、180px 间距、全图平铺。
| 批次 | 图片组成 | 导出张数 | 处理总耗时 |
|---|---|---|---|
| 第一次 | 800×600 / 1200×800 / 1920×1080 / 1000×1000 / 640×480 各 6 张 | 30 | 4.57 秒 |
| 第二次 | 同上 | 30 | 4.66 秒 |
| 第三次 | 同上 | 30 | 4.89 秒 |
| 第四次 | 同上 | 30 | 4.57 秒 |
| 对照 | 页面自带的示例图 | 3 | 0.53 秒 |
四次之间差了 0.32 秒,这个波动比尺寸差异带来的影响还大,说明瓶颈不在某一种尺寸上。
按 30 张算,平均每张 0.15 秒左右。
导出是串行的,每张之间还留了 60 毫秒让界面刷新。
也就是说这 4.6 秒里有大概 1.8 秒是故意等出来的,真正画水印和编码的时间更短。
这个间隔我留着没删,不然后面几张一进循环页面就锁住,队列状态根本刷新不出来。
六、同一套参数,图越大水印越多
这段我单独量了一次,数的是 canvas 里 fillText 的调用次数。
| 原图尺寸 | 文字水印个数(56px) | 图片水印个数(18%) |
|---|---|---|
| 800×600 | 12 | 12 |
| 1920×1080 | 36 | 15 |
| 3840×2160 | 110 | 24 |
1200×800 那次铺了 16 个,介于第一行和第二行之间。
文字水印这一列涨得很快:800×600 铺 12 个,3840×2160 变成 110 个,差了 9 倍。
图片水印那一列几乎不动,同样放大到 4K,只从 12 个变成 24 个。
原因在参数口径上。文字水印的 56px 是绝对像素,跟图片尺寸没关系,图一大就得铺更多行更多列。
图片水印的宽度写的是 W * scale,跟着原图宽度走,所以格子数基本恒定。
想在小图上精确控制水印密度,用文字水印更合适;想让密度在大小图之间保持一致,图片水印更稳。
七、核心那段代码:两层循环铺满
平铺没有任何花哨算法,就是按格子走。
js
const stepX = bw + Math.max(1, state.spacing);
const stepY = bh + Math.max(1, state.spacing);
const sx = Math.max(1, state.spacing) / 2;
const sy = Math.max(1, state.spacing) / 2;
const cols = Math.ceil((W - sx) / stepX) + 1;
const rows = Math.ceil((H - sy) / stepY) + 1;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
drawRotatedText(ctx, txt, sx + c * stepX, sy + r * stepY, state.text.angle);
}
}
bw 和 bh 来自 ctx.measureText,量的是当前字号下这段文字的真实宽度。
字号乘 1.2 当行高,这一步不加的话上下两行会贴在一起。
不透明度不是单独的开关,它最后变成 rgba 的第四个参数。
js
function hexToRgba(hex, a) {
let h = String(hex).replace("#", "");
if (h.length === 3) h = h.split("").map(function (x) { return x + x; }).join("");
const n = parseInt(h, 16) || 0xffffff;
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
}
#fff 这种三位写法先展开成六位,再按位拆出 RGB。
导出链路的顺序是:先 drawImage 把原图画满整个 canvas,再 drawWatermark 盖上去,最后 canvas.toBlob 编码。
canvas 的尺寸直接取原图的自然宽高,所以导出的是原分辨率,不是预览那一版缩小过的图。
输出格式跟着源文件走,JPG 进 JPG 出,PNG 进 PNG 出,JPG 的编码质量固定在 0.92。
八、验证与踩坑:四条校验提示的原文
失败路径我一条一条点过去了,页面给的都是中文提示,不是静默失败。

四条提示的原文我抄下来了:
- 拖入非图片文件:
错误:有 1 个文件不是 PNG/JPG/WEBP 图片,已跳过 - 水印文字清空:
错误:水印内容不能为空,请输入水印文字 - 字号改成 0:
错误:水印字号必须大于 0 - 平铺间距改成 0:
错误:平铺间距必须大于 0
验证方式是把这几个值直接改掉再点导出,然后读页面上的提示节点。
第一次测我差点以为页面没校验。当时点完导出还等了三秒才去读,什么都没读到。
后来翻了代码才发现提示 2.8 秒后自动移除,是我看晚了。
第二个坑更重要。我一开始在 1200×800 的图上调好参数,换成 3840×2160 的图,水印小得几乎看不见。
56px 是写死的像素值,不会跟着图片放大,想铺满 4K 图得把字号手动往上调一大截。
这两个坑都没写进需求,是实际点出来的。
九、实测数据汇总
| 项目 | 实测值 |
|---|---|
| 单文件体积 | 46120 个字符,1239 行 |
| 外部依赖 | 1 个 script 标签,0 个 link 标签 |
| 资源请求 | 0 条 |
| 30 张导出总耗时 | 4.57 / 4.66 / 4.89 秒 |
| 3 张示例导出 | 0.53 秒 |
| 文字水印平铺(56px / 180px) | 800×600 铺 12 个,3840×2160 铺 110 个 |
| 图片水印平铺(18% / 180px) | 800×600 铺 12 个,3840×2160 铺 24 个 |
| 校验提示 | 4 条,全部为中文原话 |
「资源请求 0 条」是我用浏览器的性能接口复核的,从打开页面到导完 30 张图,资源列表一直是空数组。
十、本地复现
双击 index.html 就能用,不需要装任何依赖。
想走静态服务:python -m http.server 8000 ,然后访问 http://localhost:8000 。
想看它有没有偷偷联网,按 F12 打开网络面板再导一批图,列表里不会多出请求。
十一、说点不足
文字水印的字号是绝对像素,不跟着图片尺寸走,这是最不顺手的一处。
导出格式跟着源文件走,没有单独的格式下拉框,也没有质量滑块,JPG 固定 0.92。
水印位置只做了九宫格和全图平铺两种,拖拽定位和自定义坐标都没做。
文字只支持单行,换行、多行排版、描边这些一律没有。
本机内存就是上限。几十张 4K 图一起塞进来浏览器会明显变卡,没有做分批。
没有撤销,也没有缩略图批量预览,改一个参数整批重新算。
水印一旦导出就烧进像素里了,页面上不保留任何可编辑的水印图层,想改只能重新导一遍。
直接下载试玩
这个 Demo 已经完整开源,源代码、本文档和全部截图都在公开仓库里,可以直接下载来玩:
- Demo Park 仓库:atomgit.com/deli007/dem...
- 本案例目录:atomgit.com/deli007/dem...
打开上面的链接,进入 codearts-image-watermark-tool 文件夹,把 index.html 下载到本地,用浏览器打开即可运行。
十二、用码道做这个页面的体会
第一条是需求里把「不上传服务器」写成硬约束。
这三个字决定了整个技术选型,交回来的页面从头到尾没出现过一次网络请求。
第二条是让页面自带示例数据。
三张示例图是页面用 canvas 现画的,我调整参数和截验收图都靠它,不用翻硬盘。
第三条是边界条件写清楚。
我把「字号为 0」「间距为 0」「非图片文件」三种情况写进需求,交回来就是四条中文提示。
第四条是绝对值和比例值要分开看。
字号写绝对像素、logo 宽度写百分比,两种口径做出的平铺效果完全不同,这个差异我是量完才写清楚的。
第五条是数据要自己量一遍。
4.57 秒这个数字点了四次才敢写,中间那次 4.89 秒说明单次结果不足以当结论。
接下来想把字号改成「按原图宽度的百分比」再加一档预设,顺便把拖拽定位补上。