
最近在做业务系统的时候遇到一个高频需求用户导出的PDF和Docx文件需要自动带上公司名称、用户ID或仅供内部使用之类的文字水印。一开始我是在各个业务代码里各写各的后来发现代码重复得厉害维护成本也高干脆抽了个工具类统一处理。这篇文章就把这个Pdf和Docx文件导出生成水印的工具类的完整实现思路、核心代码、和踩过的坑整理出来给正好有同样需求的朋友做个参考。这个工具类不是什么高深技术但里面的细节真不少。比如Word的页眉结构、PDF的页面事件、中文字体加载、旋转角度设置随便一个点处理不好生成出来的水印不是歪了就是不显示。我折腾了两天踩平了坑之后给你一套能直接用的方案。另外先说明一下文章专注于生成水印这一方向不涉及任何去除水印的内容。做工具类是为了给导出文件加保护标识而不是帮人去掉水印这个边界得拎清楚。1. 为什么需要这样一个水印工具类核心需求与适用场景1.1 从需求文档到技术方案水印到底解决什么问题水印的本质是给电子文档打上一个身份标记。最常见的几种需求是给内部合同加上机密文件严禁外传的字样给客户导出的报表加上公司Logo或客户名称防止被转发给培训材料加上讲师姓名做来源追溯给证书、证明类文件加上仅限本人使用。从技术角度来看PDF和Docx的水印实现原理完全不同。Docx是微软的OpenXML格式水印通常被放在页眉区域利用章节属性里的background和图片或文字组合来实现PDF则更灵活可以直接用底层API在内容流上叠加图形或文字。所以一个统一的工具类必须在内部把这两套机制分别封装好对外只暴露一个简单的方法。我在做这个工具类之前也看过不少网上的零散代码但大多数只写了某一种文件格式或者只支持文字水印不支持图片水印。真正落到项目里你需要的是一个能同时处理Pdf和Docx、还支持配置水印内容、字体、字号、颜色、角度、透明度的通用类。这才是我写这个工具类的初衷。1.2 工具类相比于在业务代码里硬写的优势很多人觉得不就是加水印嘛在导出方法后面多写几行调用不就行了但实际项目里一个系统往往有十几个导出接口每个接口生成的文件可能有不同的水印需求。如果每个接口自己写一遍你要面临这些麻烦代码重复严重修一个Bug要改十几个地方水印样式不统一有的转了30度有的转了45度新增文件格式时所有调用点都得动单元测试没法写水印逻辑分散在各处。抽成工具类之后调用方只需一行代码比如WatermarkUtil.mark(inputStream, outputStream, config)。内部根据文件类型自动分发到对应的处理器配置通过一个统一的参数对象传入。这样一来业务代码干净清爽水印逻辑集中管理加新格式也只是多写一个处理器的事。2. Docx水印实现基于Apache POI从零手写2.1 先理解Word文档的结构XML与页眉的关系要正确地在Docx里加水印必须知道Docx文件不是一个连续的大文件而是一个Zip压缩包。里面包含了一堆XML文件其中word/document.xml是正文内容word/header1.xml、word/header2.xml是页眉word/settings.xml里有页面设置。水印之所以放在页眉是因为页眉会在每一页自动重复而且相对于正文而言页眉内容可以独立控制Z轴顺序。Apache POI库中的XWPFDocument对象对应整个Word文档通过它可以创建XWPFHeaderFooterPolicy来操作默认页眉。水印文字要放在页眉里并设置成绝对定位即与正文无关的浮动效果。POI没有直接提供addWatermark这样的方法需要我们手动往页眉段落里塞一个CTRP对象然后通过底层org.openxmlformats.schemas.wordprocessingml.x2006.main里的类设置图形效果。核心思路是拿到默认页眉的段落创建一个新的CTP在CTP里插入CTRRun再给CTR添加CTDrawing图形最后设置文本、旋转角度、字号、颜色和透明度。这些操作听起来绕但代码写顺手了也不难。2.2 通过XWPFDocument创建文字水印的完整代码我直接贴出我封装好的核心方法。首先你需要在Maven里引入poi-ooxml依赖版本建议用4.1.2以上我这里用的是4.1.2。dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version4.1.2/version /dependency然后写一个DocxWaterMarker类核心方法如下public static void addTextWatermark(XWPFDocument doc, String text, WatermarkConfig config) throws IOException { XWPFHeaderFooterPolicy policy doc.getHeaderFooterPolicy(); if (policy null) { policy doc.createHeaderFooterPolicy(); } XWPFHeader header policy.getDefaultHeader(); if (header null) { header policy.createHeader(XWPFHeaderFooterPolicy.DEFAULT); } CTP ctp header.getParagraphArray(0) ! null ? header.getParagraphArray(0).getCTP() : header.createParagraph().getCTP(); if (ctp.getPPr() null) { ctp.addNewPPr().addNewFramePr(); } CTBody body ctp.getPPr().isSetFramePr() ? null : null; // 实际使用下面方式 // 清除已有水印 ctp.setPPr(null); CTR ctRun ctp.addNewR(); CTDrawing drawing ctRun.addNewDrawing(); CTPicture picture drawing.addNewPicture(); // 这里需要构建一组复杂的XML结构建议直接使用字符串拼接方式 // 我封装了一个方法将需要的XML以字符串形式解析成CTP避免繁琐的API调用 }上面这段代码其实写到一半会发现POI的原生API构建水印特别繁琐尤其是CTDrawing的嵌套结构。所以后来我换了一种方式直接构造好水印的XML片段然后用POI的XmlToken类解析。下面是我最终使用的完整方案import org.apache.poi.xwpf.usermodel.*; import org.apache.poi.wp.usermodel.HeaderFooterType; import org.apache.xmlbeans.XmlToken; import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTPosH; import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTPosV; import org.openxmlformats.schemas.wordprocessingml.x2006.main.*; import org.openxmlformats.schemas.drawingml.x2006.main.*; public void addWatermarkToDocx(XWPFDocument doc, String text, WatermarkConfig config) { // 1. 获取默认页眉 XWPFHeaderFooterPolicy policy doc.getHeaderFooterPolicy(); if (policy null) { policy doc.createHeaderFooterPolicy(); } // 如果页眉已有内容先清掉旧水印避免重复添加 XWPFHeader header policy.getDefaultHeader(); if (header ! null) { for (int i header.getParagraphs().size() - 1; i 0; i--) { header.removeParagraph(i); } } else { header policy.createHeader(HeaderFooterType.DEFAULT); } // 2. 新建一个段落用于承载水印 XWPFParagraph paragraph header.createParagraph(); paragraph.setAlignment(ParagraphAlignment.CENTER); // 3. 创建水印XML片段这是网上比较成熟的方案主要是用了XmlToken String waterMarkStyle String.format( w:pict xmlns:w\http://schemas.openxmlformats.org/wordprocessingml/2006/main\ xmlns:v\urn:schemas-microsoft-com:vml\ xmlns:o\urn:schemas-microsoft-com:office:office\ xmlns:w10\urn:schemas-microsoft-com:office:word\ v:shape id\poweredbyword\ style\position:absolute;margin-left:0;margin-top:0;width:%1$dpt;height:%2$dpt;rotation:%3$d;z-index:-251658240;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin\ o:allowincell\f\ fillcolor\%4$s\ strokecolor\%4$s\ v:textbox style\mso-fit-shape-to-text:t\ w:txbxContentw:pw:rw:rPrw:sz w:val\%5$d\/w:color w:val\%4$s\/w:spacing w:val\10\//w:rPr w:t%6$s/w:t/w:r/w:p/w:txbxContent/v:textbox/v:shape/w:pict, (int) config.getFontSize() * 10, // width 约等于字号*10 (int) config.getFontSize() * 10, // height config.getRotationAngle(), // 旋转角度如 -30 config.getColorHex(), // 颜色 #808080 之类 (int) config.getFontSize() * 2, // 字号半磅值 text ); // 4. 将xml解析到段落中 XmlToken xmlToken XmlToken.Factory.newInstance(); try { xmlToken.set(waterMarkStyle); paragraph.getCTP().addNewPict().set(xmlToken); } catch (Exception e) { throw new RuntimeException(解析水印XML失败, e); } }这个方法的关键在于利用了v:shape这种VML图形定义而不是复杂的DrawingML对象。Word对VML兼容性很好而且这种方式不需要额外处理透明度——因为fillcolor本身就带有透明度效果。如果想要更淡的水印直接把颜色调淡即可。2.3 踩过的坑水印被正文遮住、字体大小与旋转角度的计算我踩的第一个坑是水印被正文内容挡住。原因很简单页眉里默认的段落是position:absolute但没有设置z-index在某些Word版本里正文会覆盖页眉内容。解决方法是给v:shape的style里显式加上z-index:-251658240这个负值会把水印压到页脚区一样的层次确保正文在上层可编辑水印在下层显示。第二个坑是水印尺寸不对。Word的水印在页面上一般是铺满整张纸的但如果我们只在页眉里放一个固定大小的shape它只会在每一页顶端出现一下不会铺满。要让水印平铺需要让shape的宽度和高度足够大或者利用页面背景效果。上面代码里我用了 width 和 height 都设置为字号*10配合rotation和mso-position-horizontal:center可以让水印在页面正中显示。但如果你需要多行平铺效果就得靠多个shape或者干脆用PDF的循环绘制思路来弄。第三个坑是奇偶页页眉不同。有些文档设置了不同首页或奇偶页页眉此时getDefaultHeader()可能只作用于默认页眉其他页不会出现。这种情况下需要同时处理createHeader(HeaderFooterType.FIRST)和createHeader(HeaderFooterType.EVEN)把同样的水印加到每个页眉里。我在实际项目里就遇到过导出合同首页不显示水印的情况排查了半天才发现是首页页眉独立了。3. Pdf水印实现PDFBox与iText的取舍3.1 为什么我用PDFBox而不是iTextPDF水印的主流库有两个Apache PDFBox和iText。iText功能更丰富支持事件监听、字体嵌入、透明度控制但它采用的是AGPL许可证对于很多商业项目来说要么付费要么开源你的整个业务系统风险很大。PDFBox是Apache开源许可证商用完全没问题所以我在工具类里选择了PDFBox。虽然PDFBox没有iText那么方便的PdfPageEventHelper但只要自己写一个循环页面的方法效果完全不差。我用的PDFBox版本是2.0.24Maven依赖如下dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version2.0.24/version /dependency3.2 用PDFBox实现文字水印的两种姿势覆盖层与背景层PDFBox加水印的两种思路覆盖层在原有页面上直接画上文字文字会盖住原内容。适合强调性的水印比如作废。背景层先把水印画到一个透明层再把原页面内容叠加在上面。适合半透明的版权标识。在PDFBox里通常我们使用PDPageContentStream在页面内容流中加入新的绘制操作。如果将PDPageContentStream以Append模式添加到页面后它默认会追加到内容尾部也就是显示在最上层。如果想要背景效果需要先获取已有的内容流在它前面插入水印内容。这个操作比较绕需要直接操作COSStream。我的做法是默认使用覆盖层但把颜色设置得很淡比如RGB(192,192,192)并打开透明度混合模式这样视觉上就是背景水印的效果实现起来也简单。以下是核心代码public void addWatermarkToPdf(PDDocument document, String text, WatermarkConfig config) throws IOException { // 加载字体支持中文 PDFont font config.getFont() ! null ? config.getFont() : loadDefaultChineseFont(); float fontSize config.getFontSize(); float angle (float) Math.toRadians(config.getRotationAngle()); float pageWidth document.getPage(0).getMediaBox().getWidth(); float pageHeight document.getPage(0).getMediaBox().getHeight(); // 计算平铺间隔让水印在页面上均匀分布 float stepX fontSize * 12; // 横向间隔 float stepY fontSize * 6; // 纵向间隔 for (PDPage page : document.getPages()) { PDPageContentStream cs new PDPageContentStream(document, page, AppendMode.APPEND, true, true); cs.beginText(); // 设置透明度 PdfGraphicsState gs new PdfGraphicsState(); gs.setAlphaConstant(config.getOpacity()); // 0.0f~1.0f cs.setGraphicsStateParameters(gs); cs.setFont(font, fontSize); cs.setNonStrokingColor(config.getColorR(), config.getColorG(), config.getColorB()); for (float x -pageHeight / 2; x pageWidth pageHeight; x stepX) { for (float y -pageHeight / 2; y pageHeight pageWidth; y stepY) { cs.setTextMatrix(cosAngle * x - sinAngle * y, sinAngle * x cosAngle * y, -sinAngle * y cosAngle * x, sinAngle * x cosAngle * y, x, y); cs.showText(text); } } cs.endText(); cs.close(); } }这里注意setGraphicsStateParameters需要先在pom.xml里引入pdfbox-tools或者直接用底层COSDictionary创建我在实际代码里是用了PDExtendedGraphicsState来实现的。你直接用下面这段稳定写法PDExtendedGraphicsState gs new PDExtendedGraphicsState(); gs.setNonStrokingAlphaConstant(config.getOpacity()); cs.setGraphicsStateParameters(gs);3.3 解决PDF水印的中文乱码和多行平铺问题PDFBox的默认字体是PDType1Font.HELVETICA不支持中文。要支持中文必须嵌入一个具有中文字符集的字体。推荐加载系统中的中文字体文件比如Windows下的C:/Windows/Fonts/simhei.ttf或者Linux下的/usr/share/fonts/truetype/wqy/wqy-microhei.ttc。加载代码PDType0Font font PDType0Font.load(document, new FileInputStream(new File(config.getFontPath())));注意ttc集合文件PDFBox可能不支持直接加载建议用simhei.ttf或simsun.ttc转成ttf再加载。我在项目里用的是思源黑体SourceHanSansCN-Regular.otf需要先转成ttf或者直接用系统中备好的simhei.ttf。多行平铺的关键在于控制步长stepX和stepY。如果水印文字是机密文件一行较长那么步长要适当加大否则会重叠。另外旋转角度为45度时水印对角线间距计算有讲究我通常用字号乘以一个系数来估算。如果希望更精确可以用font.getStringWidth(text)计算实际文字宽度再决定间隔。4. 统一工具类设计把Pdf和Docx水印逻辑封装成一行调用4.1 定义一个WatermarkService接口与默认实现既然要复用自然要设计一个统一的入口。我定义了一个WatermarkService接口方法接受一个输入流和一个输出流再配一个水印配置对象。调用方不用关心是PDF还是DOCX。public interface WatermarkService { void addWatermark(InputStream inputStream, OutputStream outputStream, WatermarkConfig config) throws IOException, IllegalAccessException; }默认实现SimpleWatermarkService里持有一个MapFileType, FileWatermarkHandler文件类型由扩展名或Content-Type推断。每个处理器负责具体的格式。这样做的好处是以后如果要支持PPT或者Excel只需要再写一个处理器注册到Map里就行。Spring项目里甚至可以自动装配所有FileWatermarkHandler彻底做到开闭原则。4.2 配置化参数水印内容、位置、角度、字号、透明度、颜色我设计的WatermarkConfig长这样public class WatermarkConfig { private String text; // 水印文字支持换行 private int fontSize 48; // 字号 private float opacity 0.2f; // 透明度 0~1 private int rotationAngle -30; // 旋转角度负值表示向顺时针方向实际根据视觉调整 private String colorHex #C0C0C0; // 颜色 private String fontPath; // 中文字体文件路径PDF用 private Font docxFont; // 对于docx可直接传java.awt.Font private boolean tiled true; // 是否平铺 private float tileStepX 120f; private float tileStepY 80f; // getter/setter 略 }我通常会提供两个静态工厂方法WatermarkConfig.createDefault(text): 只指定水印文字其他全用默认值WatermarkConfig.createWithRotation(text, angle, opacity): 给需要特殊样式的场合。配置化之后业务调用变得非常干净。比如在Spring Boot里Autowired private WatermarkService watermarkService; public void exportReport(OutputStream out) { WatermarkConfig config WatermarkConfig.createDefault(内部资料请勿外传); config.setRotationAngle(-45); watermarkService.addWatermark(new ByteArrayInputStream(bytes), out, config); }4.3 自动识别文件类型并使用对应的处理器识别文件类型不能只看扩展名因为很多系统传递的InputStream本身没有文件名信息。我用的方法是读取文件头魔数PDF文件头是%PDFDOCX文件本身是Zip压缩包但压缩包内的[Content_Types].xml或word/document.xml才会表明是Word。所以我先读前4个字节判断是不是PDF如果是Zip结构就尝试再读包内的word/目录能读到就是DOCX。如果这两种都不满足直接抛异常提示不支持。这个方法的好处在于即使从数据库里读出来的BLOB字节数组也能正确识别。当然如果业务系统明确知道类型也可以提前设置省去这部分开销。5. 实测验证与常见异常排查5.1 生成水印后的文件质量检查尺寸、清晰度、遮挡关系水印生成之后不能直接丢给用户要先做一轮自测。我常用的验证方法分为三层程序化验证用PDFBox重新打开生成的文件读取每页的文字看水印文字是否出现在内容流中视觉验证把文件转成图片用LibreOffice或者Adobe肉眼检查水印是否居中、是否太浓或太淡、是否与正文重叠兼容性验证同一个文件用不同版本的Office和浏览器PDF阅读器打开确保水印不会在某些软件里消失。其中最容易出问题的是Docx水印。因为VML方式在WPS里支持很好但在某些Mac版Office里可能会显示为“图片水印”的占位符而看不见。这时候可以检查一下header.xml里的v:shape定义是否完整或者考虑用PDF方式渲染一份通知客户。5.2 依赖冲突与版本兼容性POI 4.x、PDFBox 2.x的注意事项很多项目早已引入POI用于生成Excel可能会出现poi-ooxml版本冲突。比如你用了POI 4.1.2但项目里其他模块用的是3.17运行时就会出现NoClassDefFoundError或XmlBeans类错误。解决办法是统一版本并且在引入poi-ooxml时注意不要带ooxml-schemas的重复依赖。PDFBox和POI之间没有直接依赖冲突但两者都会用到commons-logging这类基础库确保你的maven-surefire不排除它们就行。还有一点如果你的应用跑在JDK 17以上PDFBox 2.0.x可能不支持新的module结构需要升级到2.0.25或以上或者使用3.x版本。POI 4.1.2在JDK 17下也有--add-opens的模块警告要记得在启动参数里加上--add-opens java.base/java.langALL-UNNAMED5.3 字体缺失与文件加密等特殊场景的处理字体缺失是PDF水印最头疼的问题。部署到Linux服务器时系统里可没有Windows的simhei.ttf。我建议把需要的字体文件放在项目的resources/fonts目录下打包时一起带上运行时用classpath读取而不是依赖操作系统路径。比如InputStream fontStream getClass().getClassLoader().getResourceAsStream(fonts/simhei.ttf); PDType0Font font PDType0Font.load(document, fontStream);另外加密的PDF不能直接加水印。如果客户上传的PDF有打开密码或所有权密码PDFBox读取时会抛InvalidPasswordException。我的工具类里加了一个可选参数password在读取PDDocument时传入。而对于加密后的Word文档POI本身也不支持需要先转成未加密的Docx再处理这个场景比较少处理策略是直接抛出明确异常让调用方判断。6. 最后分享几点实践经验在我自己的项目里这个工具类已经稳定运行了大半年。最大的感受是把水印逻辑集中到一个类之后后续要做水印文字调整、透明度调整、加时间戳后缀只需要改配置根本不需要碰业务代码。比如我们后来新增了一个需求给所有水印加上当前日期我直接在WatermarkConfig里加了一个appendDate(boolean)字段在创建默认配置时自动拼接日期字符串几十个导出接口全部生效。还有一个小技巧如果你的Docx水印在WPS里显示正常但在微软Office里不显示试着把v:shape里的strokecolor设置成跟fillcolor一样而不是透明。有些Office版本在解析VML时如果strokecolor为透明会连整个图形都隐藏掉。如果你打算长期用建议把工具的单元测试写起来。PDF用PDFBox的PDFTextStripper去判断水印文字是否存在Docx用XWPFDocument读页眉里的XML验证。这样每次修改都有保障不会因为重构把水印弄丢。以上基本都是我在JVM生态下的Java实现。如果你用的是.NET或Python思路完全可以平移Docx本质上都是OpenXMLPDF都是内容流叠加。核心是先把两种格式的内部结构吃得透再来封装工具类就会顺手很多。希望这篇分享能帮你少踩一些坑。