
简介一款基于微信小程序实现的图片加水印工具源码面向小程序开发者、自媒体运营者及有图片版权保护需求的普通用户。小程序内置全屏水印、隐形水印、横幅水印、专属水印等多种模式支持自定义字体、大小、位置、蒙板与透明度可从相册、拍照甚至聊天记录中选择图片无需服务器和域名没有后端使用微信开发者工具打开即可运行并提交审核。资源包共63个文件包含js逻辑脚本、json配置、wxss样式、wxml页面结构以及png/jpg示例图片与说明文档整体仅339KB轻量易读。已有710人学习下载适合用于学习微信小程序图片处理与canvas绘制技巧也可直接改造为个人或商业用途的加水印工具。1. 微信小程序图片加水印为什么要选 canvas 2d 本地生成图片秒加水印制作生成这个需求动手写过的人都知道难点不在写字而在把图片、画布、导出三个环节串成一条不闪屏、不超时的链路。微信小程序里最可靠的方案是本地生成用 canvas 2d 接口把水印绘制到底图上再调 canvasToTempFilePath 导出临时图片整个过程不上传服务器、不依赖任何第三方库。适合做内部资料分发、活动海报批量出图、商品图防搬运这类场景新手按步骤能跑通老手也能在参数和兼容性上拿到可复用的结论。下面按绘制、排版、导出、踩坑和批量落地的顺序一次讲完。2. 微信小程序 canvas 2d 画文字水印的最小可运行代码2.1 新版 canvas 2d 与旧版 wx.createCanvasContext 的取舍微信小程序的画布接口有两代。旧版 wx.createCanvasContext 基于指令队列调 ctx.draw() 才真正上屏导出时容易出现样式没画完的竞态新版 canvas 2dtype2d 的 canvas 节点返回真实 Canvas 2D 上下文fillText、fillRect 调用后立即生效行为与浏览器一致排查问题时心智负担小得多。从基础库 2.9.0 起官方就在推新版新项目没有理由再走旧接口。老项目想改造也简单canvas 标签加 type2d把 createCanvasContext(xx) 换成 SelectorQuery 拿 node绘制代码本身基本平移但导出和尺寸逻辑要按新 API 重写下面代码给的就是新版完整写法。2.2 页面里放一个隐藏画布节点加水印通常不需要把画布展示给用户常见做法是把 canvas 节点放在页面里用定位移出可视区。注意不能用 display:none节点尺寸为 0 时后续取宽高、导出都会报 invalid size。canvas idmarkCanvas type2d stylewidth: 320px; height: 180px; position: fixed; left: -9999px; top: 0; z-index: -1;/canvasstyle 里的 320px 是 CSS 像素canvas 真实像素在 JS 里再乘 devicePixelRatio。节点挂载后用 SelectorQuery 的 fields({ node: true, size: true }) 能同时拿到 canvas 实例和布局宽高这是新版 2d 画布的标准拿节点姿势。如果代码写在自定义组件里这一句要换成 this.createSelectorQuery()否则查不到组件内部的节点。2.3 绘制水印并导出到临时路径的完整代码初始化放在 Page 的 onReady 里。onReady 而不是 onLoad是因为首次渲染完成后 canvas 节点才存在onLoad 阶段 select 返回的是空。Page({ onReady() { this.paintWatermark() }, paintWatermark() { wx.createSelectorQuery() .select(#markCanvas) .fields({ node: true, size: true }) .exec((res) { if (!res || !res[0]) { console.error(canvas 节点未找到检查是否在 onReady 之后调用) return } const canvas res[0].node const cssWidth res[0].width const cssHeight res[0].height const system wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync() const dpr system.pixelRatio // canvas 物理像素与 CSS 像素解耦 canvas.width cssWidth * dpr canvas.height cssHeight * dpr const ctx canvas.getContext(2d) ctx.scale(dpr, dpr) // 先铺白底避免导出 jpg 时透明区域变黑 ctx.fillStyle #ffffff ctx.fillRect(0, 0, cssWidth, cssHeight) // 居中一行文字水印 ctx.font 20px sans-serif ctx.fillStyle rgba(180, 0, 0, 0.65) ctx.textAlign center ctx.textBaseline middle ctx.fillText(内部资料 · 禁止外传, cssWidth / 2, cssHeight / 2) this.exportImage(canvas) }) }, exportImage(canvas) { wx.canvasToTempFilePath({ canvas, success: (res) { console.log(水印图生成, res.tempFilePath) // tempFilePath 可直接给 image src 用也可以传给 wx.uploadFile }, fail: (err) { console.error(导出失败, err) } }) } })这段代码里最容易被忽略的是 ctx.scale(dpr, dpr)。canvas.width 放大到 CSS 宽高的 dpr 倍后绘图坐标系也跟着放大了不调 scale 的话原本 20px 的字会按物理像素画在 3 倍屏上视觉字号只有 6px 左右scale 之后所有绘制都能继续用 CSS 像素思维font、坐标不用再手动乘系数。wx.getWindowInfo 是基础库 2.20.1 的接口低版本用 wx.getSystemInfoSync 兜底避免老项目一升级就崩。2.4 canvasToTempFilePath 的关键参数说明新版 2d 画布导出必须把 canvas 实例传进去否则默认找页面里第一个 canvas多画布页面经常导出成别的图。常用参数如下。参数作用建议值canvas2d 画布实例指定导出源必传x / y / width / height截取画布上的局部区域默认全画布不传destWidth / destHeight导出图输出尺寸像素与 canvas.width / height 一致fileTypepng 或 jpg文字水印用 png清晰度优先qualityfileType 为 jpg 时的压缩质量 0~10.8success / fail导出结果回调success 拿 tempFilePathdestWidth、destHeight 不等于 canvas 物理尺寸时框架会做一次缩放。成品图要求清晰度时建议按 canvas 物理像素导出不要在导出环节二次压缩宁可文件大一点。jpg 模式不支持透明通道所以 2.3 节先铺白底png 可以跳过铺底适合保留透明背景的 logo 水印。3. 水印排版参数坐标换算、rotate 旋转与平铺密度3.1 水印坐标和图片缩放模式的换算实际项目里画布常要放一张底图drawImage 之前得先把图片按 cover 或 contain 缩放。cover 是裁掉多余边填满画布contain 是完整显示并留白两者算出的绘制区域不同水印贴右下角时的坐标基准也不一样。先写一个通用的缩放计算函数。function layoutImage(imgW, imgH, canvasW, canvasH, mode contain) { const scale mode cover ? Math.max(canvasW / imgW, canvasH / imgH) : Math.min(canvasW / imgW, canvasH / imgH) const drawW imgW * scale const drawH imgH * scale return { drawW, drawH, dx: (canvasW - drawW) / 2, dy: (canvasH - drawH) / 2 } }drawImage(img, dx, dy, drawW, drawH) 之后图片在画布上占的矩形就是 dx、dy、drawW、drawH 围出的区域。右下角水印的锚点必须用这个区域的内边而不是画布自身的宽高否则 contain 留白时水印会跑到图片外面。商品主图习惯 contain 保留全貌运营分享图用 cover 铺满更美观mode 参数建议做成可配置项不同业务入口传不同值。3.2 用 ctx.rotate 实现斜向水印图片旋转与角度参数防搬运场景里斜向水印比正排水印更常见。rotate 会绕当前原点旋转所以先 translate 到水印中心再旋转最后在局部坐标系原点画字画完 restore 恢复避免影响后续绘制。function drawMark(ctx, text, cx, cy, angleDeg, fontSize, color) { ctx.save() ctx.translate(cx, cy) ctx.rotate(-angleDeg * Math.PI / 180) ctx.font ${fontSize}px sans-serif ctx.fillStyle color ctx.textAlign center ctx.textBaseline middle ctx.fillText(text, 0, 0) ctx.restore() }angleDeg 传 30 得到左下往右上斜 30 度的效果取负值则反向倾斜。图片旋转水印的常规做法是旋转中心放在图片中心或某个边角但要注意角度大的时候文字会超出画布局部区域导出时被裁切。这个函数在 3.3 节平铺时会被反复调用save/restore 保证每次旋转互不污染。提示fillText 的对齐基准由 textAlign 和 textBaseline 控制。这里统一设 center 和 middle传入的 cx、cy 就是水印文字的几何中心做九宫格定位或平铺计算时心智负担最小。3.3 行列平铺水印的间距计算平铺水印的实现思路以 spacingX 为列距、spacingY 为行距从图片左上区域开始遍历起点在每个起点画一条斜水印。循环范围要放大到图片宽高再加上一个间距否则边缘会漏空防截图效果打折。function drawTiledMarks(ctx, text, imgW, imgH, opts) { const { spacingX 160, spacingY 110, angle -30, fontSize 18, color rgba(255,255,255,0.45) } opts for (let y spacingY / 2; y imgH spacingY; y spacingY) { for (let x spacingX / 2; x imgW spacingX; x spacingX) { drawMark(ctx, text, x, y, angle, fontSize, color) } } }起点偏移一半间距是让矩阵中心落在图片视觉中心附近而不是某一行从左上角硬对齐观感更稳。间距越小水印越密但文件体积和绘制耗时都会上升图片在 1000px 级别时平铺几十条文字对现代手机是毫秒级真正影响体积的是导出分辨率而不是水印数量这也是秒加能成立的原因。3.4 平铺密度与字号的推荐参数表用途fontSizespacingXspacingY透明度 alpha单条角标品牌露出图片宽 / 20不适用不适用0.65~0.75全文平铺防截图图片宽 / 26120~18090~1200.35~0.5Logo 图片平铺不适用图片宽 / 3图片高 / 30.8字号和间距按图片宽度比例算而不是写死 16px、20px。同一段代码处理 600px 小图和 3000px 大图时比例值才不会失真。绘制前还可以用 ctx.measureText(text).width 量一下文字宽度如果超过 spacingX 的 70%就缩小字号或加大列距避免相邻水印叠成黑块。注意 measureText 必须在 ctx.font 设置之后调用否则量出来的是默认字号的宽度。4. 秒加容易失败的 4 个坑尺寸上限、DPR、图片加载与导出时序4.1 canvas 尺寸上限与超限缩放微信小程序 WebView 渲染下的 canvas物理像素尺寸受设备 GPU 和 WebView 限制影响很大。常见安卓机型上限在 4096px 左右部分新机能到 8192pxiOS 相对宽松但也有天花板。原图是 6000px 宽的大图时直接 canvas.width 6000 会导出失败或花屏。稳妥做法是初始化时把最长边收敛到 4096 以内。function initCanvasSize(canvas, cssWidth, cssHeight, dpr) { const MAX_SIDE 4096 let bw Math.round(cssWidth * dpr) let bh Math.round(cssHeight * dpr) const maxSide Math.max(bw, bh) let downScale 1 if (maxSide MAX_SIDE) { downScale MAX_SIDE / maxSide bw Math.round(bw * downScale) bh Math.round(bh * downScale) } canvas.width bw canvas.height bh return { bw, bh, scale: dpr * downScale } }拿到返回值后 ctx.scale(scale, scale)后续绘制依旧按 CSS 像素坐标系写。这里向下取整避免算出 4096.5 这种带小数的物理宽高。导出超限大图时宁可在画布阶段缩小也不要让 canvasToTempFilePath 硬扛两者的失败率完全不同。4.2 图片没加载完就 drawImage画面空白canvas 2d 里加载图片只能用 canvas.createImage()不能用 H5 习惯的 new Image()。createImage 返回的对象是异步解码常见错误是在 onload 触发前就调 drawImage结果画了个寂寞。const img canvas.createImage() img.onload () { ctx.fillStyle #ffffff ctx.fillRect(0, 0, cssWidth, cssHeight) ctx.drawImage(img, 0, 0, cssWidth, cssHeight) drawTiledMarks(ctx, 内部资料, cssWidth, cssHeight) this.exportImage(canvas) } img.onerror () { console.error(图片解码失败, src) } img.src srconload 里同时做铺底、drawImage、画水印、导出是为了保证导出时序跟在绘制之后。src 支持本地临时路径和已下载文件网络图片建议先用 wx.getImageInfo 换成本地路径再喂给 img.src可以绕开域名白名单和跨域解码的问题。4.3 导出黑图白图jpeg 透明区域与 iOS 表现canvasToTempFilePath 用 fileType: jpg 导出时透明像素没有颜色信息安卓一般给白色iOS 上经常被填成黑色这是很多小程序水印图导出后背景发黑的元凶。解决办法是绘制阶段显式铺白底让底色由代码决定而不是交给平台猜测。需要真透明背景就坚持 png但 png 体积比 jpg 大几倍分享海报场景建议 jpg 加白底组合。4.4 导出时序与基础库版本的坑新版 2d canvas 绘制是同步生效理论上画完立刻导出没问题但基础库版本较旧时偶发第一次导出 blank、第二次才成功。比较省事的兜底是导出 fail 里做一次重试间隔 80~100ms。exportWithRetry(canvas, retry 2) { return new Promise((resolve, reject) { wx.canvasToTempFilePath({ canvas, success: (res) resolve(res.tempFilePath), fail: (err) { if (retry 0) { setTimeout(() { this.exportWithRetry(canvas, retry - 1).then(resolve, reject) }, 80) } else { reject(err) } } }) }) }真机上偶发 canvasToTempFilePath:fail invalid size 时优先怀疑 4.1 的物理尺寸超限其次检查 canvas 节点布局宽高是否为 0。排查可以参照下面这张表。现象根因对应手段导出整体发黑jpg 加透明画布绘制前铺白底偶发 blank、第二次成功旧基础库导出时序竞态fail 里指数退避重试画面全白无水印未等 img.onload绘制移进 onload安卓花屏或导出失败设备 WebView 尺寸上限更小MAX_SIDE 临时调低到 2048 验证5. 批量生成水印图并保存到相册的连贯流程5.1 复用一个 canvas 节点做批量处理批量出图不必每张 new 一个 canvas。常见做法是复用同一个节点初始化一次拿到 canvas 和 ctx之后每次换图片只更新内容再导出。串行 Promise 循环控制内存避免几十张大图同时解码把小程序撑爆。async function batchRun(canvas, srcList) { const outputs [] for (const src of srcList) { outputs.push(await drawOneAndExport(canvas, src)) } return outputs }drawOneAndExport 接收 canvas 和 src内部 createImage、onload 绘制水印、canvasToTempFilePath 导出最后把 tempFilePath resolve 出去。循环里保持同一个 canvas 物理尺寸性能和表现都很稳定不需要每张重建画布。5.2 导出文件先落盘到 USER_DATA_PATHcanvasToTempFilePath 生成的是临时文件小程序退出后可能被清。批量场景建议先把结果复制到用户目录后续预览、上传、保存相册都只跟这一个目录打交道。const fs wx.getFileSystemManager() fs.copyFileSync(tempFilePath, ${wx.env.USER_DATA_PATH}/mark_${Date.now()}.png)USER_DATA_PATH 是每个小程序独立的用户数据目录10MB 存储配额对小体积水印图足够。文件名带时间戳避免覆盖清缓存时也能按 mark_ 前缀统一删除。5.3 保存相册先声明权限失败再引导设置保存相册调 wx.saveImageToPhotosAlbum但先要在 mp 后台「设置-服务内容声明-用户隐私保护指引」里声明相册仅写入权限不声明接口会直接报错这是上线审核阶段最常见的遗漏。前端对拒绝授权要做引导。wx.saveImageToPhotosAlbum({ filePath: savedPath, success() { wx.showToast({ title: 已保存到相册 }) }, fail(err) { if (err.errMsg err.errMsg.indexOf(auth) -1) { wx.showModal({ title: 需要相册权限, content: 请在设置中允许保存图片到相册, confirmText: 去设置, success(r) { if (r.confirm) wx.openSetting() } }) } } })fail 里 errMsg 各端文案不完全一致auth deny、authorize no response、privacy permission统一用 includes(auth) 判断再引导去设置页即可。这套批量链路在 uniapp 工程里也能平移节点查询换成 uni.createSelectorQuery()导出换成 uni.canvasToTempFilePath({ canvas })绘制代码不动HBuilderX 运行到微信开发者工具时行为一致。最后用 wx.getImageInfo 读一下落盘图片的 width/height和画布物理尺寸对比即可确认导出没有超限降级。本文还有配套的精品资源点击获取