Java实现PDF多行水印:换行、旋转、平铺全解析

发布时间:2026/10/14 1:44:00
Java实现PDF多行水印:换行、旋转、平铺全解析
一次真实的交付场景我到现在还记得很清楚某个系统的报告导出功能对方临时提要求所有PDF页面都要盖上“内部资料·禁止外传”必须斜着铺满全页而且水印文字是两行甚至多行内容不能挤成一排否则就“丑拒”。于是我把Java处理PDF水印的换行、平铺、旋转三个硬需求研究了一整遍最终落成一套可以复制到任何项目里的工具类。这篇就按我踩坑的顺序把完整实现思路、代码和参数经验都写出来遇到同样需求直接抄。1. 背景与方案选型水印不是“贴字”是“排版”1.1 需求背后是三件事文本容器、坐标网格、图形变换很多项目第一次接触水印需求第一反应是“在页面上打印一行文字而已”真正动起手来才发现水印本质是个排版问题而且是三重排版问题的叠加。第一重是换行。水印文案往往是“内部资料”“禁止外传”“请勿拍照”这种多行组合每一行还要居中。如果只用单行绘制接口文本会平铺成一行又长又傻视觉上完全不是水印。第二重是平铺。水印要覆盖整页不能只在页面正中放一条。这里需要一套坐标网格按水平间距和垂直间距把相同内容复制到所有格点上。真正难的不是循环本身而是旋转后的网格怎么算才不漏角。第三重是旋转。水印几乎都是斜着的常见-30度到-45度。PDF底层有一套坐标变换矩阵理解不了这层写出来的代码要么水印位置飞出页面要么旋转中心完全不对。把这三个需求映射到技术实现上就是三套对应的底层能力文本排版引擎、页面坐标系和网格算法、PDF图形状态机。我的建议是先想清楚这三件事再去写代码否则很容易陷入“改一个参数跑一次”的泥潭。1.2 三条实现路线的对比与最终选择Java处理PDF水印我能想到的无非三条路线第一条用报告模板生成新PDF时直接加水印。适用场景是项目自己生成PDF可以在模板阶段就绘制水印。但问题是一旦要处理别人传上来的PDF、历史文件这条路就直接断了而且把水印逻辑耦合在生成报表的代码里后面想改样式特别痛苦。第二条把每一页PDF转成图片用图像处理库在上边画水印再重新导出PDF。这个方案视觉上最可控但致命缺陷是PDF从矢量变成了位图文字不可选、不可搜索、文件体积暴涨打印质量也会下降放到交付场景里基本一票否决。第三条直接在PDF内容流层面操作用底层绘制API在水印层写文字。这也是我最终采用的路线读取原PDF遍历每一页在页面内容流里追加水印指令。优点是不改变原有内容的可编辑性文件体积增加很小速度也快。配合PdfContentByte和文本排版对象换行、平铺、旋转都能自己控制。路线定了以后库的选型就简单了。我选的是经典开源PDF处理库就是com.lowagie.text.pdf这一系以 openpdf 1.3.28 或更高版本为例其API成熟、资料多ColumnText的排版能力和PdfContentByte的CTM坐标变换都能满足需求。如果你用的是同系库代码可以直接平移。2. 先把中文水印写上去字体加载与颜色透明度2.1 第一次跑出“豆腐块”之后我改成了这种字体加载方式很多新手第一个水印Demo输出后英文是对的中文全是“□□□□”原因只有一个字体不对。PDF不是操作系统它不认系统中安装的字体名称必须显式指定嵌入或引用一个包含中文字形的字体文件并告诉PDF库用什么编码去映射这些字符。我最初的写法是加载系统默认字体结果跑出来的中文全部乱码。后来改成优先加载PDF库自带的亚洲字体包找不到时再回退到系统字体文件一句话总结字体加载要分两层第一层尝试内建中文字体第二层从常见路径读取真实字体文件。我用的字体加载逻辑简化后大概是这样的private static BaseFont loadChineseFont() { try { // 方式一库内建的STSong-Light适合快速验证不嵌入字体文件 return BaseFont.createFont(STSong-Light, UniGB-UCS2-H, BaseFont.NOT_EMBEDDED); } catch (Exception e) { // 方式二从系统常见字体文件加载并嵌入保证任意机器上一致 String[] candidates { C:/Windows/Fonts/simhei.ttf, // Windows系 C:/Windows/Fonts/msyh.ttf, // Windows系 /usr/share/fonts/truetype/wqy/wqy-microhei.ttc, // Linux系 /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc }; for (String path : candidates) { File f new File(path); if (f.exists()) { return BaseFont.createFont(path, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); } } throw new IllegalStateException(找不到可用的中文字体文件请手动指定字体路径); } }第一层NOT_EMBEDDED的意思是字体不嵌入PDF文件体积小但看到的效果取决于阅读器有没有对应字体。第二层EMBEDDED会把字体子集化后打包进PDF体积会大几兆但换台电脑显示也不会变。生产环境我建议走第二层用真实字体文件不要赌对方的PDF阅读器。另外有个性能小坑createFont每次调用都触发字体文件读取几百页PDF循环下来很浪费。我习惯用一个静态变量缓存已经创建的字体对象第一次加载完后面直接复用。注意多线程项目里要加上线程安全处理否则并发调用会偶发字体加载死锁。2.2 水印颜色和透明度有存在感但别刺眼水印不是正文它要在“看得见”和“不碍事”之间找平衡。颜色我用浅灰或者按企业VI淡化处理后的浅蓝、浅红反正RGB里三个通道保持接近、饱和度低就不会抢正文视线。透明度我强烈建议用PdfGState控制而不是直接依赖颜色带透明通道。原因很简单颜色带透明通道在部分PDF阅读器上有兼容问题而图形状态是PDF标准里的正规机制。每次绘制单个水印前先存状态设置透明度绘制然后恢复状态这样不会影响到后面的水印。PdfGState gs new PdfGState(); gs.setFillOpacity(0.3f); // 填充透明度 0.3数字越小越淡 gs.setStrokeOpacity(0.3f); // 描边透明度字体一般只用填充但一起设置更保险 cb.setGState(gs);透明度取值我一般建议0.2到0.4之间。低于0.2打印出来几乎看不见高于0.4正文被盖得难受阅读体验直线下降。还有一个层级选择stamper.getUnderContent(pageIndex)把水印放在页面内容底层正文在上水印只从空白处透出来stamper.getOverContent(pageIndex)把水印放在最上层会直接压住正文。如果页面有背景图片getUnderContent会被背景遮得干干净净所以我大多数场景直接用getOverContent配合透明度既保证水印可见又不影响阅读。3. 让水印换行用 ColumnText 搭一个迷你排版区3.1 为什么不是 showTextAligned单行文本的天花板很多初版水印代码会这么写cb.beginText(); cb.setFontAndSize(baseFont, fontSize); cb.showTextAligned(PdfContentByte.ALIGN_CENTER, 内部资料\n禁止外传, pageWidth / 2, pageHeight / 2, -45); cb.endText();这段代码跑出来的效果那个\n基本不会变成换行。有的实现会输出一个类似乱码的空白字符有的直接忽略反正最终结果就是一行很长的文本歪在页面中间根本没有“多行水印”的观感。原因在于showTextAligned只负责把一串文本按某个锚点对齐输出它不管文本内部有没有换行符也不管文本宽度超不超页面。想让它自动换行等于让一个只认识“起点、终点”的函数去干排版引擎的活走不通。3.2 用 ColumnText 搭一个“迷你排版区”换行必须交给排版对象。在com.lowagie.text这一体系里ColumnText就是干这个的给它一个矩形区域、行距、对齐方式它会把多行文本排进区域里一行放不下就换行继续写下一行。我在旋转、平铺之前先验证了单点换行核心代码是// 1. 准备一个矩形排版区坐标是左下角0,0到宽,高 ColumnText ct new ColumnText(cb); // 2. 放进带 \n 的文本和字体样式绑定 Phrase phrase new Phrase(内部资料\n禁止外传\n请勿拍照, font); // 3. 设置排版参数文本、左、下、右、上、行距、水平对齐 ct.setSimpleColumn(phrase, 0, 0, boxWidth, boxHeight, leading, Element.ALIGN_CENTER); // 4. 强制排版 ct.go();这里的boxWidth和boxHeight是排版区的宽度和高度leading是行距也就是两行基线之间的距离。多行文本会根据boxWidth自动决定每行放在哪儿ALIGN_CENTER让每一行在矩形内水平居中。实际使用中要注意一个隐藏问题如果水印文本很多超过了boxHeight能容纳的行数ColumnText只排版能放下的部分多余行会被截断。所以设置排版区高度时要么按行数 * leading 上下留白计算要么调大boxHeight留足余量。我常用四个短词组合boxHeight给到60到80就绰绰有余。4. 旋转与平铺坐标变换和网格算法是分不开的4.1 旋转的数学账单CTM矩阵到底在干嘛PDF的坐标系统底层不关心“文本角度”这种东西它只有一张坐标变换矩阵叫CTMCurrent Transformation Matrix。你把这张矩阵改掉后续绘制的所有内容都会跟着旋转、平移、缩放。PdfContentByte.concatCTM(a, b, c, d, e, f)对应的变换公式是x a*x c*y e y b*x d*y f写代码时最容易犯的错误是把矩阵参数位置记反。标准逆时针旋转角度theta时double rad Math.toRadians(angle); float cos (float) Math.cos(rad); float sin (float) Math.sin(rad); cb.concatCTM(cos, sin, -sin, cos, x, y);这里x, y是坐标平移量也就是旋转后局部坐标系的原点落在页面哪里。cos, sin, -sin, cos就是标准的二维旋转矩阵。这个矩阵会让“局部坐标系的X轴”朝旋转方向倾斜“局部坐标系的Y轴”随之垂直倾斜。实际在循环里画一个旋转水印完整套路是cb.saveState(); cb.setGState(gs); cb.setColorFill(grayColor); cb.concatCTM(cos, sin, -sin, cos, anchorX, anchorY); // 在旋转后的局部坐标系里创建一个从(0,0)到(boxWidth, boxHeight)的排版区 ColumnText ct new ColumnText(cb); ct.setSimpleColumn(phrase, 0, 0, boxWidth, boxHeight, leading, Element.ALIGN_CENTER); ct.go(); cb.restoreState();注意anchorX和anchorY是旋转后排版区左下角对应的页面坐标。先concatCTM再排版ColumnText里的(0,0)就不再是页面的左下角而是“旋转后的起点”旋转和排版这两个需求就被解耦了逻辑非常清爽。4.2 平铺参数设计间距、交错和角落覆盖平铺思路非常直白两层循环按固定间隔遍历所有锚点在每个锚点上执行一次“旋转排版”。for (float y startY; y endY; y gapY) { for (float x startX; x endX; x gapX) { // 在这里执行上面的旋转和排版 } }但如果你直接让startX 0, startY 0, endX pageWidth, endY pageHeight最后一定会在页面四个角露出大块空白。原因很简单水印文字是旋转过的它的排版矩形在对角线上突出很远锚点即使落在页面范围内水印的有效区域也会超出页面反过来锚点在页面范围外一点点的水印其文字主体可能已经飘进页面里了。所以遍历范围必须向四边扩展一个安全距离这个距离取水印排版矩形的对角线长度float diagonal (float) Math.sqrt(boxWidth * boxWidth boxHeight * boxHeight); float startX -diagonal; float startY -diagonal; float endX pageWidth diagonal; float endY pageHeight diagonal;这个扩法简单粗暴但有效。因为任何锚点与页面边界的距离只要大于对角线长水印矩形旋转后就不可能碰到页面所以把范围扩到-diagonal到尺寸diagonal就能做到全页覆盖。密度控制看两个参数gapX水平间隔、gapY垂直间隔。间隔越小水印越多越密。想要更有层次感的平铺还可以让奇数行整体错开半个横向间隔形成“砖墙”式交错网格视觉上比规规矩矩的方格更自然int row 0; for (float y startY; y endY; y gapY, row) { float shift (row % 2 0) ? 0 : gapX / 2; for (float x startX shift; x endX; x gapX) { drawWatermarkAt(x, y, ...); } }旋转角度也可以玩出花让奇数行用-30度、偶数行用30度或者每隔几行换一个角度水印会像编织纹理一样交错。不过大多数真实需求只要求统一角度我建议先做好固定角度再考虑差异化。4.3 完整循环体内的实现所有代码拼在一起把上面所有逻辑放一起单个页面的水印绘制逻辑大致如下。这是一段伪代码风格的串讲看懂它后面整工具类就顺理成章for (int pageIndex 1; pageIndex reader.getNumberOfPages(); pageIndex) { Rectangle pageSize reader.getPageSizeWithRotation(pageIndex); float pageWidth pageSize.getWidth(); float pageHeight pageSize.getHeight(); PdfContentByte cb stamper.getOverContent(pageIndex); // 安全范围 float diagonal (float) Math.sqrt(boxWidth * boxWidth boxHeight * boxHeight); float startX -diagonal; float startY -diagonal; float endX pageWidth diagonal; float endY pageHeight diagonal; int row 0; for (float y startY; y endY; y gapY, row) { float shift (row % 2 0) ? 0 : gapX / 2; for (float x startX shift; x endX; x gapX) { cb.saveState(); cb.setGState(gs); cb.setColorFill(grayColor); cb.concatCTM(cos, sin, -sin, cos, x, y); ColumnText ct new ColumnText(cb); ct.setSimpleColumn(phrase, 0, 0, boxWidth, boxHeight, leading, Element.ALIGN_CENTER); ct.go(); cb.restoreState(); } } }saveState和restoreState必须成对出现而且最好是“一水印一配对”。这不仅能隔离旋转矩阵还能防止透明度设置污染其他绘制内容。我在实战里见过有人把透明度设置放在循环外结果后面正常内容全变半透明定位了很久才想起来是水印代码没恢复状态。5. 可复用的工具类从主函数到全量参数5.1 完整实现代码下面这段是我在实际项目中用的工具类精简版去掉了一些业务耦合保留了完整可运行能力。它支持多行水印文本、全局旋转角度、平铺交错、透明度和颜色设置。Maven坐标你们按自己用的库版本调整核心API都一样。import com.lowagie.text.*; import com.lowagie.text.pdf.*; import java.io.File; import java.io.FileOutputStream; public class PdfWatermarker { private static BaseFont cachedFont; public static void main(String[] args) throws Exception { addWatermark( input.pdf, output.pdf, 内部资料\n禁止外传\n请勿拍照, 18, // 字号 220f, // 水平间隔 140f, // 垂直间隔 180f, // 排版区宽度 72f, // 排版区高度 26f, // 行距 -30, // 旋转角度 0.3f, // 透明度 192, 192, 192); // 浅灰色 System.out.println(水印添加完成); } public static void addWatermark(String src, String dest, String watermarkText, float fontSize, float gapX, float gapY, float boxWidth, float boxHeight, float leading, float angle, float opacity, int red, int green, int blue) throws Exception { BaseFont baseFont loadChineseFont(); Font font new Font(baseFont, fontSize, Font.NORMAL); Phrase phrase new Phrase(watermarkText, font); double rad Math.toRadians(angle); float cos (float) Math.cos(rad); float sin (float) Math.sin(rad); BaseColor color new BaseColor(red, green, blue); PdfGState gs new PdfGState(); gs.setFillOpacity(opacity); gs.setStrokeOpacity(opacity); PdfReader reader new PdfReader(src); PdfStamper stamper new PdfStamper(reader, new FileOutputStream(dest)); for (int pageIndex 1; pageIndex reader.getNumberOfPages(); pageIndex) { Rectangle pageSize reader.getPageSizeWithRotation(pageIndex); float pageWidth pageSize.getWidth(); float pageHeight pageSize.getHeight(); PdfContentByte cb stamper.getOverContent(pageIndex); float diagonal (float) Math.sqrt(boxWidth * boxWidth boxHeight * boxHeight); float startX -diagonal; float startY -diagonal; float endX pageWidth diagonal; float endY pageHeight diagonal; int row 0; for (float y startY; y endY; y gapY, row) { float shift (row % 2 0) ? 0 : gapX / 2; for (float x startX shift; x endX; x gapX) { cb.saveState(); cb.setGState(gs); cb.setColorFill(color); cb.concatCTM(cos, sin, -sin, cos, x, y); ColumnText ct new ColumnText(cb); ct.setSimpleColumn(phrase, 0, 0, boxWidth, boxHeight, leading, Element.ALIGN_CENTER); ct.go(); cb.restoreState(); } } } stamper.close(); reader.close(); } private static BaseFont loadChineseFont() { if (cachedFont ! null) { return cachedFont; } try { cachedFont BaseFont.createFont( STSong-Light, UniGB-UCS2-H, BaseFont.NOT_EMBEDDED); return cachedFont; } catch (Exception e) { String[] candidates { C:/Windows/Fonts/simhei.ttf, C:/Windows/Fonts/msyh.ttf, /usr/share/fonts/truetype/wqy/wqy-microhei.ttc, /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc }; for (String path : candidates) { File f new File(path); if (f.exists()) { try { cachedFont BaseFont.createFont( path, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); return cachedFont; } catch (Exception ignore) { // 继续尝试下一个字体文件 } } } throw new IllegalStateException(未找到可用中文字体请手动指定字体路径); } } }单页PDF加了水印后文件大小变化几乎可以忽略几百页的大文件会多出几兆主要是嵌入字体子集的开销完全在可接受范围内。速度上最慢的环节是字体文件首次加载其次才是逐个页面的坐标循环。实际跑一个几百页的文件水印绘制本身连一两秒都不用。5.2 参数调优建议不同场景的参数组合参数这个东西不同字号、不同页面大小手感完全不同。我给几个实测参数组合作为起点你们拿到项目里先跑一遍看效果再微调。场景字号间隔 gapX/gapY排版区宽/高旋转角度透明度说明内部资料防截图18220/140180/72-300.35最常见的组合密度适中合同级强水印22280/170220/90-450.28间隔大、字形大不遮挡正文普通报告标识14160/100140/60-300.3字号小铺得密一点也无妨证书防伪造背景20260/160200/80300.2正角度水印右侧上扬gapX和gapY的取值要大于排版区在对应方向的投影否则水印之间会明显重叠。但因为旋转的存在直接用gapX boxWidth并不完全正确我更推荐用“跑一次放大PDF看”的方式调。PDF不像网页能实时预览调参是件体力活所以把参数从代码里抽出来放到配置文件是最划算的。6. 实战排坑记录这些坑只有跑过才知道6.1 高频问题速查表我整理了一份速查表前几个问题几乎每个接手的人都会问一遍。现象大概率原因解决办法中文全部变成方块或乱码字体文件没有中文字形或字体加载失败被降级到默认字体使用带中文字体文件并显式加载优先 EMBEDDED文本不换行全挤在一行用了 showTextAligned 而不是 ColumnText换成 ColumnText设置矩形区域和行距水印位置跑飞跑到页面外面concatCTM 参数顺序不对或锚点坐标越界检查矩阵参数acos, bsin, c-sin, dcos锚点加安全范围旋转角度方向反了PDF坐标系Y轴向上角度正负和直觉相反把角度改成相反数比如 -30 改 30多试一次平铺四角漏空白循环范围只覆盖了页面矩形把遍历范围上下左右扩展一个对角线长度水印完全看不见getUnderContent 被背景图或正文盖住改成 getOverContent并把透明度调到0.3左右水印太淡或太重透明度设置不当或被外部状态污染统一用 PdfGState 控制透明度每一水印 saveState/restoreState文件体积暴涨每个页面都嵌入了完整字体使用字体子集化或只嵌入一次检查字体加载是否被反复调用打开PDF很卡水印数量太多重叠严重增大 gapX/gapY或减少交错行密度6.2 几个藏得比较深的坑第一个坑是ColumnText的排版区域高度问题。我一开始测试只有两行文字boxHeight设了40没问题。后来换成四行文案直接只显示两行剩下两行看不见了。后来又跑到某台机器上因为字体度量略有不同同样的boxHeight少装了一行。现在我的习惯是boxHeight至少等于行数 * leading 10多行文案宁可多留空间也不让它吃回头亏。第二个坑是字体对象缓存。在几百页的循环里如果每次页面都重新createFont从系统字体文件读取解析那段耗时会被放大很多倍。我这里用了静态缓存但要注意这个缓存不做线程互斥的话并发环境可能同时加载两次。简单做法是加一个同步方法或者用“双检查锁”成本不高收益很直接。第三个坑是关于stamper.close()和reader.close()的顺序。正确顺序是先关stamper再关reader。如果反过来某些版本会直接把输出流关掉导致PDF写不完整事后打开提示文件损坏。这个错误极其隐蔽因为小文件可能偶然成功等到几百页大文件才稳定复现。第四个坑是旋转后的字体渲染效果。同一套字号和角度不同阅读器放大后的边缘平滑度有明显差异特别是斜向文字的锯齿。这个很难完全消除但有一个缓和技巧不要叠加多层旋转比如在已经旋转过的状态下再对ColumnText进行角度设置会放大锯齿把一个水印的旋转、平移、排版都按“一次变换、一次绘制”来做渲染效果最干净。还有一个小细节如果原PDF本身带有页面旋转属性比如某些扫描件旋转了90度那么水印坐标系和视觉坐标系会不一致。getPageSizeWithRotation返回的是旋转后的宽高能避免大部分问题但水印的绝对角度仍可能和页面视觉角度有偏差。遇到这种文件先跑一次看效果再决定要不要额外补偿角度。最后再分享一个实际经验我做了这么多水印需求后最大的体会是参数表永远没有“通用默认值”只有“你的场景默认值”。正式交付前一定要把至少三页不同内容的PDF合成一个测试文件分别跑不同密度、不同透明度然后让业务方打开真实阅读器看一眼。尤其是在调整文本换行时先把文字内容固定下来再调间距和角度不然每改一次文案密度和旋转就得重新调人会崩溃。这个工具类的扩展方向也有很多比如只对指定页码范围加水印、按文档标题动态生成水印文案、在平铺水印之间加入二维码防伪标识都是在现有矩阵变换框架上追加独立绘制块的事。你先掌握换行、平铺、旋转这三个核心点后面想怎么加都不会太费劲。