做了这么多年Java开发,PDF处理的需求几乎绕不开。之前接了个活儿,对方不给我PDF文件路径,直接传了个字节数组过来,让我往里加空白页,还要顺带在页面上做点简单的文字标注。一开始我想着这不就是PDDocument.load一下的事嘛,等真上手才发现,字节流和文件路径的处理方式差异不小,坑也不少。这篇就把我用PDFBox操作PDF字节流、添加空白页、做简易编辑的完整过程和踩过的坑整理出来,给同样被字节流折磨过的朋友参考。
1. 项目背景与需求拆解
1.1 为什么是字节流而不是文件路径
平时最常见的PDF操作方式无非就是传个文件路径,new File("xxx.pdf")一捏,PDDocument.load(file)一读,完事。但真实项目里,尤其是对接第三方系统、微服务调用、消息队列消费这类场景,PDF文件往往是以byte[]或者InputStream的形式存在的。
比如我这次接的需求,上游是一个文档生成服务,它把生成好的PDF直接通过HTTP接口以二进制流形式返回给我。我要做两件事:
- 在PDF的指定位置插入若干空白页;
- 给每一页加上页脚标注,比如“第X页 / 共Y页”。
这个过程如果先把字节流转成临时文件再处理,也不是不行,但有几个明显问题:
- 需要额外处理临时文件的创建和删除,搞不好还会留下垃圾文件;
- 在高并发场景下,频繁的磁盘IO会拖慢整体性能;
- 在云函数、容器化环境里,文件系统可能是只读的或者临时目录空间有限。
所以直接基于字节流操作,才是干净利落的做法。再说了,PDFBox本身就是支持从InputStream加载和保存到OutputStream的,没必要绕弯路。
1.2 核心需求里藏着哪些技术点
拆解一下这个需求,其实涉及几个关键环节:
- 字节流加载PDF:
PDDocument.load(InputStream),注意这个InputStream最好是支持随机访问的,否则PDFBox会采用不同策略; - 添加空白页:
PDDocument的addPage方法配合PDPage对象,真正细节的地方在页面尺寸的设定; - 内容流编辑:要在页面上写字、画线,就得开
PDPageContentStream,设置字体、字号、颜色等属性; - 字体问题:中文字体渲染是重灾区,PDFBox默认字体不支持中文,必须手动加载系统TTF字体;
- 保存与回写:处理完要把
PDDocument写回ByteArrayOutputStream,再转回byte[]给下一个环节。
这里面的每一项单独拎出来都不算难,但组合在一起,细节和坑就多了。尤其是字体和页面尺寸这两个,我调试的时候确实费了不少功夫。
2. 环境准备与核心API速览
2.1 Maven依赖引入
我用的是PDFBox 2.x版本,这是目前最稳定、资料最多的版本线。别一上来就用最新版,2.0.x的稳定性和生态经过长期验证,绝对够用。Maven坐标如下:
<dependency> <groupId>org.apache.pdfbox</groupId> <artifactId>pdfbox</artifactId> <version>2.0.27</version> </dependency>如果要做更复杂的编辑,可能还需要fontbox,不过这个依赖会随着pdfbox一起传递引入,不需要额外声明。用3.x版本的朋友注意一下,3.0之后API有Breaking Change,比如PDDocument.load的异常类型可能不同,但我这里以2.0.27为准,稳定优先。
2.2 核心类和使用逻辑
PDFBox里最核心的几个类先过一遍:
PDDocument:整个PDF文档的抽象,加载、保存、关闭都靠它;PDPage:表示PDF里的一个页面,由它来承载内容流;PDPageContentStream:内容流对象,往页面上画东西全靠它,文本、线条、形状都通过它写入;PDRectangle:页面尺寸的载体,A4、A3或者自定义尺寸都是由这个类定义的;PDType0Font:加载外部TTF字体文件,用于中文等非内置字符集渲染。
常规的操作链路是:加载文档 → 操作页面 → 用内容流绘制 → 保存 → 关闭文档。这个链路我在字节流场景下走了一遍,发现有几个地方跟文件操作的直觉不一样,下面细说。
3. 字节流加载与保存的正确姿势
3.1 从byte[]到PDDocument
首先是加载,字节流直接塞给PDDocument.load即可:
public PDDocument loadFromBytes(byte[] pdfBytes) throws IOException { return PDDocument.load(new ByteArrayInputStream(pdfBytes)); }这一步本身没什么难度,但是你要注意一个核心问题:ByteArrayInputStream不支持随机访问。PDFBox内部碰到不支持RandomAccess的输入流时,会把整个流的内容拷贝到内存中的一个RandomAccessBuffer里,然后再解析。这个行为对调用方是透明的,但你心里要有数——如果PDF很大,比如几十MB甚至上百MB,这一步就会吃掉相当可观的内存。
提示:如果你的PDF是从文件读出来的byte[],并且后续要多次读取或频繁操作,可以考虑先存入临时文件再用
RandomAccessFile方式加载,能有效降低内存压力。纯字节流场景下,最好评估一下文件大小再做取舍。
另外有一点,PDDocument.load(InputStream)执行完之后,传入的InputStream是可以关闭的,PDFBox内部已经把需要的数据读取完了。但PDDocument用完之后必须调用close(),这个一定要记得,不然文件句柄和内存资源都会泄露。
3.2 从PDDocument回写到byte[]
操作完文档,要把它转回字节流。这里我踩过坑:千万别直接尝试从PDDocument拿什么“原始字节”再改,正确的做法是通过save方法输出到ByteArrayOutputStream:
public byte[] saveToBytes(PDDocument document) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); }这个方法的背后逻辑是PDFBox会把整个文档结构重新序列化一遍,包括修改过的页面、对象。所以哪怕你只是加了一页,整个文件也可能会被重写,对于大文件来说耗时不可忽视。
还有一点值得注意:document.save(baos)执行完之后,baos还在手里,但如果你紧接着调用document.close(),再尝试读取baos.toByteArray()是没问题的,数据已经拷贝出来了。这里顺序无所谓,重点是别在document.close()之后再去操作baos对应的资源就行。
3.3 一个完整的工具方法骨架
把加载和保存封装起来,写成一个工具类,后面所有场景复用:
public class PdfBytesEditor { public byte[] addBlankPages(byte[] srcBytes, int pageCount) throws IOException { PDDocument document = null; try { document = PDDocument.load(new ByteArrayInputStream(srcBytes)); for (int i = 0; i < pageCount; i++) { PDPage blankPage = new PDPage(PDRectangle.A4); document.addPage(blankPage); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); } finally { if (document != null) { document.close(); } } } }这个try-finally结构不是装样子,PDDocument如果不close,在服务器端跑久了内存占用会飙得很难看。我后来还专门用try-with-resources重构过,简洁一些。
4. 添加空白页的核心细节与参数选择
4.1 页面尺寸的选择逻辑
PDPage的构造可以直接传一个PDRectangle对象,常见的尺寸如下:
| 页面类型 | PDRectangle常量 | 尺寸(磅) |
|---|---|---|
| A4 | PDRectangle.A4 | 595 x 842 |
| A3 | PDRectangle.A3 | 842 x 1191 |
| Letter | PDRectangle.LETTER | 612 x 792 |
| 自定义 | new PDRectangle(width, height) | 任意 |
这里先说明一下PDF里的“磅”:1磅 = 1/72英寸,A4的宽高是210mm×297mm,换算过来正好是595×842磅。如果你是从其他系统拿到的PDF,想跟原页面保持一致的尺寸,千万不要拍脑袋用A4,最稳妥的做法是读取现有页面的尺寸然后套用:
PDRectangle existingSize = document.getPage(0).getMediaBox(); PDPage newPage = new PDPage(existingSize); document.addPage(newPage);这样做的好处是文档所有页面尺寸统一,打印时不会出现页面大小不一致导致的分页错乱。
4.2 指定位置插入空白页
addPage默认是把新页追加到文档末尾,但很多业务场景是要在指定位置插入。比如合同需要在第二页和第三页之间插入一页补充协议,这时候就要用getPages().insert方法:
public void insertBlankPage(PDDocument document, int index, PDRectangle pageSize) { PDPage blankPage = new PDPage(pageSize); document.getPages().insert(blankPage, index); }注意insert的索引从0开始,也就是说insert(blankPage, 0)是插到第一页的位置,insert(blankPage, document.getNumberOfPages())等价于addPage。
4.3 初始化页面属性
创建空白页的时候还能顺手做一些初始化,比如背景颜色。之前我在一个案例里需要在新增页面上加灰色背景,用来区分不同合同附件。设置背景色其实是通过内容流填充整个页面矩形来实现的:
PDPage blankPage = new PDPage(PDRectangle.A4); document.addPage(blankPage); PDPageContentStream cs = new PDPageContentStream(document, blankPage); cs.setNonStrokingColor(240, 240, 240); // 浅灰色 cs.addRect(0, 0, blankPage.getMediaBox().getWidth(), blankPage.getMediaBox().getHeight()); cs.fill(); cs.close();这里有个细节:new PDPageContentStream(document, blankPage)默认会清空页面上已有的内容流吗?不会。构造方法里有个重载可以传“append模式”,如果页面原本有内容,想要在现有内容之上叠加绘制,需要使用PDPageContentStream(document, blankPage, AppendMode.APPEND, true, true)。但空白页没有这个顾虑,默认模式就好。
注意:每次用完
PDPageContentStream之后一定要close()。如果你不close,内容流可能不会被正确写入PDF,页面就真的是“空白的”,你以为加了背景但实际上没有。
5. 简易编辑实战:文字、线条和基础图形
5.1 在页面上写文字
把文字写到页面上,核心是三步:创建内容流、设置字体、绘制文本。最简单的例子:
PDPageContentStream contentStream = new PDPageContentStream(document, page); contentStream.beginText(); contentStream.setFont(font, 12); contentStream.setNonStrokingColor(0, 0, 0); // 黑色 contentStream.newLineAtOffset(72, 720); // 距离页面左下角向右72磅,向上720磅 contentStream.showText("这是测试文本"); contentStream.endText(); contentStream.close();这段代码的逻辑很简单:beginText开始文本对象,newLineAtOffset定位光标,showText写入文本,endText结束文本对象。需要注意坐标系的原点在页面左下角,x轴向右,y轴向上。所以坐标(72, 720)的意思是距离左边1英寸、距离底边10英寸的位置。
5.2 中文字体是个深坑
如果你直接运行上面的代码,用PDFBox内置的StandardFont,比如PDType1Font.HELVETICA,写中文的时候你会发现页面上的文字全是乱码或者直接就空掉了。这是因为标准14种Type1字体根本不含中文字符的映射,PDFBox也无法为它们编码中文字符。
正确做法是加载系统字体TTF文件:
PDType0Font font = PDType0Font.load(document, new File("/System/Library/Fonts/PingFang.ttc"));在Windows环境下,中文字体路径一般是:
C:\Windows\Fonts\msyh.ttc // 微软雅黑 C:\Windows\Fonts\simsun.ttc // 宋体在Linux服务器上,可以先确认是否有中文字体,没有的话需要安装,或者把字体文件放到项目资源目录下,通过类路径加载:
PDType0Font font = PDType0Font.load(document, PdfBytesEditor.class.getClassLoader().getResourceAsStream("fonts/msyh.ttc"));提示:
.ttc是多字体集合,PDFBox加载的时候默认取第一个字体。如果第一个字体不合适,有些版本支持通过PDType0Font.load的重载方法指定字体索引,但不是所有版本都支持,保守的做法是用单独的中文字体文件比如simhei.ttf、msyh.ttf(纯ttf格式)更省心。
字体选择上也有一点经验:别用宋体画页面标注,宋体的笔画在某些PDF阅读器里细得看不清;微软雅黑和黑体是页脚标注、水印这类内容的好选择,清晰度高、视觉效果好。
5.3 画直线和矩形框
有时候打字还不够,需要在页面上画表格线、分割线或者高亮框。绘制线条:
contentStream.setStrokingColor(0, 0, 0); contentStream.setLineWidth(1f); contentStream.moveTo(50, 500); contentStream.lineTo(545, 500); contentStream.stroke();moveTo划线起点,lineTo划线终点,stroke是真正把线画出来的动作,少一步线都出不来。画矩形框:
contentStream.setStrokingColor(0, 0, 255); contentStream.addRect(50, 500, 200, 100); contentStream.stroke();addRect(x, y, width, height)按照左下角起始坐标、宽高来定位。如果只想要边框,用stroke;如果还要填充底色,用fill或者先fill再stroke。
5.4 给每一页加页脚
这次需求里的“第X页/共Y页”标注,实现起来就靠遍历页面加内容。一个关键细节是:总页数要在遍历之前先取好,不然后面边添加边遍历会出问题:
public byte[] addPageFooters(byte[] srcBytes) throws IOException { try (PDDocument document = PDDocument.load(new ByteArrayInputStream(srcBytes))) { int totalPages = document.getNumberOfPages(); // 加载字体 PDFont font = PDType0Font.load(document, PdfBytesEditor.class.getClassLoader().getResourceAsStream("fonts/msyh.ttf")); for (int i = 0; i < totalPages; i++) { PDPage page = document.getPage(i); try (PDPageContentStream cs = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { float pageWidth = page.getMediaBox().getWidth(); cs.beginText(); cs.setFont(font, 9); cs.setNonStrokingColor(128, 128, 128); cs.newLineAtOffset(pageWidth / 2 - 30, 20); cs.showText("第 " + (i + 1) + " 页 / 共 " + totalPages + " 页"); cs.endText(); } } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); } }看到这里的关键点没?AppendMode.APPEND配合后面两个布尔值true, true,意义分别是“是否压缩内容流”“是否重置上下文”。如果你要在原页面上追加内容,这个重载必须用对。写成new PDPageContentStream(document, page)会直接覆盖原有内容,合同文件被这么一搞就废了。
注意:
totalPages在遍历前取好非常关键,如果循环里用document.getNumberOfPages()做判断,而循环体里又加了新页面,会死循环或者越界。这是新手最容易犯的错。
6. 进阶:内容流绘制中的坐标与状态管理
6.1 理解PDF的坐标系和变换
之前提到坐标系原点是左下角,但实际项目中经常遇到要旋转文本、或者把元素放到页面上特定位置的情况。简单说明一下坐标系变换规律:
- 平移:
contentStream.transform(Matrix.getTranslateInstance(tx, ty)) - 旋转:
contentStream.transform(Matrix.getRotateInstance(angle, x, y)) - 缩放:
contentStream.transform(Matrix.getScaleInstance(sx, sy))
在页脚场景,你会想把文字放在页面底部居中,用pageWidth / 2 - 估算文本宽度 / 2来定位。PDFBox没有直接获取文本宽度的通用API,但可以算,前提是字体已经加载:
float textWidth = font.getStringWidth("第 1 页 / 共 5 页") / 1000f * fontSize;注意getStringWidth返回的是千分之一字号的单位,要除以1000再乘以字号才是实际磅数。这个小计算我调了半天才搞明白。
6.2 内容流的资源管理
PDPageContentStream是持有底层资源句柄的,用完必须关闭。但你在操作过程中如果中间抛异常了,用try-with-resources是最稳的写法:
try (PDPageContentStream cs = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { // 绘制操作 }这个写法简洁明了,也避免了手动关闭时的异常遮蔽问题。如果项目用了更老的Java版本,也需要保证finally里有个cs.close()兜底。
6.3 多次写入内容流的注意事项
实操过程中我经常需要分多次往同一页面写内容。比如先写标题,再画分割线,最后加页脚。这里有一个潜在坑:如果每次都用new PDPageContentStream创建内容流,默认模式都会尝试替代原页面内容流。解决方式就是每次都带上AppendMode.APPEND,或者在一个内容流里完成所有绘制。
我是倾向于后者:把一次完整页面需要的内容一次性在一个内容流里画完,既清晰又不容易出错。只有在内容和逻辑实在分不开的时候,才用追加模式。
7. 实操中遇到的几个典型问题与排查记录
7.1 中文字体加载后仍显示乱码
这个情况我排查过很多次,最后定位到原因基本是两个:
一个是用错了字体文件格式。有些.ttc字体文件里的字体名称不是常规的,PDFBox虽然能加载,但内部无法正确映射字符。我踩过内置字体加载失败的坑之后,就换了项目中自带的一个msyh.ttf,问题迎刃而解。
另一个是字体文件本身损坏或路径不对。PDType0Font.load解析的是字节流内容,如果路径指向的资源不是有效字体,直接抛IOException。用getResourceAsStream之后记得先检查返回是否为null。
7.2 页面内容被清空
这个现象通常就是没用追加模式创建PDPageContentStream。我之前一版代码:
new PDPageContentStream(document, page)结果原页面内容全没了。后来改成追加模式就好了。有一个特别注意的点:追加模式的构造参数里,两个布尔值不能随便写。compress参数表示是否压缩内容流,resetContext表示是否重置图形状态。实测下来两个都传true是标准做法。
7.3 生成的文件体积异常变大
对同一个PDF反复添加页面和内容,文件体积可能会不断膨胀。这是PDF对象累积的结果。页面被多次追加内容流,旧的未引用对象没有垃圾回收。PDFBox提供了document.save时自动清理机制,但不彻底。
如果要压缩体积,可以考虑用PDFMergerUtility合并时优化,或者在保存前手动压缩:
PDDocument addDocument = PDDocument.load(...); // ... 操作 ... addDocument.save(baos); addDocument.close();当然,最简单也最笨的办法是每次从原始字节重新处理,避免对同一文档反复操作。
7.4 高并发场景下的线程安全问题
PDDocument不是线程安全的,多个线程共用同一个PDDocument实例必然出事。我在做批量处理服务时,每个请求都创建一个独立实例,处理完释放内存。要提升吞吐,只能靠多实例并行+资源可控。
实测来看,用线程池并行处理不同PDF文件的效果还是很好的。但注意字体加载这一块是重量级操作,最好做成单例缓存,不然每个线程都加载一遍字体,CPU和内存开销都上去了。我用一个简单的ConcurrentHashMap缓存了字体对象,按字体路径做key,性能提升明显。
8. 需求落地后的效果和性能优化心得
8.1 从字节进去字节出来
最终实现是这个链路:
byte[] → ByteArrayInputStream → PDDocument → 操作页面/内容流 → ByteArrayOutputStream → byte[]后端接口拿到新的字节流之后,直接转成HTTP响应流返回给前端下载,无临时文件、无磁盘残留。这个链路在服务端处理文档的场景里非常通用,无论是上传PDF后自动盖章、加水印,还是模板填充后转出,都适用。
8.2 性能方面的实测感受
我用一份200页的PDF做了测试,源文件约15MB。纯加载 + 每页追加一行页脚文字 + 保存回写,整个流程耗时大概在2秒到3秒之间,内存峰值大约200MB。这个表现在可以接受,但如果你要做上千页的批量处理,就得考虑分批或者限制并发数了。
内存这块还有个小优化思路:PDDocument.load(new ByteArrayInputStream(bytes))会在内存里复制一份数据做随机访问缓冲。如果原始字节数组你确实用不到了,可以传RandomAccessBuffer优化。但大部分场景下这个优化意义不大,保持代码清晰才是第一位的。
8.3 事务性考虑
在服务端处理PDF时要注意,如果处理过程中出了问题,原始字节流要保留住,否则用户重试的时候你手里没原始文件可处理。我的做法是先存一份源文件字节或者对象存储路径,处理成功后再更新状态为“已处理”。这样至少可以保证重试时能拿到原始数据,不至于搞出只处理了一半的文件。
9. 几个容易被忽略的边界情况
9.1 页面数量为零的PDF
测试的时候一定要考虑到空文档的情况。某些系统会生成一个完全没有页面的PDF,这时你对document.getPage(0)调用直接抛IndexOutOfBoundsException。处理方式是提前判断:
if (document.getNumberOfPages() == 0) { // 直接加一页空白页或返回原始字节 }9.2 加密的PDF
只调用PDDocument.load对加密PDF会抛异常,需要先用OpenPassword提供密码。如果你的业务有可能遇到加密PDF,建议在出错时捕获异常并给出明确提示,别让用户看到一串堆栈。
9.3 页面旋转
有些从扫描仪或者手机App生成的PDF,页面内部可能设置了旋转属性(Rotate的值为90、180、270)。你在追加内容时如果没考虑这个旋转,文字坐标会让人觉得“歪了”。处理办法是在内容流中读取页面的旋转值并做坐标换算,或者直接用PageMode相关API辅助定位。
9.4 字符编码与全角半角
写中文文本时还有个小坑:字符串里有全角空格或者特殊符号时,某些字体渲染出来可能跟预期不一样。我在页脚里用了中文标点“/”,在部分字体下显示变窄。后来统一换成英文斜杠感觉反而更协调。这种事情不试根本不知道,做批量处理前建议先拿单页测一下输出效果。
10. 实用总结与扩展建议
用PDFBox对PDF字节流添加空白页、做简易编辑,核心流程比我预想的简单,但细节确实不少。归纳一下,最关键的几点经验是:
- 加载方式:
PDDocument.load(new ByteArrayInputStream(bytes)),用完一定close; - 保存方式:
document.save(ByteArrayOutputStream),然后toByteArray(); - 追加内容:创建
PDPageContentStream务必用AppendMode.APPEND模式,否则原页面内容会被覆盖; - 中文字体:必须加载外部TTF字体,使用内置标准字体写中文会乱码;
- 页面尺寸:为了让新增页面与原文档保持一致,最好用
getMediaBox()取得原页面尺寸再创建新页面。
这个能力不光能用于“添加空白页”和“写页脚”,稍微扩展一下就能做很多事:批量给合同添加水印、在指定位置插入公司LOGO图片、为PDF添加批注信息、把多页PDF拆分后重新组装等等。原理都一样,只是绘制内容的差异罢了。
我后来还把这个工具类封装成了独立的微服务接口,输入输出都是字节流,上游系统完全不用关心PDF内部结构。整套方案跑了大半年,稳定性一直不错。根据我的实测,字节流模式下的大文件处理必须谨慎评估内存,但如果控制住并发量和文件规模,这个方案完全够用,而且它天然适合微服务和云原生的部署环境。
如果你也是在被“字节流 + PDF操作”的组合拳折磨,希望这篇里的经验和代码能帮你少踩几个坑。头几次做的时候别追求一次到位,先写一版能把文字放上去的demo,再逐步加功能,你会发现PDFBox的接口设计其实非常贴心,大部分你想做的事情它都有对应的原生方法。