ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

免服务器小程序图片水印实现:canvas合成与四种水印模式解析

免服务器小程序图片水印实现:canvas合成与四种水印模式解析 简介这是一份面向微信小程序开发者的图片加水印实战源码完整实现全屏水印、隐形水印、横幅水印、专属水印等多种添加方式。每种模式均内置字体大小、文字样式、位置、蒙版及透明度等调节选项还可自动获取微信头像昵称生成专属标识适合保护原创图片、个人品牌展示等场景。压缩包内共63个文件以js逻辑、json配置、wxss样式、wxml页面为主另含png/jpg示例图与txt说明文档整体仅339KB便于快速下载与导入。已有710人学习适合想掌握小程序图像处理与自定义组件的中初级开发者。项目无需服务器和域名无后端依赖支持直接导入微信开发者工具运行通过源码可学习canvas绘图、图片选材及文件处理等核心思路是完整的免后端小程序实践范例。1. 免服务器的小程序图片水印先想清楚“水印在哪里加”看到这份源码第一反应是它的描述里反复强调“免服务器和域名”这对做小程序的人来说是个好消息因为只要没有后端接口就不用去配置 request 合法域名也不用为私有化部署买一台机器。但它也带来一个限制所有水印合成逻辑只能落在本地也就是用户自己的手机上。这就意味着你必须把图片处理做成 canvas 像素级的合成而不是调一个在线接口。这篇文章从 uni-app 工程结构出发拆解一套能跑通的微信小程序图片加水印源码包含全屏水印、横幅水印、专属水印和隐形水印四类典型玩法并把从选图到保存的完整链路和审核需要注意的事项一并梳理出来。适合正在做微信小程序图片工具、毕设项目或者想快速搭一个无后端工具型小程序的开发者参考。2. 水印类型与 canvas 像素合成原理2.1 为什么非得是 canvas而不是 CSS 覆盖很多第一次做水印功能的人会想直接在image上盖一层view加透明文字不就行了吗这样预览确实可以但你最终要把带水印的图片保存到相册或者分享给微信好友这时候必须拿到一张真实的位图而不是 DOM 图层。CSS 覆盖只能用于屏幕预览无法被canvas.toTempFilePath输出成文件。另一个原因是隐形水印需要读写像素色的最低有效位这只有 canvas 的getImageData才能做到。所以源码里绕不开 canvas。在 uni-app 中推荐使用 canvas 2d 接口type2d来替代旧的 canvasdrawImage/fillText模式。旧接口能跑但在自定义组件里获取 canvas 节点很别扭且在某些 Android 机上会出现 y 轴偏移。2d 接口通过SelectorQuery拿到 canvas 节点再调用getContext(2d)渲染逻辑和浏览器端几乎一致。这份源码里的 common 目录下就是封装的这一层 canvas 工具分别处理全屏、横幅、专属三种模式的绘制。2.2 四类水印的绘制路径与透明度处理这套源码把水印拆成了四个模式每个模式对应不同的绘制策略。我把它们的核心技术点和参数整理如下水印类型核心实现典型参数全屏水印在整张图片上按行列平铺文字或图形使用globalAlpha控制透明度spacingX,spacingY,opacity,rotate横幅水印在图片顶部/底部拉一条半透明遮罩再绘制文字文字不需要平铺bannerHeight,bannerColor,text,position专属水印把用户头像和昵称作为水印内容头像用drawImage绘制成圆形昵称用fillText输出avatarSize,nicknameColor,font,position隐形水印在图片像素中写入信息肉眼不可见但用 Photoshop 调色阶可以看出channel,intensity,message实现全屏水印时最常见的误区是在 for 循环里开启新的路径但忘记save/restore导致所有文字的透明度叠加成了深色块。正确做法是先设置一次ctx.globalAlpha然后每画一个文字调用一次ctx.fillText全部画完再恢复。参考代码uni-app微信小程序环境// pages/watermark/fullscreen.js function drawFullscreenWatermark(canvas, image, options) { const ctx canvas.getContext(2d); const { width, height } options; // 先将原图画到画布底层 ctx.drawImage(image, 0, 0, width, height); ctx.save(); ctx.globalAlpha options.opacity ?? 0.15; // 透明度建议控制在 0.05~0.35 之间 ctx.fillStyle options.color || #ffffff; ctx.font bold ${options.fontSize || 20}px sans-serif; ctx.translate(width / 2, height / 2); ctx.rotate((options.rotate || -30) * Math.PI / 180); const spacingX options.spacingX || 120; const spacingY options.spacingY || 80; const textWidth ctx.measureText(options.text || 影子评测).width; for (let row -height / 2; row height / 2; row spacingY) { for (let col -width / 2; col width / 2; col spacingX textWidth) { ctx.fillText(options.text || , col, row); } } ctx.restore(); // 导出带水印的本地文件路径 return new Promise((resolve, reject) { wx.canvasToTempFilePath({ canvas, success: (res) resolve(res.tempFilePath), fail: reject, }); }); }这段代码里有几个参数需要重点说明globalAlpha是整体透明度全屏水印一般设成 0.1~0.2 才不会影响主体内容rotate用来让水印斜向排布通常用 -30 度到 -45 度比水平排布更容易防裁剪measureText用于测量单个水印字符串的宽度否则每列水印之间会搭在一起。canvasToTempFilePath是官方导出接口在 2d canvas 模式下需要传入 canvas 节点对象而不是 canvasId这是老代码迁移时最容易踩的坑。2.3 图像尺寸与内存边界微信小程序对 canvas 尺寸并不是无上限的不同机型的内存差异会导致大图在drawImage时直接白屏。通常的做法是在绘制前先做一次压缩用uni.getImageInfo获取图片宽高按最长边 2000px 为基准等比缩放输出 JPEG 格式质量 0.9 再把绘制后的图片导出。这套源码里也继承了类似的处理否则从相册选一张 4000px 的照片到低端机很容易崩。3. 选图链路与专属水印的工程实现3.1 相册、拍照和聊天记录三种入口微信小程序的选图入口并不统一。相册和拍照可以用wx.chooseMedia或uni.chooseImage而“聊天记录选照片”实际上是微信给小程序开放的wx.chooseMessageFile接口返回的是聊天会话中的文件其中type为 image 时会带一个临时文件路径。所以拿到这份源码后你会看到三个入口被合并成了一个统一的选项面板用起来就像一个轻量级的微信小程序图片提取工具。三种入口对比入口API返回类型是否需要特殊权限相册uni.chooseMedia临时文件路径无需拍照uni.chooseMedia临时文件路径无需但真机需要相机权限聊天记录uni.chooseMessageFile临时文件路径 文件信息需要用户主动选择关键代码如下// pages/select/select.js async function chooseImage(sourceType) { let tempFilePath; if (sourceType chat) { // 从聊天记录选择图片文件 const res await uni.chooseMessageFile({ count: 1, type: image, extension: [jpg, png, jpeg], }); tempFilePath res.tempFiles[0].path; } else { const res await uni.chooseMedia({ count: 1, mediaType: [image], sourceType: [sourceType], }); tempFilePath res.tempFiles[0].tempFilePath; } // 拿到临时路径后先解析宽高再做压缩 const info await uni.getImageInfo({ src: tempFilePath }); return { path: tempFilePath, width: info.width, height: info.height }; }需要注意uni.chooseMessageFile返回的tempFiles里不仅有path还有size和name字段有些图片在聊天记录里可能被系统做过分发压缩实际像素已经下降所以不建议把它当作原图来用。另外这个 API 需要用户在聊天界面主动选择文件不能静默读取因此在 UI 上文案必须写清楚“从聊天记录选择图片”否则审核时容易被判定为不合理权限申请。3.2 专属水印的头像和昵称怎么拿专属水印需要把微信头像和昵称合成到图片里。但这几年微信已经把用户信息获取收得很紧wx.getUserProfile弹窗也只能拿到“微信用户”这类默认昵称头像也变成了灰色头像。真正可靠的做法是让用户主动上传头像和填写昵称或者使用微信的“头像昵称填写能力”给button添加open-typechooseAvatar和bindchooseavatar事件昵称则用input typenickname组件。源码里所谓“可以自动获取你的微信头像名字”大概率是这一能力的封装而不是离线获取的真实微信资料。对应实现!-- pages/profile/profile.vue -- button classavatar-wrapper open-typechooseAvatar chooseavataronChooseAvatar image :srcavatarUrl modeaspectFill/image /button input typenickname placeholder请输入昵称 v-modelnickname /// pages/profile/profile.js export default { data() { return { avatarUrl: /static/default-avatar.png, nickname: , }; }, methods: { onChooseAvatar(e) { // 返回的是临时路径保存时需要复制到本地用户目录 this.avatarUrl e.detail.avatarUrl; }, drawAvatarOnCanvas(ctx, x, y, radius, url) { // 先用 drawImage 画一个圆形头像绘制图片后再用 arc clip 裁剪 return new Promise((resolve, reject) { const img new Image(); img.onload () { ctx.save(); ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(img, x - radius, y - radius, radius * 2, radius * 2); ctx.restore(); resolve(); }; img.onerror reject; img.src url; }); }, }, };这里有一个很现实的问题头像临时路径在本次会话结束后可能失效所以如果用户编辑了专属水印模板最好把头像复制到wx.env.USER_DATA_PATH下的固定目录里保存。复制文件可以使用FileSystemManager的copyFile也可以直接用saveFile接口但saveFile会把文件存到不可见的内部存储用户下次进入时通过getSavedFileList才能找到。对于工具型小程序我一般建议只在本次会话里使用不做持久化省去存储清理成本。3.3 导出与保存到相册的授权处理完成合成后用wx.canvasToTempFilePath拿到临时文件再通过uni.saveImageToPhotosAlbum保存到系统相册。这个接口第一次调用会触发授权弹窗用户拒绝后再次调用会直接失败所以需要在fail回调里引导用户去设置页打开“保存到相册”权限。源码中会把这部分封装成一个 promise 版本的saveImage函数比较关键的是判断授权状态用wx.getSetting拿到scope.writePhotosAlbum。4. 免服务器部署mp-weixin 目录与 project.config.json 配置边界4.1 为什么不需要设置合法域名这份源码的目录结构里有mp-weixin、uni_modules和pages说明它是一份 uni-app 工程。mp-weixin是 HBuilderX 编译输出的微信小程序包微信开发者工具打开的是这个目录。没有任何request、uploadFile等网络接口所以不需要在 mp 后台配置 request 合法域名、socket 合法域名或 uploadFile 合法域名。只要把代码包上传到微信后台审核通过后即可发布。但“免服务器”不等于“无状态”。临时文件路径只在用户端存在分享给好友后对方看到的是已经合成好的图片无法再编辑。所以这类小程序的分享路径通常是把图片先保存到相册或者用云存储上传但那样就不算严格免服务器了。这里是纯本地处理因此分享行为只能基于图片文件本身。4.2 用 project.config.json 管理 appid 和代码包拿到源码后不要直接点上传先把project.config.json里的 appid 改成你自己的。微信开发者工具会优先读这个文件如果 appid 为空或别人的编译阶段会报错或无法预览。常见配置如下{ appid: 你的小程序AppID, projectname: image-watermark, compileType: miniprogram, miniprogramRoot: mp-weixin/, cloud: false, setting: { es6: true, postcss: true, minified: true, urlCheck: false } }注意urlCheck在开发者工具里可以关掉因为它只对开发阶段的非法域名提示起作用既然项目里没有网络请求关掉后不会影响正式环境。如果你用的是 HBuilderX 打开 uni-app 源码需要先在 HBuilderX 里配置自己的小程序 appid再 “运行到小程序模拟器”之后 HBuilderX 会重新生成mp-weixin目录。每次改完代码都重新编译不要直接去改mp-weixin里的 js 产物否则下一次编译会被覆盖。4.3 代码包体积与静态资源控制微信小程序主包大小限制是 2MB如果用 HBuilderX 的 uni-app 工程未压缩前很容易超限。源码中的静态文件分布大致如下路径作用app.js/app.json小程序入口与全局配置pages/页面目录负责选图和水印编辑common/公共 js 模块封装 canvas 工具static/静态资源用于默认头像和示例水印uni_modules/uni-app 插件模块mp-weixin/编译输出目录微信开发者工具实际运行包为了保证不超限static目录只放压缩过的 PNG/JPG不要放超过 200KB 的原图。对于需要动态绘制的水印 logo可以通过 base64 内联到 CSS 或 js避免额外 request。超过 2MB 时还可以开分包把水印模板页放到subPackages里但这类工具型小程序页面很少通常不需要激进优化。4.4 审核时容易被问到的问题提交审核时微信审核员会检查按钮文案、隐私协议和用户信息收集。由于“专属水印”会收集头像和昵称你需要在 mp 后台的“用户隐私保护指引”中声明收集用户选择的照片、头像和昵称否则审核会以“未声明收集用户信息”的理由驳回。另一个高发问题是保存图片到相册的授权弹窗没有引导文案审核员会模拟第一次进入如果文本里没有明确说明“将保存图片到相册”可能被判为诱导。源码的readme.html里其实已经写了一些说明但建议发布前再核对一遍。5. 隐形水印的 LSB 嵌入与 PS 通道验证5.1 避开在 JPEG 上直接嵌入隐形水印隐形水印在源码里的描述是“肉眼不可见但通过 PS 等软件可以看到”这通常指的是 LSB最低有效位算法。LSB 通过修改像素 R/G/B 通道的最低一位来携带信息改动强度是 1/255肉眼看不出但用 PS 的通道分离和色阶调整能把它放大显示。但 LSB 非常脆弱JPEG 的有损压缩会直接抹掉部分低位信息所以如果你保存成 JPEG这个水印大概率会失效。因此在实现隐形水印时导出格式必须使用 PNG。代码里可以在 canvas 的toTempFilePath中设置fileType: png来强制导出 PNG 格式。5.2 一个可复现的 LSB 水印嵌入示例下面给出一个简化版的 LSB 隐写函数只嵌入一段固定的一维标记不涉及高鲁棒性编码。把所有像素的红色通道最低位替换为标记位强度为 1/255肉眼不可见。// utils/lsb.js function embedLSB(imageData, markLength 32) { const data imageData.data; const totalPixels data.length / 4; const bitCount markLength; // 用 32 个像素代表一个标记 for (let i 0; i bitCount; i) { const pixelIndex i * 4; // 标记序列是固定的 0x5A5A5A5A按位写入倒数第二位避免 JPEG 再次压缩影响 const bit (0x5A5A5A5A (i % 32)) 1; data[pixelIndex] (data[pixelIndex] ~1) | bit; } return imageData; } function verifyLSB(imageData) { let value 0; for (let i 0; i 32; i) { const bit imageData.data[i * 4] 1; value | bit (i % 32); } return value 0x5A5A5A5A; }这里只在前 32 个像素的 R 通道写入了一个固定标记更完整的实现还可以把写入位置分散到整张图片中并叠加纠错码。要注意的是getImageData在微信小程序 canvas 2d 里同样可用但在真机上对超大图片逐像素遍历会有明显卡顿所以建议把图片压缩到 1000px 内再处理。还有一点保存为 PNG 后再用 PS 打开切换到“通道”面板选中“红”通道按住 Ctrl 键查看通道内高光区域如果像素在标记位上有规律变化就能看到淡淡的水印图案。5.3 实操验证步骤我验证隐形水印时一般不会直接用肉眼盯原图而是用一句命令把水印图和未水印图做差分避免被屏幕低色彩位数干扰。在 PC 上可以用 ImageMagick 的compare命令# 生成差分图亮点位置即为像素级差异 compare test.png test_lsb.png -compose src delta.png生成的delta.png就是两张图各像素位级的差异如果 LSB 嵌入成功差异图里会出现规律性的亮点。没有 ImageMagick 的话也可以在 PS 里把两张图叠在一起图层混合模式改为“差值”再加大亮度。这套源码的隐形水印强度不高主要用于版权归属证明不适合作为生产环境防伪方案因为只要截图或调色就能被破坏。如果要做不可移除的鲁棒水印需要引入频域变换DCT/DWT但那是另一个工程了可以在后续版本里慢慢迭代。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进