早些年我第一次接到 Java 生成 PDF 的需求,以为就是找个库、调几个 API、二十分钟搞定。结果光中文乱码就折腾了一晚上,后来在多个项目里反复做报表导出、合同生成、投标文件合并,才把 Java 生成和编辑 PDF 这条路径彻底摸透。说句实话,真正卡住人的从来不是某个 API 不会调,而是选型、字体、内存这三件事心里没底。这篇指南会从零开始,带你走一遍 PDF 文档生成与编辑的完整流程,包括库怎么选、代码怎么写、坑怎么躲,适合正在做电子合同、报表导出、批量文档处理的 Java 工程师参考,也适合需要给系统接入 PDF 能力的团队拿来抄作业。很多人日常处理 PDF 会想到搜狗 PDF 编辑器、PDF 转 Word 这类现成工具,但放到 Java 程序里,我们要的是在代码层面精确控制文档生成和编辑,这就完全是另一回事了。
1. PDF 库选型:iText、PDFBox、OpenPDF 到底怎么挑
1.1 为什么选型比写代码更重要
PDF 这块不像 Excel 生态有 POI 一家独大,Java 里的 PDF 库各有各的脾气,选不对后面全是泪。常见的候选有 iText、Apache PDFBox、OpenPDF,另外还有 Flying Saucer、Apache FOP 这类把 HTML/XML 渲染成 PDF 的“模板派”。在动手之前,你至少得先搞清楚它们的授权模型和擅长边界,否则代码写到一半发现授权有问题,或者某个版面功能根本实现不了,被迫返工的代价是非常大的。我见过不少团队一开始选了功能最全的库,后来因为许可证问题推倒重来,也见过有人用 PDFBox 硬写了三天表格,最后发现 OpenPDF 三行代码就能搞定。
1.2 几个主流库的横向对比
iText 是老牌 PDF 库,功能最全,可以说没有它做不到的版面效果,但授权是最大风险点。iText 5 用的 AGPL 许可证,如果你公司做的是对外提供的 SaaS 服务,AGPL 会强制你开源整个服务端代码,这对绝大多数公司来说完全不可接受。iText 7 已经转向商业授权为主,社区版依然是 AGPL,等于说闭源商用基本得买许可证。不是不能用,而是必须先过法务关。如果你的项目是内部小工具、学习项目,或者公司确实有预算,iText 自然是很好的选择。
Apache PDFBox 是 Apache 2.0 许可,闭源商用毫无压力。它的定位是 PDF 底层操作,读取、写入、表单、合并、拆分、文本提取都能做,但 API 风格非常接近“一门语言直接操作 PDF 对象”,文档结构、内容流、字体这些概念需要你自己懂。想让它画一个自动分页的表格,要么自己写循环,要么引入别的辅助库。PDFBox 的优势在于对存量 PDF 的编辑能力很强,这是很多上层库做不到的。
OpenPDF 是我在内部管理系统里用得最多的一个。它是从 iText 4 分叉出来的开源分支,采用 Mozilla Public License / LGPL 双许可,闭源集成没有太大障碍。API 长得和 iText 5 早期的com.lowagie.text一致,写表格、页眉页脚、书签、水印、段落样式都很顺手。对绝大多数业务场景,OpenPDF 的版面控制能力比 PDFBox 更上层,学习成本也低得多。如果你的目标是从零生成一份漂亮的业务 PDF,OpenPDF 是性价比最高的起点。
我把核心差异整理成了表格,方便直接对照:
| 对比项 | iText 5/7 | Apache PDFBox | OpenPDF | Flying Saucer |
|---|---|---|---|---|
| 开源协议 | AGPL / 商业授权 | Apache 2.0 | MPL / LGPL | LGPL |
| 闭源商用友好度 | 低,需要购买许可 | 高 | 高 | 高 |
| 从零生成复杂版面 | 很强 | 一般,偏底层 | 很强 | 较强,依赖 HTML/CSS |
| 编辑存量 PDF | 较强 | 很强 | 能力有限 | 不建议 |
| 文本提取、表单回填 | 支持 | 支持 | 部分支持 | 不支持 |
| 学习曲线 | 中等 | 陡峭 | 平缓 | 平缓 |
选型结论其实很直接:内部管理系统、单据、合同、报表这类“从零生成”的场景,优先选 OpenPDF;需要大量操作存量 PDF、做文本提取、表单回填、合并拆分,用 PDFBox 更稳;如果公司希望前端改模板、后端只负责渲染,Flying Saucer 把 HTML/CSS 转成 PDF 是最省事的路径。至于 iText,除非公司有预算且有法务做过评估,否则我不建议默认选它。
2. 从零生成第一个中文 PDF:环境、代码与字体根源
2.1 环境准备和依赖引入
Java 8 以上都可以跑,OpenPDF 只需要加入一个 Maven 依赖。我这里用的是 1.3.30,目前开源版维护还算稳定,小版本号更新也不频繁,锁死版本问题不大。
<dependency> <groupId>com.github.librepdf</groupId> <artifactId>openpdf</artifactId> <version>1.3.30</version> </dependency>这里有个容易被坑的点:OpenPDF 的包名是com.lowagie.text.*,原因是它继承自 iText 4 时代的 Lowagie 公司命名。第一次从 iText 5 转过来的人经常找不到类,别慌,认准com.lowagie这个前缀就行。很多报错“ClassNotFound”的人,都是把 import 写成了com.itextpdf导致的,这类问题排查起来很浪费时间。
2.2 最小可运行的中文 PDF 生成代码
有了依赖之后,生成一份带中文标题和正文的 PDF,代码量其实很少。下面这段就是一个可以直接跑通的最小示例:
import com.lowagie.text.Document; import com.lowagie.text.Font; import com.lowagie.text.Paragraph; import com.lowagie.text.pdf.BaseFont; import com.lowagie.text.pdf.PdfWriter; import java.io.FileOutputStream; public class QuickStart { public static void main(String[] args) throws Exception { // 使用内置中文 CID 字体,后面会专门讲字体方案 BaseFont bfChinese = BaseFont.createFont( "STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED); Font titleFont = new Font(bfChinese, 18, Font.BOLD); Font contentFont = new Font(bfChinese, 12, Font.NORMAL); Document document = new Document(); PdfWriter.getInstance(document, new FileOutputStream("demo.pdf")); document.open(); document.addTitle("PDF生成示例"); document.addAuthor("demo"); Paragraph title = new Paragraph("Java PDF 生成示例", titleFont); title.setAlignment(Paragraph.ALIGN_CENTER); document.add(title); document.add(new Paragraph("欢迎来到 PDF 生成与编辑的实战指南。", contentFont)); document.close(); } }这段代码里,Document是排版层,PdfWriter负责把内容输出到文件流,两者通过getInstance绑定。之后open()、add()、close()就是一个完整的写 PDF 流程。这里要提醒一点:document.close()必须放在最后,它是触发 PDF 文件收尾、写入xref交叉引用表的关键步骤,漏掉的话生成的 PDF 在阅读器里会提示文件损坏。
2.3 中文乱码的根因,以及三种解决路径
中文乱码是 Java 生成 PDF 遇到最多的问题。根因很简单:PDF 标准字体只有 Helvetica、Times、Courier 等西文字体,不包含中文字形。想要中文,必须要么嵌入中文字体文件,要么用系统内置的 CID 字体映射表。很多人直接new Font(Font.HELVETICA, 12, Font.NORMAL)然后往里塞中文,打出来全是乱码或者空白,就是因为 PDF 里根本没有对应的字形信息。
我梳理了三种主流的中文处理方案:
方案 A:用 OpenPDF/iText 内置的亚洲 CID 字体。代码里用的STSong-Light搭配UniGB-UCS2-H就是这种方案。优点是不需要任何字体文件,跨平台、体积小,适合内部系统快速出件。缺点是字体没有嵌入 PDF,阅读端必须能正确解析 CID 映射,某些老旧的阅读器或打印服务商可能会显示异常。
方案 B:嵌入自定义字体文件。Windows 下可以指定C:/Windows/Fonts/simsun.ttc,Linux 下指定/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc。配合BaseFont.IDENTITY_H和BaseFont.EMBEDDED使用,这样 PDF 里直接带上字体字形,发给外部客户最稳妥。缺点是文件体积会明显变大,一个中文字体动辄几 MB 到十几 MB。
BaseFont bf = BaseFont.createFont( "C:/Windows/Fonts/simsun.ttc,0", BaseFont.IDENTITY_H, BaseFont.EMBEDDED);注意simsun.ttc后面有个,0,这是 TTC 字体集合的索引,表示使用集合里的第一个字体。漏掉这个参数或者写错索引,运行时经常报字体无法解析。
方案 C:如果用 PDFBox 做生成侧,中文字体是通过PDType0Font.load加载字体文件来实现的,没有内置 CID 字体这条路。代码长这样:
PDDocument doc = new PDDocument(); PDType0Font font = PDType0Font.load(doc, new FileInputStream("NotoSansCJK-Regular.ttc")); PDPageContentStream cs = new PDPageContentStream(doc, new PDPage()); cs.beginText(); cs.setFont(font, 14); cs.showText("中文内容"); cs.endText();如果你只是在“生成 PDF”这一步,我建议优先用方案 B,也就是显式嵌入字体。虽然文件大一点,但避免了很多交付环节的隐性风险。内部系统追求体积小、速度快,再考虑方案 A。
2.4 段落、字体样式与页面边距的微调
生成 PDF 有一个很容易忽略的细节:Document构造函数的四个参数是左右上下边距,单位是 pt,而不是像素。默认的new Document()边距是 36pt,也就是约 1.27 厘米。做企业合同时,我习惯把左边距调大一点,方便装订,比如:new Document(PageSize.A4, 54, 36, 54, 54),这样左、上、下留出更多空间,整体版面不会显得拥挤。
段落级别的控制用Paragraph就能做。行距可以靠setLeading,段前段后距离用setSpacingBefore和setSpacingAfter。字号、粗体、斜体、颜色都在Font里设置:
Font redBoldFont = new Font(bfChinese, 14, Font.BOLD); redBoldFont.setColor(new Color(192, 0, 0));这里有个实用技巧:业务文档里经常出现中英文混排,英文字母如果用中文字体渲染,视觉上会偏松散。遇到这种需求,可以拆成多个Chunk,中文用一个字体,英文数字用另一个字体,再用Paragraph把它们拼起来,这样既保证了中文字形不出问题,又让英文显示更紧凑。
3. 让 PDF 更像“正式文档”:表格、图片、页眉页脚与书签
3.1 表格是业务文档的命根子
做过合同、报价单、订单的人都知道,PDF 里最不能缺的就是表格。OpenPDF 的表格组件叫PdfPTable,使用逻辑和 HTML 表格很像,但细节更多。下面这一段是我常用的写法:
PdfPTable table = new PdfPTable(3); table.setWidthPercentage(100); table.setWidths(new float[]{1, 2, 2}); PdfPCell cell = new PdfPCell(new Phrase("项目", contentFont)); cell.setHorizontalAlignment(Element.ALIGN_CENTER); cell.setPadding(8); cell.setBackgroundColor(new Color(220, 220, 220)); table.addCell(cell); table.addCell(new PdfPCell(new Phrase("数量", contentFont))); table.addCell(new PdfPCell(new Phrase("备注", contentFont))); for (int i = 0; i < 5; i++) { table.addCell(new PdfPCell(new Phrase("商品" + i, contentFont))); table.addCell(new PdfPCell(new Phrase("1", contentFont))); table.addCell(new PdfPCell(new Phrase("", contentFont))); } document.add(table);setWidths接收的比例数组用来控制三列宽度的相对比例,这一行不设置的话,表格会按默认均分列宽,在真实业务里经常出现“内容写不下的列太宽、内容很长的列太窄”。PdfPCell里的setHorizontalAlignment、setVerticalAlignment、setPadding都是高频使用的样式接口。跨列用setColspan,跨行用setRowspan,用法和 HTML 一致。
表格跨页是另一个高频需求。只要在填完表头后调用table.setHeaderRows(1),后续分页时表头会自动重复,这个细节很多教程都没提,但实际做长报表时非常关键,否则第二页开始读者根本不知道每列是什么含义。
3.2 图片插入与缩放
业务文档里免不了放 Logo、商品图、签名图片。OpenPDF 用Image.getInstance加载图片,支持文件路径、URL 和字节数组三种方式。从数据库读取图片二进制后,直接传byte[]是最省事的:
Image logo = Image.getInstance(byteArray); logo.scaleToFit(120, 60); logo.setAlignment(Image.ALIGN_CENTER); document.add(logo);scaleToFit是等比缩放,比如120 * 60表示图片最大宽 120pt、高 60pt,不会因为只设置一个宽度而变形。这里要提醒一个常见问题:有些高清 PNG 图片原尺寸非常大,直接document.add不加缩放,会超出页面边界,PDF 查看器里看着就像图片“丢了”。凡是广告公司丢过来的大图,务必先调用scaleToFit再放入文档。
3.3 页眉页脚与页码:用事件回调而不是手动数页
给 PDF 加页眉页脚,最优雅的方式是继承PdfPageEventHelper。为什么要用事件?因为 PDF 的排版是流式进行的,你添加内容时并不知道一页多长、什么时候分页,只有底层页面事件能准确告诉你“新一页开始了、当前页结束了”。手动记录页数判断在哪添加,在动态内容场景下几乎必出错。
class FooterEvent extends PdfPageEventHelper { private BaseFont bf; public FooterEvent(BaseFont bf) { this.bf = bf; } @Override public void onEndPage(PdfWriter writer, Document document) { PdfContentByte canvas = writer.getDirectContent(); canvas.beginText(); canvas.setFontAndSize(bf, 10); canvas.setTextMatrix(document.right() - 100, document.bottom() - 20); canvas.showText("第 " + writer.getPageNumber() + " 页"); canvas.endText(); } } PdfWriter writer = PdfWriter.getInstance(document, out); writer.setPageEvent(new FooterEvent(bfChinese));setPageEvent要在document.open()之前调用,事件才会覆盖整个文档生命周期。上面这段是基于writer.getPageNumber()来实现页码的,OpenPDF 会自动维护页码,不需要自己统计。页眉的写法同理,把onEndPage改成onStartPage,坐标放在document.top()附近即可。
3.4 书签与文档元数据
长文档加书签,体验会好很多。OpenPDF 里用PdfOutline可以方便地挂载目录书签:
PdfOutline root = writer.getRootOutline(); PdfOutline chapter1 = new PdfOutline(root, new PdfDestination(PdfDestination.FIT), "第一章 概述"); addToBody(chapter1);PdfDestination.FIT表示点击书签时页面适配窗口显示。如果是复杂文档,可以做成两级书签,根书签下挂子书签,阅读器左侧的目录就能像一本书一样展开。
文档属性也不要漏掉。document.setTitle、document.setAuthor、document.setSubject这些元数据在文件管理器里能看到,很多电子合同系统会拿这些字段做检索和归档,生成时顺手写上,后续处理会省很多事。
4. 编辑存量 PDF:合并拆分、文本提取、表单回填与水印
到了这一节,我要把主角换成 PDFBox。原因很简单:OpenPDF 的强项是从零生成版式,对“读入现有 PDF 再修改”这一块支持不是核心;而 PDFBox 天生就是读写并重的库,Apache 2.0 协议也让我放心在商业项目里用它做混合场景。实际项目里最常见的组合就是 OpenPDF 生成、PDFBox 处理存量文件。
4.1 合并多个 PDF 文件
合并 PDF 用PDFMergerUtility,代码非常少:
PDFMergerUtility merger = new PDFMergerUtility(); merger.addSource(new File("part1.pdf")); merger.addSource(new File("part2.pdf")); merger.setDestinationFileName("merged.pdf"); merger.mergeDocuments(null);注意addSource的顺序就是合并后的顺序,这个很好理解。但有一个细节:mergeDocuments(null)里的参数表示内存使用设置,传null时使用默认内存策略。如果待合并的文件特别大,可以传MemoryUsageSetting.setupTempFileOnly(),让临时数据写到磁盘而不是堆内存,后面讲性能时还会展开。
4.2 按页拆分 PDF
拆分可以用PDSplitter,比如把一份 10 页的 PDF 拆成前 5 页和后 5 页:
PDDocument doc = PDDocument.load(new File("original.pdf")); PDSplitter splitter = new PDSplitter(); splitter.setStartPage(1); splitter.setEndPage(5); List<PDDocument> splitDocs = splitter.split(doc); for (int i = 0; i < splitDocs.size(); i++) { splitDocs.get(i).save("split-" + (i + 1) + ".pdf"); splitDocs.get(i).close(); } doc.close();split返回的是一个PDDocument列表,每个对象对应一部分页面,需要单独保存和关闭。用 PDFBox 2.0 和 3.0 的写法略有差异,但核心逻辑一致;如果你用的是 3.0,加载文件可以换成Loader.loadPDF(file),其余代码几乎不用动。
4.3 提取文本与 OCR 的边界
提取文本是 PDFBox 的看家本领,给定一个PDDocument,直接交给PDFTextStripper就行:
PDFTextStripper stripper = new PDFTextStripper(); stripper.setStartPage(1); stripper.setEndPage(3); String content = stripper.getText(doc);这里要泼一盆冷水:PDFTextStripper提取的是 PDF 文本层里的字符,也就是从 Word、LaTeX、Flying Saucer 这类工具生成的“数字原生 PDF”里的文字。如果拿到的 PDF 是扫描件、图片型 PDF,文本层是空的,提取结果就是空白。扫描件要提取文字,必须走 OCR 路线,比如配合 Tesseract 做图像识别,这是另一套技术栈,别指望 PDFBox 能直接搞定。
4.4 表单字段回填与扁平化
PDF 表单是个很常见的需求,税务发票、标书报名表都是典型的 AcroForm。PDFBox 回填表单字段非常直接:
PDDocument doc = PDDocument.load(new File("form.pdf")); PDAcroForm form = doc.getDocumentCatalog().getAcroForm(); PDTextField field = (PDTextField) form.getField("name"); field.setValue("张三"); doc.save("form_filled.pdf"); doc.close();这里的关键是“扁平化”。如果不做扁平化,保存后的 PDF 字段仍然是可编辑状态,用户用 PDF 编辑器还能随意改掉内容,这对需要固定信息的合同或回执单来说是不可接受的。调用form.flatten()之后,字段会被转换成静态文本和图形,PDF 编辑器里再也点不开输入框,最终交付给客户的应该都是扁平化之后的版本。
4.5 给 PDF 加文字水印
水印是合同、招标文件里常见的需求。PDFBox 里用PDPageContentStream在页面内容上追加一段文字,关键是设置透明度:
PDPage page = doc.getPage(0); PDPageContentStream cs = new PDPageContentStream( doc, page, AppendMode.APPEND, true, true); cs.beginText(); cs.setFont(PDType1Font.HELVETICA_BOLD, 48); cs.setNonStrokingColor(200, 200, 200); PDExtendedGraphicsState gs = new PDExtendedGraphicsState(); gs.setNonStrokingAlphaConstant(0.3f); cs.setGraphicsStateParameters(gs); cs.newLineAtOffset(150, 400); cs.showText("内部资料"); cs.endText(); cs.close();AppendMode.APPEND是关键,它表示“追加”而不是“覆盖”。很多人第一次加水印时用默认模式,结果把原有内容全清掉了,这就是没有理解PDPageContentStream的构造参数含义。至于颜色,setNonStrokingColor设置的是文字填充色,浅灰色加透明度是比较常见的“不影响阅读”的水印方案。
5. 内存、性能与稳定性:大文件处理时最容易忽略的几个点
5.1 首当其冲的 OutOfMemoryError
操作大 PDF 的人,几乎都会遇到java.lang.OutOfMemoryError: insufficient memory这类异常。原因往往是 PDFBox 把整份文档的页面内容和资源全部加载进了 JVM 堆内存。处理一个 500MB 的 PDF,如果-Xmx只给了 1GB,分分钟堆溢出。
解决思路有三个层次。第一,粗暴但有效的办法:把堆内存调大,比如-Xmx2g或-Xmx4g,这是最直接的兜底方案。第二,使用 PDFBox 3.x 的流式读取能力,通过MemoryUsageSetting.setupTempFileOnly()让临时数据写到磁盘,只把当前需要处理的页目录等元数据放在内存里。第三,合并大文件时不要一次性把所有PDDocument都留活,边合并边释放。如果你遇到 OOM,先检查内存策略,再检查代码逻辑,不要一上来就怀疑是 PDF 文件本身坏了。
5.2 生成侧的流式特性与字体复用
OpenPDF 生成 PDF 时是顺序写文件的,只要你在add内容时让底层流持续刷写,内存占用其实非常低。但很多人会在循环里反复创建Font和Image,每次创建都要重新解析字体文件或图片字节,这种写法既慢又吃内存。
正确的做法是把常用字体和图片缓存成静态常量或单例。字体解析一次可能只需要几十毫秒,但放到一个导出几千行的报表循环里,反复解析就会变成肉眼可见的性能瓶颈。我习惯在工具类里维护一个Map<String, Font>,同一个字体 key 只创建一次,后续直接复用。
private static final Map<String, Font> FONT_CACHE = new ConcurrentHashMap<>(); public static Font getFont(String fontName, float size, int style) { String key = fontName + "-" + size + "-" + style; return FONT_CACHE.computeIfAbsent(key, k -> new Font(baseFont, size, style)); }5.3 线程安全与并发导出
Document实例不是线程安全的,但每个线程可以各自new Document(),所以并发导出 PDF 本身没有太大问题。要注意的是复用同一个BaseFont或多个线程共享同一个PdfWriter的行为,这样会出现文档交错、页面缺失、文件损坏等诡异问题。
如果你的服务器要同时处理大量 PDF 导出请求,我建议采用“每请求一个 Document + 每请求一个独立的输出流”的模型。看起来每个请求都new一次比较浪费,但 PDF 写入的瓶颈通常在 IO 和字体加载,而不是对象创建,真正的复用点在字体和模板上,这个思路和数据库连接池不一样,别搞混。
5.4 资源释放:try-with-resources 是底线
无论 OpenPDF 还是 PDFBox,都必须在 finally 里关闭底层资源。PDFBox 的PDDocument.close()还会负责清理临时文件,如果频繁打开不关闭,Linux 服务器的临时目录会积累大量垃圾文件,Windows 上则可能直接报文件被占用、无法覆盖。用 try-with-resources 是最稳妥的写法:
try (PDDocument doc = PDDocument.load(new File("input.pdf"))) { // 处理逻辑 } // 自动关闭5.5 一个真实的性能案例
我处理过一份约 100MB 的 PDF 合并任务,里面包含大量扫描图片页。最初我用 PDFBox 2.x 默认方式加载所有文件到内存,堆内存开到 2GB 依然报 OOM。换成 PDFBox 3.x 的流式内存策略后,把大部分临时数据写到磁盘,内存在 300MB 左右就完成了合并。这个案例说明:遇到大文件,先怀疑内存策略,再怀疑代码逻辑。如果你的项目会长期处理超大型 PDF,建议尽早把 PDFBox 版本升到 3.x,并熟悉它的内存管理参数。
最后分享一点实操体会
我在多个项目里验证过一套“便宜又省心”的组合:内部管理系统用 OpenPDF 做生成、PDFBox 做编辑,两条路线各管各的事,基本能覆盖九成以上业务需求。如果你们要生成的 PDF 版面经常改、前端同事已经写好了 HTML,那就直接走 HTML 转 PDF 的路线,比在 Java 代码里逐行调坐标高效得多。每次动工前,先把中文字体环境验证三分钟,再把大文件的内存策略想清楚,这两件事做好了,比上线后救火强一百倍。