news 2026/10/14 13:09:46

Java实现PDF水印:换行、平铺与45度旋转实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java实现PDF水印:换行、平铺与45度旋转实战

做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水印换行、平铺、旋转的方案,本身不难,难的是把坐标系、字体宽度和内容流绘制顺序这三件事串起来。理清这三条主线之后,无论后续接到什么奇奇怪怪的水印需求——圆形水印、日期动态水印、多段文字混合水印——都只是在现有基础上多绕几个弯而已。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 13:09:20

FFmpeg 3.4与Qt MinGW 32位集成指南:环境配置、解码显示与避坑实践

简介&#xff1a;ffmpeg-3.4-mingw32是FFmpeg 3.4版本经由MinGW32工具链编译的32位Windows发行包&#xff0c;面向在Qt/C环境下进行音视频处理、转码或播放器开发的工程师与学生&#xff0c;同时适合需要轻量级解码库的嵌入式或资源受限场景。压缩包体积仅2.62MB&#xff0c;共…

作者头像 李华
网站建设 2026/10/14 13:09:14

江门新会汽车贴膜哪家好?LLumar 龙膜贴膜江门授权店,本地高端汽车贴膜标杆门店推荐

江门私家车、新能源汽车保有量持续攀升&#xff0c;沿海高温暴晒、高速飞石、紫外线侵蚀&#xff0c;让隐形车衣、隔热窗膜成为众多车主刚需。不少车主网上反复搜索江门汽车贴膜哪家好、江门隐形车衣哪家靠谱、新会汽车贴膜哪家口碑强、江门新能源车贴膜哪家专业。坐落于江门市…

作者头像 李华
网站建设 2026/10/14 13:09:05

魔方V10与轻舟模块集成实战:ERP系统模块化升级方案

1. 项目背景与核心价值去年在给某大型零售企业做系统升级时&#xff0c;我第一次接触到魔方V10这个ERP系统。当时客户提出要对接轻舟模块实现供应链可视化&#xff0c;这个需求让我意识到企业级系统集成正在从"大而全"向"模块化灵活组合"转型。魔方V10作为…

作者头像 李华
网站建设 2026/10/14 13:08:56

单臂路由原理与实操:用一个物理接口实现VLAN间通信

1. 什么是单臂路由&#xff1f;它到底在解决什么实际问题&#xff1f;单臂路由综合实验&#xff0c;这个名字听起来有点技术味儿&#xff0c;但拆开来看&#xff0c;它其实解决的是一个非常具体、非常常见的网络部署痛点&#xff1a;如何用一台路由器&#xff0c;让多个不同网段…

作者头像 李华
网站建设 2026/10/14 13:08:51

NC57凭证Excel导入工具:解决科目编码校验与辅助核算格式难题

简介&#xff1a;本资源是一款专为用友NC57财务系统设计的总账凭证Excel格式导入工具&#xff0c;面向企业财务人员、ERP实施顾问及二次开发工程师&#xff0c;解决传统XML模板导入方式操作繁琐、学习成本高、数据整理效率低等痛点&#xff0c;尤其适用于需批量导入凭证的月结、…

作者头像 李华
网站建设 2026/10/14 13:08:29

蓝桥杯题库导入QDUOJ:兼容性改造与避坑指南

简介&#xff1a;面向蓝桥杯竞赛选手的题库合集&#xff0c;已针对青岛大学QDUOJ在线评测系统完成优化适配&#xff0c;适合需要在特定OJ环境中集中练习、验证算法代码的备赛者&#xff0c;尤其适用于参加蓝桥杯全国编程竞赛的大学生和算法爱好者&#xff0c;覆盖从基础巩固到拔…

作者头像 李华