做工地巡检、门店巡店、设备维保这类带凭证性质的拍照记录时绕不开一个诉求照片本身要能证明我确实在这个时间、到了这个地点、拍了这张图。很多人第一反应是装一个水印相机 App随手拍完就完事。但只要稍微往项目里落一层问题就来了——照片要进自己的系统、要和工单关联、要批量归档、还要在后台重新合成一遍App 里那点输出能力根本不够用。我最近就完整做了一遍仿水印相机样式的图片加水印功能把定位、时间、备注这三块信息叠到原图上前端跑 Canvas、后端跑 Sharp 双线并行中间踩的坑比想象中多得多。这篇文章不讲某个具体框架的用法而是把图片添加水印 水印相机版式 定位时间备注这条链路拆开讲一张工作水印到底该有哪些元素、时间和定位数据从哪来、Canvas 里怎么按顺序画、服务端怎么批量合成、以及哪些地方最容易翻车。如果你正在做巡检打卡、施工现场记录、外勤拍照存证、二手交易留痕这类功能或者单纯想给自己拍的照片套一个干净的相机水印这篇都能直接抄。看完你应该能拿出一版可上线的实现而不是只有一张截图。1. 先把水印相机的版式拆开看别急着写代码1.1 工作水印的四个信息块和它们的优先级大部分人理解的加水印就是右下角糊一行半透明文字。真按这个做出来放到实际场景里立刻会被打回来领导看不见时间、审核看不出地点、备注写长了直接被裁掉。正规的水印相机版式本质是把一条结构化记录渲染成图形它至少包含四块信息优先级从高到低分别是时间年月日 时分秒这是所有凭证类照片的第一要素缺了它整张图就失去意义。定位地点名称 详细地址通常分两行第一行是 POI 名称第二行是行政区划加街道。备注用户手填的自由文本比如3 号配电箱更换完毕门店 A 货架陈列检查长度不可控是最容易出事的一块。附属标识拍摄人、项目名、编号、自定义 Logo 或二维码属于可选项但一旦需要就得预留位置。我见过的失败案例里八成是只做了时间和定位把备注塞进一个固定宽度的小框结果用户输入三十个字直接溢出画布或者被粗暴截断成半句话。所以第一步不是选库而是先把这四块的层级关系和空间预算定下来时间最醒目但字数固定定位占两行且可能很长备注需要动态换行附属标识要留边距。1.2 字号、边距和底纹条的比例怎么定才耐看版式好不好看关键在比例而非绝对值。我用下来最稳的一套规则是以图片短边为基准做等比换算而不是写死像素值。原因很简单手机拍的图短边可能是 1080单反导出可能是 3000写死字号在两张图上观感完全不同。具体的经验取值我整理成一张表你可以直接拿去当初始参数再按自己的品牌调性微调元素相对短边的比例说明主字号时间3.2% - 4.0%再小在缩略图里就认不出来副字号定位、备注2.4% - 3.0%比主字号小一档形成层次内容左内边距3.5%和底部边距保持一致视觉才齐行间距字号的 1.35 - 1.5 倍中文行距小于 1.3 会挤成一坨底纹条高度内容高度 上下各 2.5%不要卡死等于内容高度圆角半径短边的 1.2% - 2.0%太小显生硬太大显廉价还有一个容易忽略的点底纹条不能贴着图片边缘。我一般留短边 2% 左右的四周安全边距底纹条离图片下边缘也留 2%视觉上像是贴了一张悬浮卡片而不是糊在画面上。这个边距在横构图和竖构图上都要成立所以基准取的是短边而不是宽度——竖图取宽横图取高两个方向观感才一致。1.3 底纹条为什么要做渐变而不是纯色纯黑背景加白字是最省事的做法但在浅色照片上会显得特别突兀像贴了块膏药。我的处理是底部对齐的竖向渐变遮罩 独立的信息卡片两层结构第一层是从图片底部往上、由深到透明的线性渐变高度大约是图片高度的 18% - 25%作用是把画面底部的杂乱信息压下去让水印区域干净第二层才是承载文字的半透明圆角卡片透明度控制在 0.35 - 0.5 之间。这个双层结构的好处是即使原图底部是纯白文字依然可读即使原图底部是纯黑渐变层也不会显得多余因为它本身就是透明的。我做的时候先渲染渐变层再渲染卡片最后画文字顺序不能乱。有些人图省事直接在文字上加 text-shadow在复杂背景上效果很差尤其是拍瓷砖、地砖这类高频纹理时阴影会被吃掉。2. 时间和定位这两块数据从哪来、怎么处理2.1 时间戳设备时间、服务端时间和格式化时间看着最简单其实最容易埋雷。直接用new Date()取设备时间在真实环境里至少有三个坑用户的手机时间可能是错的手动改过、时区错乱、飞行模式后漂移用户可能在国际时区格式化出来差好几个小时服务端补合成时如果又取了一次时间就会和前端显示的对不上。我的做法是以服务端时间为准前端只负责占位和预览。用户在客户端拍照时前端把当前的 UTC 时间戳带到上传请求里服务端做一次合理性校验——如果这个时间戳和服务端时间的偏差超过一个阈值比如 10 分钟就以服务端时间为准并在日志里记一笔。这样既尊重了拍摄瞬间这个语义又防止了明显的错乱。格式化上别用字符串拼接中文式的时间展示和日期库的默认输出差别很大。我最终用的是这套function formatStamp(ts) { const d new Date(ts); const p (n, len 2) String(n).padStart(len, 0); const date ${d.getFullYear()}年${p(d.getMonth() 1)}月${p(d.getDate())}日; const time ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}; const week [星期日,星期一,星期二,星期三,星期四,星期五,星期六][d.getDay()]; return { date, time, week }; }分成三段返回是有意的版式上我习惯第一行放2024年5月20日 星期一第二行放14:32:08比挤成一行好看得多而且时间那一段可以用更大的字号做视觉重心。注意不要用toLocaleString直接格式化不同运行环境的默认输出格式不一致服务端和客户端渲染出来的字符串可能完全不同这在做前端预览、后端合成时会造成两边不一致的诡异问题。2.2 定位数据的三个坐标概念混了就会偏定位这块是整条链路里最绕的。你从设备拿到的经纬度和你在图上看到的地图 SDK 输出的经纬度很可能不是一套坐标系。常见的三套是设备 GPS 原生的坐标国际通用标准、国内地图服务常用的加密坐标、以及部分地图厂商在加密坐标基础上再做一次偏移的坐标。三者之间互有转换公式直接混用会导致标记点偏移几百米在地图上看起来完全不是同一个位置。处理原则很简单在数据链路上固定一套坐标只在渲染和展示的边界做转换。我一般在存储时统一保存原始坐标展示给用户看地址时调用逆地理编码服务返回的地址文本直接落库。水印上画的是地址文本而不是经纬度数字所以只要逆地理编码这一环用对了坐标偏移问题就不会暴露在最终图片上。真正会暴露的是另一个问题逆地理编码返回的地址层级太多。比如返回某某省某某市某某区某某街道某某路 123 号某某大厦 A 座直接画上去三行都不够。我的处理是做一层地址裁剪策略优先保留 POI 名称 区 街道 门牌号省市级在大多数场景下可以省掉因为审核的人通常知道项目在哪个城市。如果用户对精度有要求就在备注里手动补充。2.3 权限被拒、定位失败之后的降级策略这条我要单独说因为它涉及一条底线水印只能记录真实的采集结果不能为了好看去编造数据。真实环境里定位失败太常见了用户关了定位权限、在室内或地下车库没有信号、系统定位服务被临时禁用、逆地理编码接口超时。这些情况下正确的做法是降级而不是伪造。我的降级顺序是这样的情况处理方式水印上呈现有坐标 逆地理成功正常渲染POI 名称 详细地址有坐标 逆地理失败保留坐标不展示地址显示经纬度保留四位小数无坐标 权限被拒提示用户手动填写地点显示地点手动填写或用户输入内容无坐标 超时原样降级只显示时间定位行隐藏表里第三种情况是我实际用得最多的让用户自己填一个地点描述和备注字段走同一套输入组件。这样既保证了信息的真实性是用户自己声明的也避免了图片上出现空白行。第四种情况隐藏定位行则要注意版式要能自适应行数不能因为少了一行就留一块空白这个在下一节讲绘制时会具体说。3. Canvas 绘制流水线顺序错了效果全毁3.1 第一步永远是解码和方向校正从手机相册、相机、系统文件选择器拿到的图片可能带着方向标记。同一张竖拍照片在系统相册里看着是正的你用Image对象加载后画到 Canvas 上出来可能是侧躺的。原因是图像传感器按固定方向写入像素方向信息记在元数据里由看图软件负责旋转。处理方式取决于你的运行环境。如果是浏览器createImageBitmap支持带方向解码// 让解码阶段就把方向信息应用掉 const bitmap await createImageBitmap(file, { imageOrientation: from-image }); canvas.width bitmap.width; canvas.height bitmap.height; ctx.drawImage(bitmap, 0, 0);如果是服务端像 Sharp 这类库默认会根据元数据自动旋转但要注意有些版本的行为是读取时旋转、写回时又保留原方向标记结果就是双重旋转。稳妥的做法是显式调用一次旋转再清理方向标记别依赖默认行为。这一步千万别跳过。我就吃过这个亏本地测试用的全是电脑上截的图方向标记是 1正常上线后用户传的全是手机竖拍图方向标记是 6水印直接横在了画面中央。排查了半天才意识到是解码阶段的问题。3.2 绘制的四层顺序和每层的作用Canvas 是后画的盖住先画的所以顺序就是一切。我固定用四层结构原图层图片本身按等比缩放绘制。如果是留白式加水印水印不覆盖原图而是在原图下方扩展一块区域这一步还要调整画布尺寸。渐变遮罩层从底部往上、由不透明黑色到全透明的线性渐变。这一步的目的是压暗画面底部让后续文字有对比度基础。信息卡片层半透明圆角矩形带一点点模糊或描边。绘制前一定要先计算好卡片需要多高——它取决于文字的行数而行数取决于定位和备注的长度。文字层分行绘制时间用主字号定位和备注用副字号。这里的关键是第 2 步和第 3 步的先后。我见过有人把卡片画在渐变下面结果卡片边缘被渐变盖住一部分看起来像没画干净。也有人只画卡片不画渐变在白墙背景的照片上文字直接糊进背景里。还有个小细节圆角矩形的 API 支持度。新版本浏览器有ctx.roundRect老环境得自己用arcTo拼路径。写一个封装函数内部判断一次就行别在每个绘制点都判断。function roundRect(ctx, x, y, w, h, r) { if (typeof ctx.roundRect function) { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); return; } ctx.beginPath(); ctx.moveTo(x r, y); ctx.arcTo(x w, y, x w, y h, r); ctx.arcTo(x w, y h, x, y h, r); ctx.arcTo(x, y h, x, y, r); ctx.arcTo(x, y, x w, y, r); ctx.closePath(); }3.3 长文本换行中文不能按空格切备注字段是自由文本用户可能写已完成三个字也可能写一段四十字的说明。自动换行的实现要特别注意两点第一中文没有空格不能按split( )切。正确做法是逐字符累加、用measureText测量当前行宽度超过最大宽度就换行。这个算法对中英混排同样适用只是要注意英文单词尽量整体换行别在高频位置断词。第二要设最大行数。备注如果允许无限换行卡片高度会失控可能把整张图都盖住。我的处理是最多三行超出部分用省略号截断完整的备注文本另外存进数据库通过编号或者二维码关联回原记录。这样图片干净信息也不丢。function wrapText(ctx, text, maxWidth, maxLines 3) { const lines []; let line ; for (const ch of text) { if (ctx.measureText(line ch).width maxWidth) { lines.push(line); line ch; if (lines.length maxLines) break; } else { line ch; } } if (lines.length maxLines line) lines.push(line); // 超出部分截断并补省略号 const consumed lines.join().length; if (consumed text.length) { lines[maxLines - 1] lines[maxLines - 1].slice(0, -1) …; } return lines; }measureText是逐字符调用的文本长了会有性能开销。如果备注普遍很长可以先粗略估算一个字符宽度中文字符约等于字号只在临界点做精确测量能省下不少调用。3.4 卡片高度必须在绘制前算出来因为卡片要包住文字所以必须先算行数再算高度最后才画卡片和文字。这是我一开始没想清楚的地方写成了先画卡片再画字结果文字一多就溢出卡片看起来像水印漏了。正确的计算顺序是先把时间、定位、备注各自拆成行数组加上行间距和上下内边距得到内容总高度再根据这个高度画圆角卡片最后按行基线逐个绘制文字。行基线用textBaseline top配合手工累加 y 坐标最省心比默认的 alphabetic 基线好控制。提示ctx.textBaseline是全局状态绘制过程中如果被其他地方改过会直接影响下一处文字的位置。养成绘制前设置一次的习惯比事后排查坐标偏移省事得多。4. 服务端合成批量、字体和 SVG 的坑4.1 什么时候该把合成搬到服务端前端 Canvas 方案适合用户当场拍、当场看、当场存的场景。但下面几种情况你必须把合成搬到服务端需要在上传后统一重排前端拍的图用户没确认好后台要重新生成一版带审核编号的水印。需要批量归档历史照片导入系统后统一补打水印。需要防止篡改水印由服务端生成客户端拿不到绘制逻辑用户没法在本地改图。需要多端一致性同一条记录在 App、小程序、后台看到的图应该完全一样。服务端方案的常见组合是图像处理库 SVG 模板叠加。图像处理库负责解码、缩放、合成、编码SVG 负责描述水印的形状、文字和位置因为它是矢量描述缩放不糊改样式也方便。4.2 SVG 里的文字有两个致命细节细节一文本必须转义。备注是用户输入里面可能有、、、引号。这些字符直接塞进 SVG 字符串会破坏 XML 结构轻则渲染错乱重则整个合成失败。所以往 SVG 里插值之前必须做一次转义function escapeXml(str ) { return String(str) .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, apos;); }这个坑非常隐蔽因为大部分测试文本都不含特殊字符。等真实用户写了个更换 3#4# 线路就炸了排查时只看代码根本看不出来得盯着日志里的 SVG 原文才行。细节二中文字体。这是服务端方案翻车率最高的一点。图像处理库渲染 SVG 依赖系统字体如果服务器上没装中文字体所有中文都会渲染成方框俗称豆腐块。本地开发机通常自带字体所以本地跑得好好的一到容器里就全是方块。解决方案是在镜像里显式安装字体并刷新字体缓存。基础镜像通常是精简版什么字体都没有必须手动装一个中文字体包再跑一次字体缓存刷新命令让渲染引擎能索引到。验证方法也很直接在服务器上列出当前可用字体确认中文字体在列表里如果不在就说明装的位置或者缓存不对。另外SVG 里的font-family最好写成一个明确的字体名而不是一串兜底列表。渲染引擎对 CSS 字体列表的支持程度不如浏览器写多了反而可能匹配失败退回默认字体。4.3 SVG 尺寸和合成位置的边界条件合成水印时SVG 画布的尺寸需要覆盖水印区域位置参数需要是整数。有两个容易忽略的边界其一SVG 尺寸不能超过底图。如果原图很小比如缩略图而你按短边比例算出来的水印区域超出了底图范围合成库会直接报错。稳妥做法是对水印尺寸和位置做一次钳制保证水印区域完全落在底图内必要时按底图尺寸重新计算比例。其二位置参数必须是整数。浮点数在某些版本里会触发校验失败。比例算完之后统一Math.round一下这个习惯能省掉一堆莫名其妙的报错。const meta await sharp(inputBuffer).metadata(); const shortSide Math.min(meta.width, meta.height); const fontSize Math.round(shortSide * 0.036); const left Math.round(shortSide * 0.035); const bottom Math.round(shortSide * 0.02); const top Math.max(0, Math.round(meta.height - bottom - cardHeight));4.4 批量处理时的并发和内存控制批量补打水印时最容易犯的错是一把梭并发。图像处理是内存密集型操作一张 4000×3000 的图解码后占用的内存远不止文件体积那么大。并发数开到几十机器内存直接被打满进程被系统杀掉表现就是处理到一半卡住不动了。我的经验值是按 CPU 核心数来控制并发通常取核心数的 1 到 1.5 倍同时给每张图设一个最大像素限制超过就先等比缩到上限再处理。处理完的图要及时释放引用别在数组里堆着。如果你用的是队列方案建议加上这三个字段任务总数、已完成数、失败列表。批量任务失败是常态个别文件损坏、格式不支持、色彩空间异常失败列表能让运维快速定位是哪几张出了问题而不是重跑一遍全部。5. 真正会翻车的地方和完整的排查思路5.1 第一张图没有水印第二张之后正常这个现象的根因几乎可以确定是字体加载的时序问题。在浏览器里绘制文字前字体可能还没加载完Canvas 会退回系统默认字体渲染甚至渲染出空白。之后字体加载完成后面的图就正常了。排查链路是这样的先确认第一张图是不是完全没有水印还是水印在但字体不对。如果完全没有说明绘制时字体还没加载Canvas 对未加载字体做了一次静默失败如果字体不对说明渲染发生了但用的是兜底字体。修复方式是在绘制前显式等待字体就绪// 业务字体提前用 FontFace 加载绘制前确保就绪 const font new FontFace(WatermarkCN, url(${fontUrl})); await font.load(); document.fonts.add(font); await document.fonts.ready; // 等所有字体进入就绪状态需要注意document.fonts.ready只保证当前已经注册的字体就绪动态新增的字体要在 add 之后重新 await 一次。这个细节我调了好几次才对。5.2 水印上的地址和地图上看到的对不上这个问题的根源是坐标来源不同前面提过。但排查时有个更省事的判断方法看偏移方向是否一致。如果所有照片都往同一个方向偏了相同的距离基本可以确定是坐标系转换漏了一步如果偏移方向随机、距离不一那更可能是逆地理编码服务本身返回的 POI 精度问题比如它返回的是道路中心点而不是建筑点位。定位精度也值得关注。设备返回的定位结果通常带一个精度半径字段半径大等于几百米时逆地理编码出来的地址本身就不可靠。我的处理是给精度设一个阈值超过阈值的定位结果不展示详细地址只展示到区级或者提示定位精度较低。这比硬着头皮画一个错误地址要诚实得多。5.3 水印被裁掉、被压缩糊掉的排查表最终图片上水印出问题症状很多但根因很集中。我整理了一张对照表遇到问题可以直接按这个顺序查症状可能的根因排查动作水印只在部分图片上出现绘制时字体未就绪 / 图片解码失败打印每张图的绘制日志和字体状态水印偏出画面缩放系数计算取了宽度而非短边检查基准值横竖图分别验证水印文字模糊画布尺寸小于图片实际尺寸检查画布宽高是否等于原图像素尺寸文字被卡片裁掉先画卡片后算高度调整计算顺序高度由行数推导中文显示成方块服务端缺中文字体列出系统可用字体确认字体包已装水印区域出现彩色噪点JPEG 压缩质量过低导出质量提到 0.9 以上避免二次压缩水印随图片旋转错位方向标记未处理检查解码阶段是否应用了方向信息表里最值得一提的是画布尺寸小于图片实际尺寸这一条。Canvas 默认尺寸是 300×150如果你忘了设置canvas.width和canvas.height浏览器会把图片缩放进这个默认尺寸导致像素密度骤降水印文字边缘发糊。这个错误在视觉上非常像字体渲染问题很容易查错方向。5.4 导出格式和元数据要一起考虑合成完成后导出格式的选择会直接影响水印观感。JPEG 有损再三压缩会让文字边缘出现振铃PNG 无损但体积大。凭证类图片我一般用 JPEG质量设为 0.92 左右同时关闭逐层渐进编码避免某些老系统读取异常。文字边缘如果有明显的彩色噪点就是压缩质量太低提到 0.95 基本能消除。元数据这块有个实际取舍重新编码后原图的拍摄参数等元数据通常会丢失。如果你需要保留原始拍摄信息比如做设备溯源、拍摄参数归档可以在编码时把原始元数据重新写入但要记得清理掉方向标记否则看图软件会再旋转一次水印位置全乱。我在这一点上反复验证过最终的选择是保留拍摄时间、设备型号、原始拍摄参数丢弃方向标记和定位元数据因为定位信息已经以文本形式画在了图上。6. 让水印既好看又经得起推敲6.1 颜色、透明度和对比度的取值范围水印的好看程度八成取决于对比度控制。我的固定套路是文字用纯白不透明卡片背景用黑色透明度在 0.3 到 0.45 之间渐变遮罩从黑色的 0.55 衰减到 0。这个组合在绝大多数照片上都能保证文字清晰同时不会把画面压得太死。有人喜欢给文字加阴影来提升可读性我不推荐在复杂背景上用。阴影会让笔画边缘变脏尤其是在浅色底上。真要用就用极小的偏移和很低的透明度当作一点点立体感而不是当作可读性的补救手段。商标和 Logo 的处理则要克制。加自定义 Logo 时透明度比文字再低一档尺寸不要超过主字号的两倍位置固定在右侧对齐。见过一些实现把 Logo 做得比时间还大整张图看起来像广告海报完全失去了记录凭证的严肃感。6.2 平铺水印和隐蔽标记的思路如果目的是防盗用而不是记录信息版式就完全不同了要换成倾斜平铺式整张图铺满半透明的重复文字旋转一个固定角度30 度左右最耐看间距保持一致。实现方式是设置旋转后按网格循环绘制注意旋转中心要设在画布中心否则平铺会偏到一侧。平铺水印的透明度要压得很低0.08 到 0.15 之间既能在深色区域隐约可见又不影响图片本身的观看。这类水印的作用是让盗用者知道处理成本而不是真的无法去除所以不用追求极致强度。如果确实需要更隐蔽的溯源能力可以考虑在被裁切后仍能提取的隐蔽标记方案通过在图像变换过程中嵌入不易察觉的信息后续用对应算法反向提取。这类方案的实现复杂度高很多需要专门评估一般项目里用可视化水印加上记录编号关联就足够满足溯源需求了。6.3 我在实际项目里最常用的几条经验最后分享几条前面没展开、但实际最省事的做法。第一把水印绘制封装成纯函数。输入是图片对象、水印数据和尺寸参数输出是画布。所有和业务无关的东西都不放进去这样前端预览、离屏批量处理、Node 环境下用兼容方案模拟都能复用同一套逻辑改一处样式全端生效。第二参数全部外置成配置。字号比例、边距比例、圆角、透明度这些不要散在代码里集中成一份配置。不同客户、不同项目对水印样式的要求往往不一样外置之后换个配置就能适配不用改绘制代码。第三做一组固定的回归测试图。准备五类图纯白背景、纯黑背景、高频纹理、超长备注、横竖构图各一张。每次改绘制逻辑都拿这五张跑一遍看效果。这比肉眼看单张图可靠得多很多版式问题只会在特定背景上暴露。第四水印内容里带上记录编号。即使版式上不显眼也建议在角落放一个短编号或者在卡片右侧放一个小二维码。图片一旦脱离系统单独流转比如被转发到聊天工具里这个编号是唯一能把它和原始记录对上的线索。我做过一次线下核查就是靠这个编号把一张散落的现场照片对回了工单。第五关于定位这条线态度要比技术更重要。水印的价值在于它记录的是真实发生的事一旦为了让照片看起来更规范而引入不可信的数据整套机制就失去了意义。所以在权限被拒、定位失败、逆地理超时这些情况下老老实实降级提示、让用户手动填写远比想办法补一个坐标要正确。这也是我在项目评审时反复强调的一点水印功能的技术难度不高难的是在每一个异常分支上都守住真实性。