最近在做业务系统的时候,遇到一个高频需求:用户导出的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里插入CTR(Run),再给CTR添加CTDrawing(图形),最后设置文本、旋转角度、字号、颜色和透明度。这些操作听起来绕,但代码写顺手了也不难。
2.2 通过XWPFDocument创建文字水印的完整代码
我直接贴出我封装好的核心方法。首先你需要在Maven里引入poi-ooxml依赖,版本建议用4.1.2以上,我这里用的是4.1.2。
<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>4.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:txbxContent><w:p><w:r><w:rPr><w: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而不是iText
PDF水印的主流库有两个:Apache PDFBox和iText。iText功能更丰富,支持事件监听、字体嵌入、透明度控制,但它采用的是AGPL许可证,对于很多商业项目来说,要么付费,要么开源你的整个业务系统,风险很大。PDFBox是Apache开源许可证,商用完全没问题,所以我在工具类里选择了PDFBox。虽然PDFBox没有iText那么方便的PdfPageEventHelper,但只要自己写一个循环页面的方法,效果完全不差。
我用的PDFBox版本是2.0.24,Maven依赖如下:
<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.24</version> </dependency>3.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里持有一个Map<FileType, 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文件头是
%PDF; - DOCX文件本身是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.lang=ALL-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本质上都是OpenXML,PDF都是内容流叠加。核心是先把两种格式的内部结构吃得透,再来封装工具类就会顺手很多。希望这篇分享能帮你少踩一些坑。