鸿蒙ArkTS实战:Canvas实现PDF多行多列水印与删除边界解析 上个月有个做文档管理的朋友找我说他们的鸿蒙应用要上线结果被一个看似不起眼的需求卡住了给PDF加水印。既要支持单页文字水印还要能多行多列平铺甚至客户那边还提了“最好能把误加的水印去掉”。我本来以为随便找个库接上就行结果在鸿蒙生态里翻了一圈发现这事儿远没有想象中简单最后只能自己动手从原理到代码一步步啃下来。这篇文章就把整个实战过程整理出来包括水印怎么加、怎么做到多行多列不重叠、以及“删除水印”这件事在技术上到底能做到什么程度希望能给正在做鸿蒙开发、尤其是碰到PDF处理需求的朋友一些参考。先说下最终结论在鸿蒙ArkTS环境下完全没有必要为了加个水印就去接入庞大的原生PDF渲染引擎。我最后采用的是系统Canvas绘制水印层 PDF解析库操作页面内容流的组合方案既绕开了NDK开发的复杂度又能保证在手机、平板上的表现一致性。整套实现从方案验证到跑通大概花了一天半核心代码不到300行。1. 为什么在鸿蒙上做PDF水印要“自己造轮子”1.1 现成方案在鸿蒙生态里的尴尬处境刚开始我确实想着偷懒去搜有没有现成的鸿蒙PDF库能直接调。搜完之后发现情况比较尴尬OpenHarmony生态里确实有几个PDF相关的三方库但大多数只是封装了格式解析能读取页数、文字内容就已经不错了真正支持“往PDF里写入新对象”这种操作的屈指可数。而要在PDF指定位置绘制水印文本本质上是往现有文档里插入内容这要求库不仅有读取能力还得有修改和序列化能力。有的朋友可能会想既然三方库不行那干脆用NDK把C的PDF库比如Pdfium、Podofo编译成鸿蒙的so库再通过NAPI调。这条路技术上是通的但有几个问题绕不过去一是编译链配置麻烦OpenHarmony的NDK和Android NDK有差异有些C库的构建脚本在鸿蒙上报一堆错二是包体积会明显增大一个PDF解析引擎动辄几兆三是如果你的应用要上架华为应用市场审核时对于NDK的原生库兼容性尤其是ABI支持有额外要求。所以对于“加水印”这种不算特别底层的需求动用重型原生库性价比不高。1.2 换一个思路把水印“画”出来再合入PDF后来我换了个角度想问题水印的本质是什么从视觉效果上看它就是一张半透明的、带有文字的图层。那如果我先把水印内容渲染成一张图片再把这张图片作为背景层合入PDF的每一页就成了吗这样一来PDF库只需要支持“导入图片页面”这一个能力而图片的绘制完全可以交给鸿蒙自带的Canvas——这套逻辑在ArkTS里实现起来非常顺手。不过这里有个细节要注意如果你直接把水印图片作为整页的背景合进去会导致PDF的文字层被压在图片下面用户就没办法复制和搜索原文了。这个问题很致命因为大多数用PDF的都是文档场景不能复制文字等于半残。所以正确做法是先把原文PDF的每一页都导出来和水印图片做图层叠加得到带水印的新页面再把这些页面合成一个新的PDF文件。虽然这样会让编辑性变差本质上成了扫描件但从业务需求角度反而是可以接受的甚至客户还觉得更安全——因为这种情况下水印也真的很难被删掉。1.3 最终的技术选型与原因分析下面是我在方案确定后从性能、开发量、可维护性三个维度做的对比供大家参考方案开发工作量包体积影响水印效果文字可复制性维护成本Native引擎Pdfium/NDK高增加5~10MB优保留高需跟随NDK适配三方ArkTS库直接写PDF中增加1~3MB一般不一定保留中依赖库作者更新系统Canvas渲染页面合生选用中低几乎无增加良好不保留低全用系统能力选最后一种方案的理由很直接它把“如何绘制水印”这个复杂问题从PDF世界的坐标系统转换成了我们更熟悉的Canvas绘图出问题也好排查。而且用的是系统API不存在兼容性黑洞无论是手机还是后续做鸿蒙PC版适配逻辑都能直接复用。2. 基础准备搭建环境与理解两种坐标系2.1 开发环境与权限配置我使用的开发环境是DevEco Studio 5.0SDK版本API 12及以上目标设备为HarmonyOS NEXT真机。在开始写代码之前有两个前置工作必须先做否则跑起来一定会踩坑申请读写权限如果你的水印功能要处理外部存储里的PDF文件需要在module.json5里声明ohos.permission.READ_MEDIA和ohos.permission.WRITE_MEDIA同时要在系统设置里给应用打开“文件访问”权限。但如果是应用沙箱内部的文件就完全不需要这些权限所以测试时我直接把PDF放在了应用沙箱目录下。引入图片编码和文件流模块我们后面需要把Canvas画出来的内容编码成JPEG或PNG然后通过文件流方式读入PDF生成模块这几个API分散在ohos.multimedia.image、ohos.file.fs等包中先统一导入。import { image } from kit.ImageKit; import { fileIo as fs } from kit.CoreFileKit; import { BusinessError } from kit.BasicServicesKit;2.2 必须先搞清楚的坐标系问题这个坑我一开始没注意导致水印位置歪到离谱。PDF的坐标系统和Canvas屏幕坐标系统完全不是一回事PDF坐标系统原点在页面左下角X轴向右Y轴向上单位是点pt。A4纸的尺寸是595 x 842 pt。Canvas坐标系统原点在左上角X轴向右Y轴向下单位是像素px。所以如果你在Canvas上以(100, 100)为起点画了一行字想把它原样放在PDF页面上出来的效果会是文字跑到页面底部去了因为PDF的(100, 100)是从下往上量的。我在第一次测试时就栽在这里整个水印像掉进下水道一样缩在页面边缘。解决的思路不复杂在把页面转成图片时直接把Canvas的背景尺寸设成和PDF页面一致的宽高比然后在Canvas上绘制时就把Canvas当做一个“从上往下”的平面绘制完成后整体输出成图片再按页面大小贴入PDF。这样PDF的坐标系就被图片整体“覆盖”了你完全不需要在PDF坐标系里做任何换算只需要保证图片的宽高比和PDF页面一致。3. 核心实操单页与多行多列文字水印的实现3.1 第一步用Canvas生成水印图层把这个函数拆出来独立实现是方便后续复用。它的输入是页面尺寸pt输出是一张带水印的透明背景图片。async function createWatermarkLayer(pageWidth: number, pageHeight: number, text: string): Promiseimage.PixelMap { const renderWidth Math.floor(pageWidth * 2); // 2倍分辨率保证文字清晰 const renderHeight Math.floor(pageHeight * 2); const context new canvasRenderingContext2D(); // 实际开发中需要通过Canvas组件获取context此处为示意逻辑 context.width renderWidth; context.height renderHeight; context.clearRect(0, 0, renderWidth, renderHeight); context.save(); context.globalAlpha 0.2; // 水印透明度关键参数 context.fillStyle #808080; context.font 48px sans-serif; context.translate(renderWidth / 2, renderHeight / 2); context.rotate(-Math.PI / 6); // 倾斜一定角度这是水印的“灵魂” context.textAlign center; context.textBaseline middle; context.fillText(text, 0, 0); context.restore(); // 将canvas区域编码为图片并返回PixelMap const pixelMap await context.getPixelMap(0, 0, renderWidth, renderHeight); return pixelMap; }参数设计的讲究我多说两句。关于globalAlpha0.15~0.25是防提取又不影响阅读的黄金区间太淡了扫描件上根本看不出来太浓了主文字会被遮挡。关于旋转角度-Math.PI / 6即-30度是经过验证的最舒适角度水平水印太容易被直接裁切掉90度垂直水印又显得太占地方45度斜向水印最经典但视觉上有点过于“警示”30度相对温和。3.2 第二步把水印图层与PDF页面合成这里涉及PDF解析和页面的重绘。我用的思路是用解析模块把原始PDF每页都转成图片然后在图片上叠加水印图层最后把所有处理过的图片重新组合成一个PDF文件。async function addWatermarkToPdf(sourcePath: string, outputPath: string, watermarkText: string) { const pdfDoc await pdf.open(sourcePath); // 示意API实际按库的接口调整 const pageCount pdfDoc.getPageCount(); for (let i 0; i pageCount; i) { const page pdfDoc.getPage(i); const pageWidth page.getWidth(); const pageHeight page.getHeight(); // 1. 原页面转成图片 const pageImage await page.renderToImage(2, 2); // 2倍缩放 // 2. 生成相同尺寸的水印图层 const watermarkLayer await createWatermarkLayer(pageWidth, pageHeight, watermarkText); // 3. 利用Canvas做图像合成 const merged await mergeImages(pageImage, watermarkLayer, pageWidth, pageHeight); // 4. 构建新页面并写入输出PDF await newPdf.addImageAsPage(merged); } await newPdf.save(outputPath); }mergeImages内部也是建立一个Canvas先把页面图片draw上去再draw水印图层最后导出成一张新的完整图片。这样做的好处是不会改变原PDF页面尺寸输出的PDF每页大小和原文档保持一致在打印和展示时不会出现缩放变形。3.3 从单行到多行多列平铺算法的设计与避坑单行水印做完之后朋友那边立刻提了新需求“一个页面上一行水印太单薄了要那种铺满整个页面的多行多列效果。”这也是网上搜索热度很高的关键词——多行多列文字水印。实现的思路就是在createWatermarkLayer函数里从“画一个居中文本”变成“循环绘制多个文本”。但这里有几个细节处理不好效果就会非常丑。核心算法逻辑如下async function createMultiTiledWatermark(pageWidth: number, pageHeight: number, text: string) { const renderWidth Math.floor(pageWidth * 2); const renderHeight Math.floor(pageHeight * 2); const context getCanvasContext(renderWidth, renderHeight); context.clearRect(0, 0, renderWidth, renderHeight); context.globalAlpha 0.2; context.fillStyle #808080; context.font 36px sans-serif; context.textAlign center; context.textBaseline middle; const angle -Math.PI / 6; const stepX 280; // 水平步长 const stepY 180; // 垂直步长 const diag Math.ceil(Math.sqrt(renderWidth * renderWidth renderHeight * renderHeight)); const centerX renderWidth / 2; const centerY renderHeight / 2; for (let x -diag; x diag; x stepX) { for (let y -diag; y diag; y stepY) { context.save(); context.translate(x, y); context.rotate(angle); context.fillText(text, 0, 0); context.restore(); } } }这里的“避坑点”非常关键遍历范围必须覆盖对角线长度。很多新手会直接for (let x 0; x renderWidth; x stepX)但水印旋转之后矩形四个角的空白区域其实在旋转前对应的是“超出画布”的坐标区域如果你只遍历画布范围旋转后就会出现四个大三角区域的空白水印覆盖不均匀。步长要和字号联动。我最终调试出来感觉比较舒服的参数是字号36pt、横向步长280px、纵向步长180px。如果字号大而步长小水印之间会重叠如果字号小而步长大页面又会显得稀疏。你先记住一个公式步长大约等于字号的5~8倍横向、3~5倍纵向再微调就快了。context.save/restore必须成对。每一次translate之后如果不restore下一次平移就是在累加的基础上进行的跑完整个循环Translate值已经跑到几万像素之外水印位置会完全失控。4. 既加又删PDF水印删除的技术边界4.1 水印对象在PDF文件中的存在形式很多人会问“能不能直接给PDF去掉别人加的水印”这个问题的答案完全取决于水印是怎么加上去的。从PDF内部结构的角度看水印有两类截然不同的存在形式对象型水印水印文字被写成PDF内容流里的文本绘制指令BT ... ET它是文档对象的一部分。这类水印理论上可以删做法是解析PDF内容流定位到绘制水印的指令段然后把相关指令从内容流中移除。像素型水印水印经过Canvas渲染已经和页面原图像融合成了一个新的图像对象。这种水印一旦合成就不可逆了所谓“删除”本质上是图像修复需要靠AI或其他算法去“猜”被遮挡部分的内容效果好不好完全看运气。而我们前面讲的实现方案用Canvas渲染后合成做出来的水印恰好就是像素型的。所以从我们自己的方案出发加出来的水印是真的难删这反而成了业务上推荐它的理由。4.2 如果真要写“删除”功能能删到什么程度虽然像素型水印删不了但在鸿蒙环境里如果你遇到的是场景B来自某些工具生成的对象型水印还是可以尝试解析删除的。这里给出一个基于开源PDF解析库的简化思路// 伪代码移除内容流中指定水印的绘制指令 function removeWatermarkFromContent(content: string): string { // 1. 按行拆分内容流 const lines content.split(/\r?\n/); const filtered: string[] []; let skip false; for (const line of lines) { const trimmed line.trim(); // 2. 识别水印开始的标记通常带有固定的颜色或字体设置 if (trimmed.startsWith(BT) trimmed.includes(/WmFont)) { skip true; // 命中水印块开始跳过 } if (trimmed.startsWith(ET)) { if (skip) { skip false; // 水印块结束 continue; } } if (!skip) { filtered.push(line); } } // 3. 重新生成内容流 return filtered.join(\n); }这段代码的实际落地难点在于你无法从视觉上判断一个文本块是不是水印。水印的绘制指令和正文里的文字绘制指令在结构上几乎没有区别唯一可能的特征就是它使用了特定字体比如WmFont、特定颜色比如灰度80%或者特定位置平铺在背景层。这就要求你开发时得能拿到生成水印的原始模板用来提取这些特征。如果PDF是别人随机生成的水印位置、字体千变万化准确的自动识别率会低到没有实用价值。所以我给朋友的建议是咱们重点做“加水印”别碰“删水印”。删除功能除了技术复杂度高之外还有业务上的风险——如果一个产品本身标榜的是“防止文档被剽窃”结果还提供一键去水印的功能这在商业逻辑上就说不过去了。4.3 删除水印之外的另一条路径水印信息提取我在调研中意外发现很多用户真正关心的不是“物理删除”而是从带水印的PDF中提取可用的文字信息。比如拿到一份加了平铺水印的扫描版PDF文字没法复制就想能不能用OCR把文字抽出来。在鸿蒙上做这件事有一个相对可行的轻量方案第一步把PDF页面渲染成高清图片这一步我们上面已经实现了把renderToImage后获得的图片保存到临时目录。第二步将图片送到OCR服务可以使用华为HMS Core的文本识别能力返回识别出的文字块。第三步把识别出的文字按坐标信息重新排版成纯文本文档。这条路径的技术难度比直接“删水印”低得多而且通用性也更强。如果你的用户反馈里出现了“PDF加水印后没法整理笔记”之类的诉求与其去和PDF底层硬碰硬不如用这个“曲线救国”的方案。5. 踩坑记录HarmonyOS实际运行中的细节问题5.1 中文水印显示为方框这个坑非常经典相信做PDF处理的都遇过。系统Canvas默认字体sans-serif在英文环境下没问题但如果你直接绘制中文文本部分设备上会变成一排方框。原因在于PDF生成库或者图片渲染管线里缺少对应的中文字体映射。解决办法是显式指定支持中文的字体族context.font 48px HarmonyOS Sans SC。如果你的目标是OpenHarmony开源平台而非HarmonyOS NEXTHarmonyOS Sans SC不一定存在可以进一步降级为sans-serif同时把文本绘制前的context.textBaseline和textAlign设置好避免字体缺失导致渲染偏移。5.2 多页文档内存飙升直接OOM我的测试文档有200页按最初写法一次性把所有页面渲染成图片再合成PDF跑到第80多页的时候应用直接闪退。Check了一下日志是图片PixelMap占用内存过大。教训就是处理PDF这类文档永远不要一次性加载全部页面。必须分成三步来做先逐页渲染并合成为图片立刻把生成的图片写入输出文件的临时存储然后释放这一页的相关对象最后再统一把临时图片目录按顺序合成最终PDF。响应用户操作时也可以加上一个ProgressDialog显示当前处理页码让用户知道进度。5.3 透明PNG的水印在部分PDF阅读器里变黑底我在真机上预览一切正常发给朋友用另一款阅读器打开发现水印区域变成了黑色底块。排查后发现问题出在图片合成时使用了JPEG编码——JPEG不支持透明通道原本透明的背景被自动填充成了黑色。解决方案就是水印图层一定要用PNG或WebP格式保存且合成时采用SourceOver混合模式透明区域才能正确保留。如果PDF解析库或合成模块对PNG支持不好可以在合并时先把背景改成纯白色再加一个浅灰色的水印虽然效果不如透明水印精致但至少不会出现黑底这种重大瑕疵。5.4 表格速查高频问题与排查方向症状可能原因排查/解决思路水印位置跑偏PDF坐标与Canvas坐标混淆检查是否直接使用了PDF的pt坐标画Canvas应使用渲染分辨率坐标水印文字模糊渲染分辨率过低用2倍甚至3倍页面尺寸作为Canvas宽高输出时再按页面大小嵌入水印覆盖正文太严重透明度或字号设置不当透明度降到0.15字号改为正文的1/4左右平铺水印出现重叠步长和字号比例不对参考字号5~8倍横向步长调整保证旋转后不重叠生成后PDF体积暴增每页图片都按高码率编码调整JPEG质量为85%或使用WebP格式存储中间图片无法读取外部PDF权限未申请或未开启检查module.json5权限声明确认用户在设置中授权5.5 性能优化让长文档处理不再煎熬除了分批处理防OOM之外还有两个小小的优化点对体验提升很有帮助第一在把原始PDF页面转图片时尺寸设在2倍就够用了不需要更高的倍率。水印只是背景层用户不会放大了细看3倍以上只是徒增内存和文件体积。第二平铺水印图层在整个文档的处理过程中每一页都是一样的所以只需要生成一次PixelMap然后所有页面复用千万别在循环里重复生成否则处理时间会线性膨胀。6. 实战中的经验总结与后续方向这套PDF水印方案做完之后我又跑了几个不同场景的验证单页合同、几十页的标书、还有扫描版的古籍PDF。整体表现稳定水印在手机上缩放查看时清晰可见在打印预览里也不会喧宾夺主。后续如果要继续扩展有几个方向是明确的动态水印在每页水印文本里拼接当前时间、操作人ID等信息方便追踪文档流转路径。这个改起来很容易只要把传入createWatermarkLayer的text从固定字符串换成动态拼接字符串即可。图片水印有些企业喜欢把自己Logo作为水印铺底原理和文字水印完全一致唯一不同的就是把fillText换成drawImage。需要注意Logo的透明PNG抠图质量以及图片平铺时的重复感处理。PDF批注联动现在很多鸿蒙办公应用已经开始支持在PDF上直接手写批注如果能把批注内容同步转成水印层就可以实现“批注水印”“评审水印”这样偏定制化的功能在协同办公场景下很吃香。我自己倒没打算把这些都做成一个通用SDK因为PDF这个格式本身太陈旧各家实现都有差异做通用库的维护成本非常高昂。反而是针对具体业务场景、定制好那几种水印样式把稳定性打磨好才是性价比最高的做法。如果你也是在做鸿蒙开发建议先从单行水印跑通再扩展多行多列千万不要一上来就追求大而全。扎实的基本功能上线跑了半年比一个总是出边角问题的“全能版”要有价值得多。