做Java后端的朋友迟早会遇到这么个需求:给PDF文档加水印。业务方上来就是一套组合拳——水印文字得能换行,展示多行说明信息;水印要平铺满整个页面,不能只窝在某个角落;铺满的同时最好带个45度旋转,不然显得太死板。
这不是三个独立特效的简单叠加,而是一整套围绕“坐标计算、字体宽度、页面内容流”的实战组合。我第一次做的时候,光中文字体和旋转偏移就折腾了一个晚上。这篇文章就把我最终稳定跑通的方案拆开讲清楚,包含完整的思路、代码片段和踩坑记录,适合所有用Java处理PDF文档的后端开发者参考。
1. 需求背后的场景与处理思路
1.1 三种效果各自在解决什么问题
先说换行。水印文字往往不是一两个词,而是一整句完整的警示语,比如“内部资料,未经许可严禁外传”“XX项目评审专用章,下载打印无效”这类长文本。如果硬塞进一行,要么超出页面边界被裁掉,要么字被压缩成一条细线,完全丧失标识作用。合理做法是让水印文字按指定宽度自动换行,形成一个多行的文字块,再把这个文字块作为水印单元来处理。
再说平铺。单个水印放在页面中间,很容易被正文内容压住,或者被人用一块色块直接盖掉。平铺意味着水印单元在整个页面内均匀重复出现,形成类似花纹底纹的效果。这样无论文档被截图、裁剪还是打印,任何局部区域都能看到水印痕迹,防扩散、防抵赖的能力要强得多。
最后是旋转。平铺的矩形水印横平竖直排列,视觉上很僵硬,而且一旦正文是排版密集的表格或图片,垂直方向的水印容易被行文覆盖。45度斜向排列是行业里最通用的做法——一方面阅读时不会被正文内容完全遮挡,另一方面也符合人们对“防伪底纹”的直觉预期。
1.2 为什么选用Java来做这件事
选Java不是因为“只会Java”,而是Java在这类后端批量处理任务里确实有优势。水印操作通常不是用户点击一次就结束,而是要在一个服务里定时处理成千上万份PDF,可能是订单合同、报表、电子票据。Java有成熟的PDF处理开源生态,可以无头运行在服务器上,天然适合嵌入Spring这类微服务框架,做成独立的文档水印服务。另外Java的内存管理机制对大批量文档处理比C/C++手动管理靠谱很多,处理1000份文档不太容易出现内存越界类的低级崩溃。
2. 动手前的技术准备
2.1 依赖库与版本差异说明
Java处理PDF绕不开开源界的PDF库,最常用的是iText。我用的是iText 5系列的API,代码风格相对传统,网上资料也多。iText 7把API重写了一遍,类名和包结构变化很大,但其核心思路一致:先拿到页面内容流(ContentByte),在内容流上绘制图形和文字。
不论你最终选用哪个版本,这里有个原则必须记住:水印分为“可以在内容底层画”和“必须在内容顶层画”两种。合同类文档通常要求水印盖在文字上面,防止内容被截图后水印消失;设计图类文档则有时要求水印垫在内容下面,避免遮挡视觉主体。你选择的绘制时机决定了水印和正文的遮盖关系,这是本项目里第一个关键决策点。
2.2 中文字体与编码准备
PDF内容流在默认状态下不认识中文。直接写字符串“内部资料”到PDF里,显示出来多半是方框或乱码。原因是PDF的基础字体(Helvetica、Times等)都是西文编码,不包含中文字形。正确做法是加载一个支持中文的字体文件,常见如思源黑体、方正黑体等TTF字体文件,然后将字体嵌入PDF文档。
代码层面要做两件事:第一是创建BaseFont时指定字体文件路径和编码标识,最常用写法是BaseFont.createFont(fontPath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED);第二是拿到字体后配合new Font(baseFont, fontSize)构造出可用的Font对象。嵌入字体文件会让PDF体积变大一些,但这是确保水印在任何设备上都能正确显示的唯一可靠办法。某些环境里字体文件放在classpath下,路径写法不是/fonts/xxx.ttf而是直接fonts/xxx.ttf,两者区别在于classpath的根目录定位,建议统一写成从classpath根开始算的相对路径。
2.3 先搞懂PDF坐标系,不然后面全乱
这是整个项目里最容易让人蒙圈的地方。PDF页面的坐标原点在左下角,X轴向右,Y轴向上,和我们日常做Web前端时“原点在左上角,Y轴向下”的坐标习惯完全相反。我在第一版代码里想当然把水印从左上角开始画,结果全部跑到了页面外面。
在实际计算平铺位置时,建议始终以左下角为基准点。页面大小可以通过PageSize.A4这类常量获得,也可以从reader.getPageSize(pageNum)拿到当前页的宽高。旋转操作同样受坐标系影响,下文做45度旋转时,旋转中心的选择会直接影响水印最终的位置,这一点后面详细展开。
3. 文字换行的实现细节
3.1 换行不能按字符数,必须按字体实际宽度
最容易踩的坑是“每行放10个汉字,超了就换行”。中英混排时,一个半角英文字母占的宽度只有中文汉字的一半左右,一个全角标点又接近汉字宽度,数字和字母宽度更不固定。按固定字数切行,会出现有的行特别长、有的行特别短,视觉上参差不齐,甚至长行直接冲出页面边界。
真正靠谱的做法是:把要绘制的一整段水印文字,交给字体对象去度量宽度。PDF库内部有精确的字形宽度表,拿到字号之后,可以直接算出字符串在当前字号下占多少个PDF单位(通常是Point)。只要累计宽度超过预设的最大允许宽度,就在这个字符前换行。
3.2 基于字体宽度计算的换行实现
核心代码如下:
// 传入整段文字、字体对象、字号、最大允许宽度 public List<String> splitTextByWidth(String text, Font font, int fontSize, float maxWidth) { List<String> lines = new ArrayList<String>(); if (text == null || text.isEmpty()) { return lines; } StringBuilder currentLine = new StringBuilder(); for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); String candidate = currentLine.toString() + c; // 关键:用字体计算宽度 float width = font.getBaseFont().getWidthPoint(candidate, fontSize); if (width > maxWidth && currentLine.length() > 0) { lines.add(currentLine.toString()); currentLine.setLength(0); } currentLine.append(c); } if (currentLine.length() > 0) { lines.add(currentLine.toString()); } return lines; }这段逻辑并不复杂,关键点在于getWidthPoint方法返回的是候选字符串的真实渲染宽度。用这个宽度和预先设定的maxWidth比较,一旦超宽就把当前行切出去。
这里有个细节:中文字符之间的换行,我们是逐字符判断的。但对于英文单词,逐字符换行可能出现单词被拆碎的情况。实际场景中水印文字多是“禁止外传”“评审专用”这类中文短语,逐个字符切完全没问题;如果水印文字包含英文句子,建议先按空格把单词切成数组,再逐个单词累加宽度判断换行,能避免把“confidential”拆成“confid ential”的尴尬。
3.3 换行后每行文字的垂直间距
换行得到多行文字后,绘制阶段还要处理行间距。直接用字号当行距会让行与行之间贴得很紧,看起来像一团黑块。通常做法是行距取fontSize * 1.2到fontSize * 1.5之间。水印本身是辅助标识信息,不需要像正文那样紧凑排版,宽松的行距反而让水印文字块清晰易读。
4. 水印平铺的布局算法
4.1 平铺网格的数学关系
平铺的实质是把页面看成一个棋盘格子,每个格子放一个水印单元。需要提前确定的参数有:水印单元宽度、水印单元高度、水平间距、垂直间距。水印单元宽度取决于换行后最长那行的宽度,水印单元高度取决于行数、行距和上下留白。
假设页面宽为pageW、高为pageH,水印单元宽为cellW、高为cellH,水平间距为gapX、垂直间距为gapY,那么水平方向理论上可以铺(int)((pageW + gapX) / (cellW + gapX))列,垂直方向铺(int)((pageH + gapY) / (cellH + gapY))行。起始坐标不能直接从0开始,因为水印单元旋转45度之后,它的外接矩形会比原始矩形大一圈,直接从0开始画,页面边缘处的旋转水印会被裁掉。
我自己遇到过这个问题:第一版代码从坐标原点(0,0)开始平铺,A4页面四周边缘的水印文字被削掉了一半,看上去像被刀子切过。解决方法是留出安全边距,起始点的X和Y都从安全边距开始,例如startX = margin + cellW / 2,让水印单元的旋转中心落在安全边距内侧。
4.2 平铺绘制完整代码
public void drawWatermark(String text) { // 假设已经拿到PdfContentByte对象cb Font font = ...; // 预先创建好的中文字体对象 int fontSize = 40; // 这里以A4为例 Rectangle pageSize = PageSize.A4; float pageW = pageSize.getWidth(); float pageH = pageSize.getHeight(); // 水印文字块尺寸,以最长一行宽度为单元宽度 List<String> lines = splitTextByWidth(text, font, fontSize, 300f); float cellW = getMaxLineWidth(lines, font, fontSize) + 30f; float cellH = lines.size() * fontSize * 1.4f + 30f; float gapX = 60f; float gapY = 40f; float margin = 40f; // 安全边距 PdfContentByte canvas = writer.getDirectContentUnder(); canvas.saveState(); // 设置透明度,浅色水印 canvas.setGState(new PdfGState() {{ setFillOpacity(0.2f); }}); float startX = margin + cellW / 2; float startY = margin + cellH / 2; for (float y = startY; y < pageH - margin; y += cellH + gapY) { for (float x = startX; x < pageW - margin; x += cellW + gapX) { drawRotatedText(canvas, lines, font, fontSize, x, y, 45); } } canvas.restoreState(); }布局算法里,cellW和cellH直接决定了平铺密度。gapX、gapY越大,页面上水印越稀疏,越少干扰正文;但太小又会显得杂乱。我经验值是在字号40的情况下,水平间距取60到80,垂直间距取40到60,效果比较均衡。你可以根据业务方对“醒目程度”的要求做调整。
5. 旋转实现与三种效果的整合
5.1 旋转的本质与关键坐标变换
旋转不是把文字“画斜”,而是改变绘制文字时的坐标系。PDF内容流底层支持仿射变换,我们可以先把坐标系平移到旋转中心,再旋转一个角度,然后在旋转后的坐标系里绘制文字。文字本身仍然保持水平,但整个坐标系已经倾斜,最终呈现出的视觉效果就是文字沿着角度方向斜排。
具体到iText 5,核心方法是canvas.concatCTM(AffineTransform.getRotateInstance(radians, x, y))。getRotateInstance有两种常用重载:一个只传旋转角,旋转中心是原点;另一个传角度以及旋转中心坐标(x, y)。用后者可以一步实现“围绕指定点旋转”,不用自己写平移和旋转的矩阵乘法,少踩很多坑。
角度要转成弧度制,Math.toRadians(45)得到的就是45度对应的弧度。不要直接写成Math.PI / 4——虽然数值一样,但代码可读性差,后续想调整成30度或60度时需要重新计算,没必要为难自己。
5.2 旋转与水印平铺的整合
平铺循环里调用drawRotatedText方法,把多行文字作为整体块绘制:
private void drawRotatedText(PdfContentByte canvas, List<String> lines, Font font, int fontSize, float centerX, float centerY, float angle) { float radians = (float) Math.toRadians(angle); canvas.saveState(); // 关键:围绕(centerX, centerY)旋转 canvas.concatCTM(AffineTransform.getRotateInstance(radians, centerX, centerY)); // 计算文字块绘制起点,让文字块中心对齐到(centerX, centerY) float lineHeight = fontSize * 1.4f; float totalHeight = lines.size() * lineHeight; float startX = centerX - getMaxLineWidth(lines, font, fontSize) / 2; float startY = centerY + totalHeight / 2 - lineHeight; for (String line : lines) { canvas.beginText(); canvas.setFontAndSize(font.getBaseFont(), fontSize); canvas.setTextMatrix(startX, startY); canvas.showText(line); canvas.endText(); startY -= lineHeight; } canvas.restoreState(); }这段代码里我先保存状态,旋转并绘制,最后恢复状态。saveState和restoreState是一对必须成双出现的关键调用——如果不恢复状态,旋转会一直叠加下去,第二个水印单元会接着第一个的旋转坐标系继续偏移,整个页面越画越乱。
文字块的对齐逻辑也很重要:先算出文字块的左上角坐标,以此为起点逐行绘制。startX等于centerX减去最长行宽度的一半,startY等于centerY加上总高度的一半,这样多行文字块的水平中线和垂直中点正好落在旋转中心的坐标上,视觉上水印是“以一个点为中心旋转的”,非常整齐。
6. 常见问题与排查速查表
做这套功能时碰到的问题,基本集中在下面这几个点上:
| 现象 | 根本原因 | 解决方案 |
|---|---|---|
| 水印显示为方块或乱码 | 字体文件没嵌入,PDF不认识中文字形 | 设置字体编码为IDENTITY_H,并确保EMBEDDED参数生效 |
| 水印被正文完全遮住 | 绘制顺序不对,水印画在内容流底层且正文不透明 | 改用getDirectContent(),或者调整水印透明度结合顶层绘制 |
| 旋转后位置明显偏了 | 没有围绕指定中心旋转,旋转中心用了原点 | 用getRotateInstance(angle, x, y)同时传中心坐标 |
| 页面边缘的水印被裁掉 | 平铺起始坐标没留安全边距 | 起始位置从margin + cellW / 2开始,而不是0 |
| 平铺越往后越乱 | 用了saveState但没有restoreState | 检查每个绘制单元是否成对调用saveState/restoreState |
| 处理大批量文件内存飙升 | 反复创建字体和文档对象没有释放 | 复用字体对象,一个文档流处理完就关闭 |
| 水印文字扭曲变形 | 旋转后的拉伸系数不对,字体设置了缩放 | 不要在旋转坐标系里再做SetHorizontalScaling等缩放操作 |
除此之外,还有一个不太起眼但很容易引起返工的问题:业务方说“水印要淡一点”,很多新手直接把透明度设为非常低的数值,比如0.05,结果打印出来几乎看不见。水印的意义在于“看得见但弱化存在感”,透明度建议控制在0.2到0.4之间,既能识别又不会过度干扰正文阅读。打印时激光和喷墨设备的色域不一致,屏幕看起来合适的透明度,打印后可能完全消失,有条件的话建议用打印预览效果复核一次。另外,如果文档本身包含透明效果或混合模式,水印叠加后可能出现异常显示,这一问题通常可以通过先将水印绘制到独立层再合并来解决,但普通场景下直接绘制即可。
7. 实操中积累的几个关键经验
第一次做这套组合效果时,我把换行、平铺、旋转分开实现,最后再合并。这个拆分思路帮了大忙——每个环节都能单独验证。每做完一步,输出一个测试PDF,肉眼确认没问题再进下一步,远比一把梭最后对着乱糟糟的成品猜原因高效。
换行功能单独验证时,可以故意传一段中英文混排的超长文本,确认行切得是否均匀;验证平铺时,可以调整间距参数,观察水印密度是否符合预期;验证旋转时,打印一个包含X轴Y轴的测试页面,核对水印旋转后和坐标系的夹角是否为45度。三者凑齐后,再合并验证组合效果。
另一个我在项目后期才悟到的点是:水印毕竟是为了防扩散、防篡改,普通角的45度旋转固然经典,但不同业务场景其实值得尝试不同的角度。20度到30度的倾角,水印方向更接近自然阅读习惯,对正文遮挡更小;90度垂直平铺则更适合带条形码或二维码的文档。角度参数最好做成外部配置项,别写死成常量,业务方改需求时就不用动代码。
最后再分享一个细节:输出文件命名上,我习惯把_watermarked作为后缀加在原文件名后面,例如合同_watermarked.pdf,而不是直接覆盖原始文件。这样既方便对账,又能在水印方案出问题时保留原文件重新处理。这个小习惯帮我避免过好几次数据事故。
这套Java实现PDF水印换行、平铺、旋转的方案,本身不难,难的是把坐标系、字体宽度和内容流绘制顺序这三件事串起来。理清这三条主线之后,无论后续接到什么奇奇怪怪的水印需求——圆形水印、日期动态水印、多段文字混合水印——都只是在现有基础上多绕几个弯而已。