尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

PDF与Word文档加水印工具类设计与实现

发布时间:2026/9/25 20:39:59

资讯中心
01
ARTICLE

PDF与Word文档加水印工具类设计与实现

PDF与Word文档加水印工具类设计与实现
上个月帮同事处理合同导出模块的需求业务方提得很简单导出的PDF和Word文档背景加一行“内部资料-部门-日期”的水印防止有人把文件外传之后说不清来源。听起来就是遍历页面上画几行字真正做起来才发现PDF和Docx的水印生成是两条完全不同的路子——PDF要用底层绘图API一页页画Docx要去改打包后的OXML结构中间还有中文字体、透明度、不同阅读器显示差异一堆坑。所以我把这套工具类的设计和实现整理出来如果你也在做OA合同、财务报告、后台数据导出的安全加强这篇文章应该能帮你省不少时间。整套工具类的核心功能其实就两个给PDF字节流加平铺文字水印给Docx字节流加背景水印。我会把技术选型的考虑、PDFBox和Apache POI两种实现方式、与Spring Boot导出接口的集成以及我在生产环境里踩过的坑一次性讲清楚。不管你是刚接触这类需求还是已经趟过一边水都能在里面找到可以直接抄作业的部分。1. 先搞清楚需求文件导出加水印到底要解决什么问题1.1 常见的三个水印场景我接触过的水印需求基本能归成三类。第一类是在线预览防截图比如客户在系统里看合同预览版右上角叠一个“预览模板”的水印就算截图流出也知道是预览版本而不是正式文件这类水印通常要求半透明、铺满页面、不遮挡正文阅读。第二类是外发文件追责溯源比如财务导出的报表要发给不同渠道水印内容带员工工号或渠道编号文件转出去之后能定位到是谁泄露的这类水印往往要求清晰但又不影响主要内容的辨识。第三类是批量打印状态标记比如在“已归档”“已作废”的文件上盖一个全页水印避免二次使用这类水印经常配合红色字体或者斜向45度平铺。场景不同水印的参数差异很大。预览截图场景透明度可以压到0.15到0.2颜色选择浅灰避免喧宾夺主溯源场景文字密度要大间隔紧凑最好把工号或手机号尾号嵌在水印文案里归档标记场景则需要深色高对比度的字或图片。所以工具类的设计不能把参数写死要留出配置项。我自己在设计配置对象时至少会暴露文字内容、字体路径、字号、颜色、透明度、旋转角度、平铺间距这些字段业务侧可以按场景直接new一个配置丢进来。1.2 为什么建议封装成工具类而不是在业务里到处写早期我碰到过把水印逻辑塞在导出Service里的项目那个味道谁碰谁知道。导出合同写一遍水印循环导出报告再写一遍导出订单又写一遍三个地方字体不一样、间距不一样后来统一需求改成右上角加LOGO改到吐血。更麻烦的是一旦某个导出接口的PDF没有正确加载中文字体出来的文件全是方块字排查时得一个一个接口翻代码。把水印做成独立的工具类之后业务层和加水印逻辑彻底分离。业务代码只需要把文件字节流传进来、把水印文字传进来拿到的是加好水印的字节流至于PDF是逐页画还是Docx是改背景节点调用方完全不用关心。这个小工具类还能和你们项目里的拦截器、AOP或者导出网关配合实现“所有导出接口统一加水印”而不是让每个开发自己在业务里调。1.3 技术选型的取舍PDFBox vs iText vs Aspose说到给PDF加水印最容易找到的资料是iText和Aspose但我在选型时把它俩都排除了原因有点现实。iText的社区版使用的是AGPL协议如果你的系统是给外部客户部署的商业项目用了iText社区版本就得掂量一下开源协议义务iText商业版要买授权项目预算有限时很尴尬。Aspose功能确实强大PDF、Word都能处理但它是付费商业库生成的输出默认带评估水印网上搜“aspose去水印”能找到一堆教程但说实话这种绕过评估限制的做法本身就存在授权风险而且Aspose库本体体积也不小为了水印功能引入一个几十MB的依赖不划算。最后我选择的是Apache PDFBox加Apache POI这对开源组合。PDFBox是Apache软件基金会的老牌PDF处理库完全开源没有商用的心理负担支持底层绘制任意图形和文字平铺水印可以做得很灵活。Docx这边用Apache POI读取和修改Office文档同时配合直接操作OXML的能力来插入水印节点。这套组合在社区活跃度、文档完整度、踩坑参考资料数量上都比较理想。如果你对Docx的处理不愿意写太多底层XML拼接还可以引入docx4j它封装了水印API我这里会两种方案都讲到。方案开源协议水印实现方式上手难度适合场景PDFBoxApache-2.0底层绘图API逐页绘制中需要灵活控制水印样式、平铺、透明度的场景iText社区版AGPL页面事件或内容流中不在意协议义务的个人项目Aspose商业付费高级API封装低预算充足、追求开箱即用POI改XMLApache-2.0修改docx内部OXML中高Docx水印想控制依赖数量docx4jApache-2.0封装好的水印API低不介意引入较多依赖2. PDF水印生成原理、平铺算法和完整代码2.1 PDFBox加水印的基本流程PDFBox给PDF加水印的核心思路一句话就能说清把文档读进来遍历每一页拿到页面的内容流往内容流里追加一段绘制指令——设置透明度、设置字体、按坐标平铺写出文字最后保存。这个操作和你在图片上用画图工具写字本质是一个道理只不过PDF的“画布”是页面内容流绘制指令是PDF操作符。实操上有几个细节要提前想明白。第一水印必须画在页面内容流Page Content Stream里而不是作为注释盖上去注释在有些阅读器里默认不打印水印就白加了。第二每一页都要单独处理不能用第一页的内容流去覆盖所有页面页面尺寸可能不一样文字坐标要按当前页的宽高实时算。第三绘制完成后要保存GraphicsState并恢复不然会影响页面里原本的文字渲染状态导致正文颜色或字体偏移。用PDFBox加载文档有两种常见方式直接传文件路径或传InputStream。考虑到这个工具类最终要接在导出接口里用输入输出最好都是字节数组既方便Spring Boot控制器返回下载流也方便和其他文件处理流程衔接。核心代码骨架大概长这样读入byte[]变成PDDocument遍历getPages()逐个创建PDPageContentStream绘制水印后关闭最后document.save到ByteArrayOutputStream。2.2 平铺水印的步长计算与旋转处理做过水印的人都知道单行水印没有意义必须平铺才有效果。而平铺中最常见、观感最好的方式就是45度斜铺。很多人直接在网上抄一个双层for循环外层循环页宽内层循环页高步长写死200或300结果遇到不同的页面尺寸就出现水印重叠或者漏铺的角落。步长不能拍脑袋定。假设要铺的文字近似一个矩形文字的宽度W可以通过font.getStringWidth(text)获取然后除以1000再乘字号得到实际宽度。设文字之间的间隔为G那么45度斜铺时横向步长stepX约等于(W G)乘以cos(45度)纵向步长stepY约等于(W G)乘以sin(45度)。为什么要用三角函数因为你的文字被旋转了45度XY轴的推进距离不再等于文字宽度而是等于文字沿对角线方向上的投影长度。实际写代码时我习惯把步长简化为一个值step (W G) * 0.707。铺贴范围也值得注意。如果循环范围只从0到pageWidth、0到pageHeight页面左上角会出现一段空白因为旋转后的文字起点在角落外面。正确的做法是让起点为负的页宽页高终点为两倍的页宽页高也就是从-pageWidth循环到pageWidth2从-pageHeight循环到pageHeight2这样四角都能覆盖到。初次写的时候可能觉得浪费循环次数但对比一下效果就知道这个范围是值得的。2.3 关键坑中文乱码与透明度设置PDFBox新手最崩溃的瞬间就是用默认字体Helvetica写入中文字符输出PDF打开全是乱码或者干脆空白。原因很简单PDFBox内置的Type1字体Helvetica、TimesRoman、Courier都是西文字体字库根本不包含中文字形你给一个不认识中文的字体塞“内部资料”四个字它只能给出乱码映射。解决办法是用PDType0Font加载一个支持中文的TTF字体文件我项目里用的是思源黑体的Regular字重既免费又不需要担心商业字体授权。字体文件放在classpath的fonts目录下通过ClassPathResource取流加载避免在Linux服务器上找不到Windows字体目录的问题。另一个高频坑是透明度不生效。如果你直接在PDPageContentStream里setNonStrokingColor然后showText水印会是完全不透明的会把正文糊掉一层。必须通过PDExtendedGraphicsState来设置透明度PDExtendedGraphicsState gs new PDExtendedGraphicsState(); gs.setNonStrokingAlphaConstant(0.2f); gs.setAlphaSourceFlag(true); contentStream.setGraphicsState(gs);这个gs对象可以复用到一页里的所有水印文字上没必要每个字都new一个。注意setNonStrokingAlphaConstant是控制非描边内容的透明度如果你画的是线条或文字边框那要设置setStrokingAlphaConstant。2.4 可直接使用的PDF水印工具类代码我提供一个可直接落到项目里的简化版本你可以根据自己项目的包名调整。这个类的输入输出都是byte[]内部做了字体缓存避免每次调用都从磁盘读字体文件public class PdfWatermarkUtil { private static final MapString, PDType0Font FONT_CACHE new ConcurrentHashMap(); public static byte[] addTextWatermark(byte[] input, WatermarkConfig config) throws IOException { try (PDDocument document PDDocument.load(input); ByteArrayOutputStream baos new ByteArrayOutputStream()) { PDType0Font font getFont(document, config.getFontPath()); for (PDPage page : document.getPages()) { float pageWidth page.getMediaBox().getWidth(); float pageHeight page.getMediaBox().getHeight(); float textWidth font.getStringWidth(config.getText()) / 1000f * config.getFontSize(); // 45度平铺步长计算 float step (textWidth config.getInterval()) * 0.707f; try (PDPageContentStream cs new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { PDExtendedGraphicsState gs new PDExtendedGraphicsState(); gs.setNonStrokingAlphaConstant(config.getAlpha()); cs.setGraphicsState(gs); cs.setNonStrokingColor(config.getRed(), config.getGreen(), config.getBlue()); cs.setFont(font, config.getFontSize()); // 从负坐标开始平铺确保四个角都有水印 for (float y -pageHeight; y pageHeight * 2; y step) { for (float x -pageWidth; x pageWidth * 2; x step) { cs.beginText(); cs.setTextMatrix(Matrix.getRotateInstance( Math.toRadians(config.getAngle()), x, y)); cs.showText(config.getText()); cs.endText(); } } } } document.save(baos); return baos.toByteArray(); } } private static PDType0Font getFont(PDDocument document, String fontPath) throws IOException { return FONT_CACHE.computeIfAbsent(fontPath, path - { try { return PDType0Font.load(document, new ClassPathResource(path).getInputStream()); } catch (IOException e) { throw new RuntimeException(加载字体失败 path, e); } }); } }这段代码里有一个容易被忽略的细节加载字体传入的PDDocument必须是当前的document对象不能用ClassLoader.getResourceAsStream之后先load好再给别的文档用因为PDType0Font和PDDocument是绑定的。我在这里用computeIfAbsent按字体路径缓存但缓存的本质是同一个字体文件字节流每次load成PDType0Font时会绑定到对应document。如果你仔细看getFont的调用位置它是在方法内部的document对象创建之后才进入缓存的所以逻辑上是安全的。WatermarkConfig这个配置类就不展开贴了字段包括text、fontPath、fontSize、alpha、angle、interval、red/green/blue提供一个默认构造函数把常用的值设好比如透明度0.2、角度45度、颜色浅灰(192,192,192)、字体默认放fonts/simhei.ttf。业务侧只需要改text就能满足大部分场景。2.5 追加图片水印的扩展方式文字水印够用了但有些需求要加图片比如把公司LOGO斜铺在页面上或者盖一个红色的“作废”章。PDFBox处理图片水印和文字水印大体一样只是把showText换成drawImage。先用PDImageXObject.createFromFile加载图片但注意图片不能直接平铺原尺寸需要根据页面大小缩放。我一般把LOGO宽度设成页面宽度的10%到15%高度按原比例换算然后在循环里用contentStream.drawImage(image, x, y, scaledWidth, scaledHeight)。图片水印的透明度同样走PDExtendedGraphicsState如果你画的是PNG带透明通道的图片PDFBox会保留alpha通道效果比JPG干净。图片平铺的步长计算没有文字那么严格可以直接按图片缩放后的宽度加一个固定间隔再乘0.707视觉上够整齐就够了。3. Docx水印生成用开源方案绕开商业库限制3.1 docx的文档结构里水印藏在哪里Docx和PDF最大的区别在于docx本质上是一个zip压缩包里面是一堆XML文件。Word在界面上点的“设计-水印”保存到文件里之后水印内容要么写在页眉word/header1.xml等里要么写在主文档的背景节点word/document.xml的w:background元素中。如果你用解压软件把一个带水印的docx解开看就能看到类似v:shape、v:textpath的VML标签这就是Word水印的真实存储形式。理解了这个结构加水印的思路就清晰了不需要用POI去绘制任何东西只要往docx内部的XML里插入一段描述水印的VML片段再把zip重新打包成docx。这就是为什么很多老牌实现选择直接改XML因为它是和具体库无关的底层方案只要你的片段符合Open XML规范Word就能识别。不过这里有一个取舍要说明。Word自己的水印功能尤其是2016以后的版本倾向于把水印写入页眉部分这样在页面视图中显示效果最接近“文字背后的背景水印”。而w:background节点属于文档背景层兼容性上表现也不错在WPS和老版本Word里都能显示。我文章里的方案一用background节点实现因为代码简单、不挑版本如果你的部署环境全部是Word 2016及以上可以考虑把VML片段插入页眉的w:p元素中原理一样只是落点不同。3.2 方案一直接修改zip内XML库无关、最稳这个方案的精髓是不依赖POI能不能画水印只依赖java.util.zip把docx当作zip来处理。步骤如下用ZipInputStream读原始docx逐个entry读取判断entry名是不是word/document.xml如果是就读取文本内容并插入水印VML然后把所有entry用ZipOutputStream写出去。其他entry原样拷贝不能漏也不能改否则文件可能损坏。插入的位置有讲究。w:background应该放在w:body标签之前属于document节点下的兄弟节点。如果原XML里已有w:background直接替换没有则在w:body前插入。VML片段里最关键的是v:shape的style属性rotation:315代表顺时针旋转315度视觉上就是常见的“水印斜着45度往上”的效果。fillcolor控制颜色v:fill的opacity控制透明度注意这个opacity是0到1之间的小数和PDFBox的alpha是同一个逻辑。下面给出完整实现为了方便阅读我把XML片段的拼接放在独立方法里public class DocxWatermarkUtil { public static byte[] addTextWatermark(byte[] input, String watermarkText) throws IOException { ByteArrayInputStream bais new ByteArrayInputStream(input); ByteArrayOutputStream baos new ByteArrayOutputStream(); try (ZipInputStream zin new ZipInputStream(bais); ZipOutputStream zout new ZipOutputStream(baos)) { ZipEntry entry; byte[] buffer new byte[8192]; while ((entry zin.getNextEntry()) ! null) { if (word/document.xml.equals(entry.getName())) { byte[] data readAll(zin); String xml new String(data, StandardCharsets.UTF_8); xml insertWatermarkXml(xml, watermarkText); zout.putNextEntry(new ZipEntry(entry.getName())); zout.write(xml.getBytes(StandardCharsets.UTF_8)); } else { zout.putNextEntry(new ZipEntry(entry.getName())); int len; while ((len zin.read(buffer)) ! -1) { zout.write(buffer, 0, len); } } zout.closeEntry(); zin.closeEntry(); } } return baos.toByteArray(); } private static String insertWatermarkXml(String xml, String watermarkText) { String safeText escapeXml(watermarkText); String vml w:background w:themeColor\text1\ w:themeShade\BF\ w:picture v:shape id\watermark\ type\#_x0000_t136\ style\position:absolute;margin-left:0;margin-top:0;width:520.5pt;height:312.6pt;rotation:315\ o:allowincell\f\ fillcolor\#c0c0c0\ stroked\f\ v:fill opacity\0.25f\/ v:textpath style\font-family:宋体;font-size:24pt\ string\ safeText \/ /v:shape/w:picture/w:background; if (xml.contains(w:background)) { return xml.replaceAll(w:background[^]*.*?/w:background, vml); } return xml.replaceFirst(w:body, vml w:body); } private static byte[] readAll(InputStream in) throws IOException { ByteArrayOutputStream out new ByteArrayOutputStream(); byte[] buf new byte[8192]; int len; while ((len in.read(buf)) ! -1) { out.write(buf, 0, len); } return out.toByteArray(); } private static String escapeXml(String text) { return text.replace(, amp;) .replace(, lt;) .replace(, gt;) .replace(\, quot;) .replace(, apos;); } }这个方案最大的优点是没有引入任何额外的docx处理库项目里只要已有Apache POI的依赖就能跑甚至没有POI也能跑因为全程只用JDK内置的zip和字符串处理。代码里我特意用ZipEntry原样写回的方式保留其他entry包括签名、样式、页眉等部分最大程度避免文件损坏。我在实际项目里用这个方案处理过几十个模板docxWord和WPS打开都没有问题。有一点要提醒你background水印在部分移动端办公软件比如手机WPS中可能不渲染因为移动端对文档背景VML的支持比较弱。如果你的用户大量用手机预览docx建议用页眉方案。页眉方案的核心是找到word/_rels/document.xml.rels里对应的header关系然后往header1.xml的w:p里插同样的v:shape片段原理一样只是定位不同。这个方案我会在常见问题里再提一下。3.3 方案二docx4j的addWatermarkContents如果你觉得手工拼XML不够优雅docx4j提供了一个更上层的API——addWatermarkContents。它内部帮你处理了VML节点的构建和插入调用方只需要把docx读成WordprocessingMLPackage生成水印节点然后调用方法保存。依赖引入dependency groupIdorg.docx4j/groupId artifactIddocx4j-JAXB-ReferenceImpl/artifactId version6.1.2/version /dependency调用示例public static byte[] addTextWatermark(byte[] input, String watermarkText) throws Exception { WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.load(new ByteArrayInputStream(input)); // 参照docx4j官方AddWatermark示例构建水印节点 org.docx4j.wml.Pict pict createWatermark(wordMLPackage, watermarkText); wordMLPackage.addWatermarkContents(pict); ByteArrayOutputStream baos new ByteArrayOutputStream(); wordMLPackage.save(baos); return baos.toByteArray(); }createWatermark方法本质还是构建VML节点只是docx4j把这些节点封装成了Java对象不用担心XML字符串拼接的转义问题。docx4j的好处是对Open XML标准的覆盖度高生成的docx在Word中渲染效果和原生“插入水印”几乎一致。坏处是依赖树比较大在Spring Boot项目里如果原来没有用过JAXB相关的东西可能引入一些版本兼容问题需要额外调一调。3.4 两种方案的效果差异与适用场景特点方案一直接改zip XML方案二docx4j额外依赖无docx4j及JAXB相关代码体积小逻辑直观中需要理解APIWord渲染效果正常显示背景水印最接近原生水印WPS兼容性好好手机端预览可能不渲染背景页眉方案更稳维护成本需要理解OXML依赖库版本升级就我的使用习惯来说个人项目或者中小型项目用方案一就够了出问题也好排查直接看XML哪里不对一目了然。大型项目如果有专门的文件处理服务用docx4j能让代码更整洁但需要提前评估依赖冲突风险。4. 与文件导出流程的集成从工具方法到生产可用4.1 导出接口里的标准接入模式工具类写好了怎么接到现有导出流程里最容易的方案是在生成文件的逻辑之后、返回响应之前加一行调用。下面是一个Spring Boot控制器的典型写法GetMapping(/export/report) public ResponseEntitybyte[] exportReport() throws IOException { // 1. 业务逻辑生成原始文件字节流 byte[] original reportService.generateReportPdf(); // 2. 添加水印 WatermarkConfig config new WatermarkConfig(); config.setText(内部资料- currentUser().getDept() - LocalDate.now()); byte[] watermarked PdfWatermarkUtil.addTextWatermark(original, config); // 3. 设置下载响应 return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, attachment;filename URLEncoder.encode( 月度报告.pdf, StandardCharsets.UTF_8)) .contentType(MediaType.APPLICATION_PDF) .body(watermarked); }这个模式的好处是水印逻辑完全独立业务层甚至不需要知道水印是怎么实现的。如果以后要把水印开关加进配置中心只需要在调用处加一个条件判断工具类本身不用动。我还见过有的团队把这个调用放在一个自定义注解的AOP切面里对所有带ExportFile注解的方法统一加水印这样连每个Controller里的那两行都不用重复写了属于进一步的工程优化。4.2 批量导出与并发场景下的流管理与性能批量导出场景里第一个要注意的是流的关闭。PDFBox的PDDocument实现了CloseableDocx的ZipInputStream和ZipOutputStream也必须在finally或者try-with-resources里关闭否则文件句柄泄漏会导致后面导出越跑越慢甚至报Too many open files。我在代码里统一用try-with-resources就是为了避免这种低级又恶心的线上故障。第二个是字体加载的性能问题。PDFBox每次创建PDType0Font都要读取字体文件并解析如果一次批量导出100份PDF每次都去读字体文件耗时可能超过实际加水印的时间。解决办法就是我在前面代码里写的字体缓存用ConcurrentHashMap按字体路径缓存。这里再补充一句要验证缓存字体能不能用于不同PDDocument实例——PDType0Font内部有对文档的引用但我上面那种写法是在每个doc实例创建后去取字体如果缓存里已经存在同一个字体路径就直接用底层它其实还是会创建一个和当前doc绑定的字体对象不会跨文档错乱实测是没问题的。第三个是超大批量的内存问题。如果导出100份20MB的PDF全部用byte[]在内存里周转服务器内存可能直接告警。建议对超大文件改走临时文件方案PDFBox支持load(File)和save(File)docx方案也可以先把zip解到临时目录再处理。实际项目中90%的导出文件都在几MB以内用byte[]反而更简单但你要知道这个边界在哪里。4.3 实测记录不同环境下的显示效果差异这个工具类在不同环境下表现不一样我列几个实测结果供你参考。PDFBox加完水印的PDF在Adobe Acrobat、Chrome内置预览、Firefox内置预览、WPS里显示效果基本一致因为水印已经固化到页面内容流里了属于“烤”进去的像素信息什么阅读器打开都是同样结果打印也能印出来。唯一要注意的是某些阅读器会把PDF整体缩放水印也跟着缩放但相对位置不变。Docx背景水印的差异就大了。Word 2016和WPS的Windows版对background节点的VML渲染都正常水印显示在页面文字后方打印也正常。但手机WPS或者其他安卓办公套件对VML支持不稳定同一份docx在电脑上看得见水印在手机上打开就是干净的。如果你的业务要求手机端也必须看到水印我的建议是用方案一插入background的同时在第一个页眉里也放一份相同VML。两个地方都写总有一个会被渲染。代价是页眉里那份在有些版本里会显得置顶所以这个取舍需要业务侧确认。还有一个冷知识加完水印之后Windows资源管理器搜索docx正文内容不受影响因为水印VML在背景层正文的文本XML并没有被破坏。这个之前有同事特意问过担心加水印会不会导致文件正文就搜不到了实测不会。4.4 常见问题速查表问题现象根本原因解决办法PDF水印中文乱码使用了内置西文字体用PDType0Font加载中文字体TTFPDF水印完全不透明未设置GraphicsState透明度创建PDExtendedGraphicsState并setNonStrokingAlphaConstantPDF只有第一页有水印只遍历了page 0用for循环遍历document.getPages()全部页面Docx加水印后文件损坏zip流处理时漏掉或重复写entry严格按原entry逐项写回不要用putNextEntry(entry)后再额外写不相关的字节Docx水印在手机上不显示移动端不渲染background VML改header页眉方案或同时写入水印文字在PDF里被截断步长或范围计算过小循环范围用-page到2*page步长加上文字实际宽度导出的响应没有水印原始字节流在Controller里没替换确认返回的是watermarked数组而不是original5. 个人实操体验与后续扩展5.1 我在真实项目里踩过的三个坑第一个坑是字体路径。最初代码里写的是new File(fonts/simhei.ttf)本地Windows跑得正常部署到服务器的Linux环境立刻报字体找不到。改成ClassPathResource从classpath读取之后才稳因为字体被打进了jar包里不管部署在哪台机器都能加载。这个改法对以后容器化部署也很友好镜像里少一个外部挂载目录。第二个坑是水印只加到了第一页。当时写PDF工具类时循环写成了page document.getPage(0)测试时只打开第一页看效果需求验收时才发现后面几页全是空的。后来强制自己每次改完PDF都翻到最后几页确认。这个坑很傻但确实容易发生在赶进度的时候。第三个坑是docx的zip entry处理。最初用ZipOutputStream写回时对非目标entry只调了zout.putNextEntry(entry)然后写data忘了把压缩级别和CRC信息处理对结果生成的文件能被Word打开但报“文件已损坏是否修复”。后来换了更保守的方式——逐字节从原ZipInputStream读出来再写到ZipOutputStream问题就消失了。所以我现在写的DocxWatermarkUtil是纯流式逐entry操作而不做内存中的全量zip重建。5.2 后续可以扩展的方向工具类只是起点水印可以玩的花样很多。一是二维码水印每个导出文件的水印里嵌一个订单号和系统地址生成的二维码文件外传后用手机一扫就知道是从哪里流出的这个溯源效率比肉眼读工号高很多。实现上并不复杂用ZXing生成二维码图片然后走PDFBox图片水印的路子铺到页面上。二是盲水印把不可见的数字水印嵌到PDF的内容流里肉眼看不出来但可以通过程序提取校验文件的出库时间。PDF的字幕盲水印通常做法是把特定信息编进内容流的对象顺序或者字体间距里技术上有一定门槛但对高安全要求的项目很有价值。三是按模板预热。如果你的系统有大量固定模板导出可以事先把水印好的PDF模板存下来导出时只做内容替换或合并完全跳过运行时加水印的环节性能会好很多。这个思路对于合同套打这种高频场景特别实用。四是动态策略。水印内容里带日期时间、操作人IP、授权范围等上下文信息让水印变成“活的”而不是静态固定文案。我后来在自己的项目里把WatermarkConfig扩展了一下允许text字段里包含{date}、{user}这类占位符由工具类统一替换业务侧传模板就行。我个人实际做下来的感受是水印这东西功能看着不起眼但恰恰是这种“不起眼”的功能最考验细节。PDF和Docx虽然都叫文档内部机制完全是两套世界观一个要画、一个要改结构。把这两条路都走通再接到导出流程里跑一段时间你对文件格式的理解会有质的提升。上面这套工具类代码我已经在多个项目里复用过了你拿去改改包名和字体路径应该能少走很多弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。