
早些年我第一次接到 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 更上层学习成本也低得多。如果你的目标是从零生成一份漂亮的业务 PDFOpenPDF 是性价比最高的起点。我把核心差异整理成了表格方便直接对照对比项iText 5/7Apache PDFBoxOpenPDFFlying Saucer开源协议AGPL / 商业授权Apache 2.0MPL / LGPLLGPL闭源商用友好度低需要购买许可高高高从零生成复杂版面很强一般偏底层很强较强依赖 HTML/CSS编辑存量 PDF较强很强能力有限不建议文本提取、表单回填支持支持部分支持不支持学习曲线中等陡峭平缓平缓选型结论其实很直接内部管理系统、单据、合同、报表这类“从零生成”的场景优先选 OpenPDF需要大量操作存量 PDF、做文本提取、表单回填、合并拆分用 PDFBox 更稳如果公司希望前端改模板、后端只负责渲染Flying Saucer 把 HTML/CSS 转成 PDF 是最省事的路径。至于 iText除非公司有预算且有法务做过评估否则我不建议默认选它。2. 从零生成第一个中文 PDF环境、代码与字体根源2.1 环境准备和依赖引入Java 8 以上都可以跑OpenPDF 只需要加入一个 Maven 依赖。我这里用的是 1.3.30目前开源版维护还算稳定小版本号更新也不频繁锁死版本问题不大。dependency groupIdcom.github.librepdf/groupId artifactIdopenpdf/artifactId version1.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.ttcLinux 下指定/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); ListPDDocument 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每次创建都要重新解析字体文件或图片字节这种写法既慢又吃内存。正确的做法是把常用字体和图片缓存成静态常量或单例。字体解析一次可能只需要几十毫秒但放到一个导出几千行的报表循环里反复解析就会变成肉眼可见的性能瓶颈。我习惯在工具类里维护一个MapString, Font同一个字体 key 只创建一次后续直接复用。private static final MapString, 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 代码里逐行调坐标高效得多。每次动工前先把中文字体环境验证三分钟再把大文件的内存策略想清楚这两件事做好了比上线后救火强一百倍。