ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UniApp跨端Canvas图片水印组件:自动换行与平铺绘制全实现

UniApp跨端Canvas图片水印组件:自动换行与平铺绘制全实现 做uniapp或者纯前端开发的朋友一定遇到过这种需求用户上传一张图片要在上面压一层水印文字内容可能是用户名加手机号也可能是二维码加时间戳。需求看着简单做起来才发现坑不少——文字稍微长一点就溢出图片边界中文和数字混排宽度怎么算都不对水印想铺满整张图又不知道怎么控制间距和旋转角度最后好不容易写完真机上一测小程序导出图片变白屏App端canvas对象拿不到。这篇文章我直接把我趟过一遍的完整方案拿出来基于Vue3的script setup语法跑在uniapp的H5、微信小程序和App端。核心功能包括单张图片加文字水印、文字水印自动换行、多行多列平铺水印、多个不同水印内容组合绘制。所有代码都是实际测试过的每个关键参数怎么算、为什么这么算我都会讲清楚。1. 需求拆解与整体方案设计1.1 水印功能的核心诉求水印本质上不是“加一行字”那么简单。从实际业务场景看水印要解决三个问题第一是版权归属证明图片的产出方第二是溯源追踪比如给不同用户生成带各自ID的图片一旦泄露就能定位到人第三是防篡改水印需要铺满或者半透明覆盖让盗图者难以通过简单裁剪去除。不同场景对水印的诉求差异很大。电商场景通常是店铺名称加商品图叠加社交类应用往往是“昵称手机号”这种动态水印企业内部资料截图则喜欢用时间戳加工号。所以水印组件不能只写死一个文案必须是可配置的——文字内容、字号、颜色、透明度、旋转角度、平铺间距、位置布局全部要通过参数来控制。1.2 为什么选择Canvas而不是CSS或后端方案市面上其实有几种加图片水印的做法我挨个对比过各有各的适用场景。CSS方案最简单用position: absolute盖一个半透明文字层在图片上面。优点是实现速度快、不涉及图像处理缺点是水印层是“视觉上”的用户长按图片或者用开发者工具一扒原图就露馅了根本起不到防篡改的作用。后端方案比如Pillow、sharp效果最稳定服务器端直接输出带水印的图片安全性也高。但问题是每次加水印都要走网络请求用户体验有延迟带宽成本也不低。小程序环境下还得上传原图再下载结果图流量开销翻倍。Canvas方案是前后端的一个折中在客户端本地完成绘制水印直接烧进像素里导出后就是一张新的图片不存在“揭开图层”的问题。而且canvas的drawImage配合fillText、rotate、translate这些API能非常灵活地实现单水印、平铺水印、旋转水印、多水印组合动态数据的支持也天然友好。唯一的门槛是跨端兼容要处理但这点在uniapp框架下有成熟的方案后面我会专门讲。1.3 组件化设计把水印能力封装成一个Vue3组件既然定位是“最全水印”我建议直接把能力封装成一个可复用组件而不是写死在页面里。组件应该接收这些props参数名类型默认值说明imageUrlString必填原始图片地址watermarkTextString必填水印文字内容fontSizeNumber16水印字号fontColorStringrgba(0,0,0,0.3)水印颜色建议用半透明rotationNumber-30水印旋转角度度modeStringsinglesingle-单水印tile-平铺水印multi-多水印gapXNumber80平铺时的横向间距gapYNumber80平铺时的纵向间距opacityNumber0.3全局透明度outputTypeStringpng导出图片格式qualityNumber1jpeg格式时的质量组件内部用一个drawWatermark方法统一处理外部只需要监听export事件拿到生成后的图片路径上传还是下载由业务方自己决定。这样设计的好处是不管你是要加水印后保存到相册、上传服务器还是直接显示预览组件都不需要操心职责单一。2. 基础实现Canvas图片加水印的完整流程2.1 图片加载与画布尺寸计算加图片水印的第一步是把原始图片加载进来并获取它的实际尺寸。uniapp里用uni.getImageInfo这个方法这里有一个关键细节这个API是异步的而且加载本地临时文件路径和网络URL的行为略有差异。网络图片在小程序端必须在manifest.json里配置downloadFile合法域名否则会加载失败这个坑我后文会再提。获取到图片宽高之后就可以创建同尺寸的画布。这里我强烈建议用离屏Canvas的思路——先把水印画在一个和图片等宽高的画布上最后再把带水印的画布导出。好处是渲染结果和原图比例完全一致不会因为CSS缩放导致模糊。// 获取图片信息 const getImageInfo (url) { return new Promise((resolve, reject) { uni.getImageInfo({ src: url, success: resolve, fail: reject }); }); };2.2 Canvas绘制drawImage与fillText的配合拿到图片尺寸后先在新创建的canvasContext里绘制图片再在上面叠加水印文字。用uniapp传统方式的API写绘制图片是ctx.drawImage绘制文字是ctx.fillText。这里有一个很多人会忽略的坐标系问题。drawImage默认把图片绘制在(0,0)原点画布尺寸必须和图片尺寸一致否则图片会被拉伸。如果你想把图片居中绘制在某个区域内就必须手动计算偏移量。比如画布是500x800图片是350x350想在顶部居中显示坐标就要算成offsetX (500 - 350) / 2 75 offsetY 距顶部留白高度比如 50 ctx.drawImage(img, offsetX, offsetY, 350, 350)水印文字的定位同理。单水印模式下我习惯把水印放在图片右下角留一定边距。计算方式是从画布右下角往左上角回推margin 20 textX canvasWidth - margin - textWidth textY canvasHeight - margin注意fillText的基准点是文字的左下角baseline为alphabetic时不是左上角所以文字的Y坐标要比视觉上的底部位置再低一点这个细节新手最容易踩画出来的文字总感觉位置偏了。2.3 导出图片H5与小程序两种实现方式绘制完成后就到了导出环节。这个环节是跨端差异最大的地方。H5端的Canvas是HTML标准Canvas可以直接用canvas.toDataURL(image/png)拿到base64字符串也能用canvas.toBlob转成Blob对象。如果canvas画布尺寸和样式尺寸不一致导出的图片会模糊所以要在创建canvas标签时把width和height属性设成图片的实际像素尺寸CSS只负责显示。小程序端包括微信小程序、支付宝小程序等不能用toDataURL必须用uni.canvasToTempFilePath把canvas内容导出成临时文件路径。这个方法必须在ctx.draw()的回调里调用否则canvas还没绘制完成同步导出会拿到空内容。调用方式如下uni.canvasToTempFilePath({ canvasId: watermarkCanvas, success: (res) { // res.tempFilePath 就是临时图片路径 } });App端HBuilderX打包的Android/iOS应用比较特殊uniapp的App端vue页面里的canvas部分版本支持toDataURL但API行为不完全一致。更稳妥的做法是统一封装一个导出方法根据uni.getSystemInfoSync().platform判断走哪条分支。这里还要提一个导出图片的清晰度问题。如果原图很大比如2000x2000小程序端canvasToTempFilePath导出时会有一个destWidth和destHeight参数默认是和canvas画布一致。但如果你后续需要把图片上传到服务器做压缩可以在导出时直接指定destWidth: 1080之类的大小让canvas帮你完成一次缩放。2.4 基础水印组件的最小实现把上面的流程串起来一个最小可用的单水印逻辑就是这样const drawSingleWatermark async (imageUrl, text) { const imageInfo await getImageInfo(imageUrl); const { width, height } imageInfo; // 创建canvas上下文 const ctx uni.createCanvasContext(watermarkCanvas); // 绘制原图 ctx.drawImage(imageUrl, 0, 0, width, height); // 设置水印样式 ctx.font 16px sans-serif; ctx.fillStyle rgba(0, 0, 0, 0.3); // 计算水印文本宽度用于定位 const textWidth ctx.measureText(text).width; const x width - textWidth - 20; const y height - 20; // 绘制水印文字 ctx.fillText(text, x, y); // 触发绘制并导出 ctx.draw(true, () { uni.canvasToTempFilePath({ canvasId: watermarkCanvas, success: (res) { // 回调拿到水印图片路径 } }); }); };这段代码在H5、小程序、App三端都能跑通但功能太简陋——文字长了就超出画布多个水印也渲染不了。后面两个章节就是专门解决这两个痛点的。3. 文字水印自动换行的实现原理3.1 为什么换行是个不能糊弄的问题中文、英文、数字、特殊符号在同一段文本中混排时每个字符在canvas中的实际渲染宽度完全不同。英文字母是半角字符中文是全角字符数字的宽度接近但不等同于英文而标点符号在不同字体下差异更大。如果只是用ctx.measureText(text).width测量整段文字的宽度然后塞进一个固定宽度区域结果要么是文字溢出画布要么是换行的位置乱七八糟。业务的真实场景往往是“用户名手机号”这种动态拼接的字符串。手机号是11位数字用户名可能3到20个字符不等拼接之后总长度完全不可控。如果不做自动换行水印宽度一旦超过图片宽度文字就直接被截断或者覆盖到图片外面整个水印效果就毁了。3.2 核心算法逐字测量累加换行自动换行的核心思路非常简单逐个字符测量宽度累加超过最大宽度时在当前位置换行。但有几个细节必须处理到位否则实现出来会生硬。第一初始光标位置的边界处理。通常水印不是从画布最左边开始排的要预留边距。假设文字区域是maxWidth 画布宽度 - 左右边距×2字体的样式要先设置好ctx.font设置会影响measureText的测量结果必须先设置font再测量。第二英文单词不能一个字母一个字母地断开换行。如果一行末尾刚好是“university”这个词的前半截后半截跑到下一行可读性就很差。这里需要做一个判断如果当前字符是字母或数字而它前面的字符也是字母或数字说明正处在单词中间这时不能强制换行要回溯到最近的一个空格或符号处换行把完整单词保留到下一行。第三中英文混排时的标点悬挂问题。中文里逗号、句号、引号出现在行首是不符合排版习惯的。可以在换行前判断如果当前字符是中文标点就把它“挤”在上一行末尾。这个处理虽然要额外写几行代码但对最终观感的提升非常明显。核心算法实现如下const wrapTextByWidth (ctx, text, maxWidth) { const lines []; let currentLine ; for (let i 0; i text.length; i) { const char text[i]; const testLine currentLine char; const testWidth ctx.measureText(testLine).width; if (testWidth maxWidth currentLine) { // 处理英文单词尽量不在单词中间断开 const lastSpaceIndex Math.max( currentLine.lastIndexOf( ), currentLine.lastIndexOf(-) ); if (lastSpaceIndex 0 /[a-zA-Z0-9]/.test(char)) { const nextLine currentLine.slice(lastSpaceIndex 1) char; lines.push(currentLine.slice(0, lastSpaceIndex)); currentLine nextLine; } else { lines.push(currentLine); currentLine char; } } else { currentLine testLine; } } if (currentLine) { lines.push(currentLine); } return lines; };3.3 自动换行后的绘制定位拿到了换行后的数组绘制时就遍历数组逐行输出。行高不能简单地用fontSize否则中文会被截断。正确做法是设置ctx.textBaseline top然后每行的Y坐标偏移用fontSize * lineHeight来递增lineHeight建议设置为1.2到1.5之间。中文和英文的行高需求不同中文字形本身就占满Em盒行高建议1.4英文字体可以1.2。绘制逻辑const drawWrappedText (ctx, lines, startX, startY, lineHeight) { lines.forEach((line, index) { ctx.fillText(line, startX, startY index * lineHeight); }); };这里还有一个隐藏问题换行之后水印的占位变高了。如果原始设计是水印放在右下角原本一行文字只占16px高换行后变成两行32px高右下角定位时Y坐标要往上提半个文本块的高度否则第二行就超出画布了。这就需要一个“文本块高度”的概念在计算Y坐标时用lines.length * lineHeight提前算好。3.4 自适应缩放当水印文字实在太多时的兜底方案自动换行能解决问题的大多数情况但总有个极端场景用户填了一个超长的昵称比如“宇宙超级无敌爆炸好看帅气迷人的小可爱”加上手机号在800px宽的图片上换行后已经占据三分之一的高度。这个时候更合理的做法不是无限换行而是自动缩小字号。我采用的是“测量-判断-缩放的迭代方案”先按预设字号测量换行后的总高度如果总高度超过了画布高度的四分之一这个比例可以配置就把字号按剩余可用高度与所需高度的比例缩放。const maxAllowedHeight canvasHeight * 0.25; if (linesCount * fontSize * lineHeight maxAllowedHeight) { const scaleFactor maxAllowedHeight / (linesCount * fontSize * lineHeight); const adjustedFontSize Math.floor(fontSize * scaleFactor); // 用调整后的字号重新测量和绘制 }注意这里重新测量非常重要——字号变了每个字符的渲染宽度也跟着变了换行的结果可能完全不同。所以正确流程是循环执行“测量→换行→计算总高度→判断是否超过阈值”直到满足条件或字号低于最小限制。实测下来这个方案对100%的输入都是安全的不会出现水印文字覆盖掉图片主体的情况。4. 多水印模式与全图平铺水印4.1 多行多列平铺水印的实现思路单水印适合个人用户一到企业级应用比如内部资料外发、付费文档截图追踪单水印很容易被直接裁掉。业界通用的做法是全图平铺水印让盗图者无处下手。Canvas实现平铺水印并不复杂本质就是一个双重循环横向按gapX间距推进纵向按gapY间距推进循环体内绘制一个个旋转后的水印。但直接双重循环会有三个体验问题第一水印按矩阵排列后非常工整反而容易被人用内容识别工具批量修掉第二文字宽度不固定如果每列都用固定间距gapX最后一行可能出现水印间距不一致视觉上很乱第三旋转角度如果全部一致一张图上所有水印都是同一个朝向人工看起来也过于规整。我的做法是引入一个随机微偏移。每个水印字符的位置在基准坐标上做一个±10px内的随机抖动旋转角度在基准角度上做一个±5°的随机扰动。这样整张图的水印看起来是均匀分布的但又不完全机械重复。有人可能会问随机之后会不会出现水印重叠或大面积留白只要扰动量控制在间距的四分之一以内视觉均匀性是完全可以保证的。const drawTileWatermark (ctx, text, { width, height, gapX, gapY, baseRotation, fontSize }) { ctx.font ${fontSize}px sans-serif; for (let x -gapX; x width; x gapX) { for (let y -gapY; y height; y gapY) { const offsetX (Math.random() - 0.5) * gapX * 0.3; const offsetY (Math.random() - 0.5) * gapY * 0.3; const rotation (baseRotation (Math.random() - 0.5) * 10) * Math.PI / 180; ctx.save(); ctx.translate(x offsetX, y offsetY); ctx.rotate(rotation); ctx.fillText(text, 0, 0); ctx.restore(); } } };这里有个细节循环变量x和y的起始值设为-gapX和-gapY而不是0。因为水印文字旋转后画布四边会有空白三角区域。从负间距开始绘制可以保证图片四角也有水印覆盖不会留下“清洁区”。4.2 多水印内容组合一个画布上绘制不同水印多水印不只是“多个相同文字平铺”更常见的业务是“不同水印信息出现在不同位置”。比如产品图既要左上角加品牌Logo水印右下角加“用户ID时间”的溯源水印中间再加一个半透明的防伪编号。这就是mode: multi要解决的场景。我自己用的数据结构是数组配置每一项都包含文本内容、位置模式、字号、旋转角度、透明度。组件遍历这个数组逐项调用绘制函数。这样做的核心优势是配置化产品的需求变更不用改代码改一下配置就行。const watermarkConfigs [ { text: 品牌名称, position: topLeft, fontSize: 24, rotation: 0, opacity: 0.8, margin: 30 }, { text: 用户昵称 13812345678, position: bottomRight, fontSize: 14, rotation: -15, opacity: 0.35, margin: 20 } ];位置模式的实现就是在ctx.translate之前先计算好基准坐标。topLeft就是(margin, margin)bottomRight就是(canvasWidth - margin, canvasHeight - margin)center就是(canvasWidth / 2, canvasHeight / 2)。每个位置模式都还要根据自身的文本宽度额外做偏移否则topLeft的文字会紧贴左上角看起来偏出屏幕。绘制顺序也有讲究。公司品牌水印通常是实色不透明的要最先绘制这样后续的半透明水印不会盖住它半透明的溯源水印放后面叠在品牌水印上方也不会太突兀。如果反过来品牌实色水印会把半透明水印遮掉一块观感很差。4.3 防移除与安全增强的经验水印最终的目的还是防篡改。我在实际项目里总结了几条增强策略性价比很高随手就能用上。第一步透明度不要固定用填充样式的rgba而是把绘制后的全局ctx.globalAlpha做一次控制。这样水印文字的颜色和透明度可以分别配置比如颜色保持黑色透明度设为0.25左右既不影响图片主体视觉又不会被一次简单的色彩调整完全去掉。第二步增加对角线上额外的大水印。平铺水印的字体通常比较小如果盗图者把图片底部裁掉一行水印可能随之消失。我习惯在画布的对角线上再绘制两个大字号水印内容和角落水印一致字号是平铺水印的三到五倍。这样即使图片被裁切只要保留中心部分水印就还在。第三步水印内容里强制加入时间戳。这不只是记录时间更重要的作用是给追踪提供线索。配合短信验证码或手机号一旦图片泄露马上能定位到具体用户和具体时间区间追责有据可查。时间格式建议精确到分钟秒级别的意义不大但分钟级别对于定位泄露源非常有用。5. UniApp跨端实战H5、小程序与App的差异与问题排查5.1 H5端的实现要点H5端是兼容性最好的环境因为uniapp的vue页面在H5端直接跑在浏览器里Canvas API走的是W3C标准。但有两个细节需要注意第一canvas标签的上下文获取方式。H5里用uni.createCanvasContext(canvasId)可以拿到一个uniapp封装过的上下文对象但这个对象不是标准的CanvasRenderingContext2D个别API行为有差异比如measureText在部分旧版浏览器里计算不准确。我最终改用了原生方式——通过document.querySelector拿canvas DOM元素再getContext(2d)完整度最高。第二H5导出图片后是base64字符串这个字符串可以直接显示在img标签里但如果这张图要上传到服务器某些后端对base64大小有限制而且base64比二进制文件体积大约多三分之一。建议上传前用canvas.toBlob转成Blob再借助uniapp的uni.uploadFile直接传Blob这样可以省一次图片转文件的过程。// H5端导出Blob示例 const exportH5Blob (canvas) { return new Promise((resolve) { canvas.toBlob((blob) { resolve(blob); }, image/jpeg, 0.9); }); };5.2 小程序端Canvas 2D与传统Canvas的区别小程序端是坑最多的地方。微信小程序早期的Canvas是基于uni.createCanvasContext的旧接口这个接口在小程序里兼容性很好但有个致命点——它不支持离屏Canvas而且绘制性能较差。从基础库2.9.0开始微信小程序支持了同层渲染的Canvas 2D接口获取方式变成const query uni.createSelectorQuery().in(instance); query.select(#watermarkCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node; const ctx canvas.getContext(2d); const dpr uni.getSystemInfoSync().pixelRatio; canvas.width imageWidth * dpr; canvas.height imageHeight * dpr; });这段代码里的dpr设备像素比处理是高清绘制的关键。旧版Canvas在真机上默认按逻辑像素渲染导出后图片模糊。Canvas 2D如果不手动乘上dpr同样也糊。所以正确做法是实际canvas宽高设置为图片逻辑尺寸乘以dpr然后所有绘制坐标也等比例乘以dprctx.scale(dpr, dpr)可以把坐标系统恢复成逻辑坐标。还要特别说明一点uniapp的canvas组件在小程序端如果被定位到屏幕外或者被其他元素遮住导出图片可能得到空白。这是因为小程序的Canvas渲染依赖组件的可见性。我踩过这个坑之后解决方案是把canvas画布固定放在屏幕内位置挪到可视区边缘加position: absolute; left: -9999px;虽然理论上也算移出屏幕但实测部分机型依然会白屏。最终采用了把canvas只渲染一次、完成后立即隐藏的方案并且用wx.nextTick保证draw完成后再导出。5.3 经典问题导出白图与黑屏小程序端导出白图是我遇到的最高频问题。排查顺序如下第一步检查是否在ctx.draw()回调里执行导出。uni.canvasToTempFilePath必须在绘制完成后执行如果写成同步执行canvas还没渲染完自然导出空白。第二步检查canvas尺寸是否正常。如果canvas宽高为0绘制和导出都会失败。尤其注意在页面还没完全渲染时就去操作canvas得到的尺寸是初始值0。正确做法是在onReady生命周期里等待canvas真正挂载后再初始化。第三步检查图片是否跨域。H5端如果图片来自CDN且crossOrigin属性没有设置canvas会被“污染”toDataURL会直接报错或者导出空图。小程序端则要看域名是否在小程序后台的downloadFile合法域名列表里不在列表里直接加载失败自然也画不出来。第四步Android低端机碰到的内存问题。大尺寸图片加载加canvas重绘很容易把内存占满表现为卡顿或白屏。优化方案是先压缩原图再绘制uni.compressImage可以把图片宽度压到2000px以内内存问题基本就消失了。下面是排查清单的速查表现象排查点解决方案导出全部白图在draw回调外执行导出把canvasToTempFilePath放进ctx.draw()回调导出白图且无报错canvas尺寸为0在onReady后获取节点尺寸设置画布宽高图片画不出图片未加载完成就drawImage先用getImageInfo加载成功后回调再画H5端导出报错canvas被跨域图片污染给图片加crossOriginanonymous或转本地文件真机导出模糊未乘设备像素比dprCanvas 2D模式设置canvas.width 逻辑宽 × dpr导出黑图/截图全黑低版本Android WebView兼容问题升级HBuilderX使用Canvas 2D接口文字位置偏下未设置textBaseline设置ctx.textBaseline middle或top5.4 uni-app v3编译器与Manifest配置如果是用HBuilderX创建的项目uniapp的vue3版本默认使用vite编译。在manifest.json里需要处理两件事。第一小程序端要声明downloadFile和uploadFile的合法域名否则真机测试时图片请求被拦截。开发环境可以勾选“不校验合法域名”但发版前必须配上真实域名否则水印功能在线上必崩。第二App端要检查权限配置。如果水印图片需要保存到相册Android需要WRITE_EXTERNAL_STORAGE权限iOS需要NSPhotoLibraryAddUsageDescription描述。这些权限在manifest.json的App权限配置里勾选然后用云打包才会生效。多说一句H5端如果页面是部署在多个域名下的manifest.json里的h5.publicPath和h5.router.base配置不能写死成单个域名。这种情况建议在发布时动态注入或者用相对路径。uniapp的H5端默认资源路径是绝对路径如果前端资源只放在A域名页面通过B域名打开所有静态资源都会404canvas组件自然无法加载。解决方式是把构建后的base改成./相对路径多域名访问就没问题了。5.5 性能优化大图片与多水印场景下的卡顿处理多水印平铺模式的性能问题不容忽视。假设一张2000x1200的图片间距80px循环体里要做大约(2000/801) * (1200/801) ≈ 25 * 16 400次绘制。每次绘制插入DOM节点如果是在低端Android机上肉眼可见的卡顿。优化思路有三个方向。第一减少绘制次数。既然水印是平铺的多行多列完全可以用ctx.createPattern来生成可平铺的画布图案然后用fillRect一次性填满整个画布。这个API在H5和小程序Canvas 2D下都支持性能是循环绘制的数十倍。// 用createPattern实现平铺水印 const patternCanvas document.createElement(canvas); const patternCtx patternCanvas.getContext(2d); // 画单个水印单元 patternCtx.translate(cellWidth / 2, cellHeight / 2); patternCtx.rotate(rotation); patternCtx.fillText(text, -textWidth / 2, 0); const pattern ctx.createPattern(patternCanvas, repeat); ctx.fillStyle pattern; ctx.fillRect(0, 0, canvasWidth, canvasHeight);第二降低canvas尺寸。先把原图用drawImage的缩放参数缩小到输出需要的尺寸再绘制而不是在超大画布上绘制后再压缩。前者每一步操作的像素点都少性能自然好。第三拆帧渲染。如果水印数量实在巨大可以选择在requestAnimationFrame里分批次绘制每帧只画固定数量的水印避免主线程被长时间阻塞。这在低端机上非常有效用户感知上就是“水印逐渐出现”配合loading提示体验反而更好。5.6 组件化封装示例完整的水印组件代码最后给出一份可以直接复制使用的核心封装代码基于Vue3的script setup语法兼容H5、微信小程序、App端template view classwatermark-canvas-wrap canvas idwatermarkCanvas canvas-idwatermarkCanvas classwatermark-canvas :style{ width: canvasWidth px, height: canvasHeight px } / /view /template script setup import { ref } from vue; const props defineProps({ imageUrl: { type: String, required: true }, watermarkText: { type: String, default: }, mode: { type: String, default: single }, // single | tile | multi fontSize: { type: Number, default: 16 }, gapX: { type: Number, default: 80 }, gapY: { type: Number, default: 80 }, rotation: { type: Number, default: -30 } }); const emit defineEmits([export]); const canvasWidth ref(300); const canvasHeight ref(300); const getImageInfo (url) { return new Promise((resolve, reject) { uni.getImageInfo({ src: url, success: resolve, fail: reject }); }); }; const wrapTextByWidth (ctx, text, maxWidth) { // 实现参考上文此处省略 }; const drawSingle (ctx, info, text) { // 绘制单水印逻辑 }; const drawTile (ctx, info, text) { // 绘制平铺水印逻辑 }; const renderWatermark async () { try { const imageInfo await getImageInfo(props.imageUrl); const width imageInfo.width; const height imageInfo.height; canvasWidth.value width; canvasHeight.value height; // #ifdef H5 const canvas document.getElementById(watermarkCanvas); canvas.width width; canvas.height height; const ctx canvas.getContext(2d); ctx.drawImage(props.imageUrl, 0, 0, width, height); // #endif // #ifndef H5 const ctx uni.createCanvasContext(watermarkCanvas); ctx.drawImage(props.imageUrl, 0, 0, width, height); // #endif // 统一设置字体、颜色、透明度后根据mode调用不同绘制函数 ctx.font ${props.fontSize}px sans-serif; ctx.fillStyle rgba(0, 0, 0, 0.3); if (props.mode tile) { drawTile(ctx, { width, height }, props.watermarkText); } else { drawSingle(ctx, { width, height }, props.watermarkText); } // H5端导出 // #ifdef H5 const exportUrl canvas.toDataURL(image/png); emit(export, exportUrl); // #endif // 小程序/App端导出 // #ifndef H5 ctx.draw(true, () { uni.canvasToTempFilePath({ canvasId: watermarkCanvas, success: (res) { emit(export, res.tempFilePath); } }); }); // #endif } catch (err) { console.error(水印生成失败, err); } }; defineExpose({ renderWatermark }); /script组件的使用方式也很简单父组件通过ref拿到组件实例调用renderWatermark方法监听export事件拿结果。如果项目里同时需要一个水印多个位置multi模式可以再声明一个watermarks配置数组在renderWatermark里遍历调用各自的定位绘制函数代码结构不需要改变。6. 写在最后的一些实操经验整个方案从需求到落地踩过的坑和个人心得集中说几个。第一能配置的水印参数一定要配置化。字体、间距、角度这些值如果全部写死在代码里产品提一个新需求就要动一次代码。早期把水印逻辑写成配置数组之后后续几乎所有的需求变化都只需要改配置稳定性大幅提升。第二跨端代码一定要分条件编译尽早隔离。uniapp的#ifdef语法是我后来才用熟练的。H5、小程序、App的Canvas API行为差异太大一开始混在一起写每次真机测试都要debug半天。分开之后各端的异常只影响自己那一段代码排查效率高很多。第三导出图片后一定要验证再走业务流。有些机型上传水印图片后会显示正常下载下来看也是正常的但图片在部分聊天工具里被二次压缩后水印会变得很淡。如果水印是强需求建议导出时就把透明度压到0.25以上并且尽量用PNG格式保住不透明通道用JPEG格式容易在压缩后出现水印文字周围的块状伪影。第四水印内容中包含手机号时别忘了做隐私脱敏。展示给所有用户看的图片水印中间四位可以用星号替代只在后台导出的时候才用完整手机号。这个细节虽然和Canvas技术无关但对合规性很重要。踩过这些坑、补齐这么多细节后这套水印方案已经能稳定覆盖绝大多数业务场景单图单水印、平铺全图水印、多标识组合水印、超长文字自动换行、跨端导出。如果你后面要扩展二维码水印核心思路一致——用ctx.drawImage绘制通过uni.createQrCodeImg或者本地库生成二维码图片再叠到水印画布上整个方案的骨架完全不用动。
返回列表