在 Spring Boot 项目里做过 PDF 导出的人,应该都体会过那种“想砸电脑”的感觉:一个简单的报表,用 iText 要注册字体、算坐标、调表格宽度,动不动写几百行;用 PDFBox 更夸张,连中文字符换行都要自己算。我这次重构项目的导出模块时换成了 x-easypdf,核心目标只有一个——5 分钟搞定 PDF 生成与中文排版。这篇文章是我从依赖引入、中文排版、表格图片到生产环境踩坑的完整笔记,适合在后端 Java 项目里处理报表、订单、凭证导出的同学直接抄作业。x-easypdf 的定位是 PDFBox 的增强封装,内置了黑体、宋体、楷体等常用中文字体,API 走链式调用,用起来比 iText 顺滑不少。
1. 选择 x-easypdf 之前,我对比过的那些 PDF 方案
1.1 iText、PDFBox、OpenPDF 的普遍痛点
先说 iText。这应该是 Java 生态里知名度最高的 PDF 库,功能确实全,从文本到表格、签名、表单、水印样样都有。但它有两个天然的坎:第一是授权,iText 7 用的是 AGPL 协议,如果你的项目不是开源的,或者不想把代码开源,就得考虑商业授权,这在很多公司里会直接触发法务审查;第二是中文处理,iText 要正常显示中文必须自己注册字体文件,而且不同字体对应的编码规则还不一样,一个宋体一个黑体,写出来的注册代码能绕一大圈。
再看 Apache PDFBox。它是 Apache 基金会底下的项目,功能集中在 PDF 的底层读写,没有授权风险,API 也算干净。但它的问题在于“太底层”了。你要画一段文字,得自己指定字体对象、计算字符串宽度、判断要不要换行、换行之后 y 坐标减多少。表格更不用说了,本质上是画一堆矩形、画若干条线,然后往格子里塞文字,位置还得手工对齐。做一页简单的 A4 报表,光坐标换算就能写到怀疑人生。
OpenPDF 是 iText 4 的社区分支,比 PDFBox 友好一些,但也只是“友好一点”,中文字体照样要自己去搞。Apache FOP 适合 XML 到 PDF 的文档生成,走的是 XSL-FO 那套规范,学习成本偏高,用在业务报表上杀鸡用牛刀。
我并不是说这些方案不行,而是当业务场景是“尽快把数据库里的数据变成一页页能看的中文 PDF”,它们给到的都是零件,不是工具。你需要的是开箱即用的文字、表格、图片、自动换行,而不是每次都重新拼装一遍。
1.2 x-easypdf 的两个核心杀手锏:内置字体与链式 API
x-easypdf 吸引我的点是它把 PDFBox 那些底层细节封装成了“看起来像写配置”的 API。两个杀手锏最关键。
第一是内置中文字体。它自带了一组FontFamily枚举,包括黑体、宋体、楷体、仿宋等常用字体,不需要你准备任何字体文件,也不需要在服务器上安装中文字体。这一点解决了最大的痛点——很多项目在 Windows 上跑得好好的,部署到 Linux 就变乱码或方框,就是因为服务器缺字体。用 x-easypdf 的内置字体,这套问题直接绕过去了。
第二是链式 API。一个文本对象从创建到确定位置、字体、大小、颜色、对齐方式,全都在一行链式调用里完成,代码整洁程度比 iText 高一个档次。后面生成表格、插入图片也都是类似风格。对于团队协作来说,这种代码 review 起来也轻松,因为你几乎一眼就能看出这段 PDF 在干嘛。
2. 项目落地:依赖、首份 PDF 与 Spring Boot 接口
2.1 依赖坐标与版本选择
Maven 依赖很简单。以我目前使用的 2.x 版本为例:
<dependency> <groupId>cn.idev.excel</groupId> <artifactId>easypdf-pdfbox</artifactId> <version>2.3.1</version> </dependency>提醒:版本号以 Maven 中央仓库当前最新稳定版为准,建议打开仓库页面确认一下再粘。
如果你的需求不止文本和表格,还想直接把 HTML 转成 PDF,那么再引入一个模块:
<dependency> <groupId>cn.idev.excel</groupId> <artifactId>easypdf-openhtmltopdf</artifactId> <version>2.3.1</version> </dependency>这里要特别说明的是,x-easypdf 1.x 和 2.x 的坐标、API 差异非常大。网上搜到的老文章大部分是 1.x 的用法,类名和 2.x 对不上。我在项目中直接用 2.x,后面贴的代码也都是 2.x 的写法。如果你之前看过 1.x 教程,请直接忘掉它。
2.2 十行代码生成第一份带中文的 PDF
先跑一个最小例子,验证整个环境通不通。生成一个 A4 页面,上面写一句中文:
import cn.idev.excel.pdfbox.core.PdfDocument; import cn.idev.excel.pdfbox.core.PdfPage; import cn.idev.excel.pdfbox.layout.text.PdfText; import cn.idev.excel.enums.FontFamily; public class QuickStart { public static void main(String[] args) throws Exception { PdfDocument document = new PdfDocument(); PdfPage page = document.createNewPage(); PdfText text = new PdfText(page); text.setText("你好,Spring Boot + x-easypdf") .setFontFamily(FontFamily.HEI_TI) .setFontSize(24) .setPosition(50, 700); document.save("hello.pdf"); document.close(); } }这段代码里最关键的一行是setFontFamily(FontFamily.HEI_TI)。如果你把它去掉,中文大概率会变成一个一个的方框。原因后面单独讲字体时细说,这里先记住:我们要做中文 PDF,字体族一定要显式设置。
setPosition(x, y)的坐标系要留心。PDF 页面的坐标原点在左下角,x 向右递增,y 向上递增,这和我们在网页里习惯的“左上角为原点”完全不同。A4 页面高度约 842 点,所以 y 设成 700 表示“页面底部往上 700 点”的位置,也就是靠近页面顶部。
2.3 用 Controller 直接输出 PDF 下载流
项目里不能只在 main 方法里跑,要接到 Spring Boot 接口上。方式也很直接:生成 PDF 后把输出流交给HttpServletResponse。
@RestController public class PdfController { @GetMapping("/report") public void generateReport(HttpServletResponse response) throws IOException { response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=report.pdf"); PdfDocument document = new PdfDocument(); try { PdfPage page = document.createNewPage(); PdfText text = new PdfText(page); text.setText("月度销售报表") .setFontFamily(FontFamily.HEI_TI) .setFontSize(20) .setPosition(50, 700); // 这里继续填充业务数据... document.save(response.getOutputStream()); } finally { document.close(); } } }注意两点:
Content-Disposition里的filename如果是中文,需要做 URL 编码,否则部分浏览器下载文件名会乱码。- 不要自己
response.getOutputStream().close()。Servlet 容器会管理 response 的流,你 close 之后容器再写东西就可能报错。x-easypdf 的save(OutputStream)会完成需要的数据写入,之后交给容器处理即可。
这个接口其实就是很多公司给第三方对接的那个“PDF 导出接口”:调用方拿到流直接保存成文件,或者嵌入浏览器预览。后端不需要在服务器上生成临时文件,天然适合无状态部署。
3. 中文排版实操:从“能显示”到“排得漂亮”
3.1 为什么中文字体是 PDF 生成的第一道坎
PDF 本身是一套复杂的文档规范。文字显示依赖字体,字体里包含字形数据。PDFBox 默认用的是 Helvetica 这类西方字体,它们一共就几百个字形,根本没有汉字。当你把一个中文字符丢进去,渲染器找不到对应字形,只能画一个空框或占位符。这个问题和你用什么 IDE、什么操作系统没关系,纯粹是字体数据不支持。
iText 的解决方法是让你把系统的中文字体文件(比如微软雅黑、思源黑体)加载进来,注册成 PDF 文档的字体资源。这个方案可行,但带来了部署问题:Windows 上字体文件和 Linux 上不一样,一旦换了运行环境,字体路径可能失效。有些项目图省事,把字体文件塞进resources,这倒是解决了路径问题,但字体文件体积动不动 10MB 起步,还不一定能过公司资产审查。
x-easypdf 的做法是直接把常用中文字体打包进库里,用FontFamily.HEI_TI、FontFamily.SONG_TI、FontFamily.KAI_TI这类枚举来引用。库在初始化时会把这些内置字体文件注册到 PDF 文档中,应用代码完全不用关心字体文件在哪。这属于典型的“用封装换省心”。
3.2 文本自动换行、对齐与行距控制
生成 PDF 最容易写崩的地方之一就是长文本。数据库里存了一个客户备注,长度 200 字,你如果直接setText画到页面上,它会往一个方向一直延伸出去,最后被页面边缘裁掉。x-easypdf 提供了自动换行开关:
text.setText("客户备注内容可能很长,请注意使用自动换行……") .setFontFamily(FontFamily.SONG_TI) .setFontSize(12) .setAutoWrap(true) .setWidth(400) .setPosition(50, 600);开了setAutoWrap(true)后,文本会按setWidth(400)给定的宽度自动折行。这里宽度单位是 PDF 的点(point),和字体大小同一个度量体系。一般来说 1pt 约等于 1/72 英寸,A4 的可用宽度大约 500 多点,你设置 400 表示占据中间大部分区域。
行距用setLeading控制:
text.setLeading(20f);默认行距跟字体大小有关,但中文阅读习惯里行距通常要略大一些,否则两行字会显得贴在一起。我个人的经验是:字号 12 的话,行距 20 比较舒服;字号 10 的话,行距 16 左右。
对齐方式也是一行代码的事:
text.setHorizontalStyle(HorizontalStyle.CENTER); text.setVerticalStyle(VerticalStyle.MIDDLE);这个在表格单元格里特别有用,因为单元格内容垂直居中、水平居中基本是报表刚需。注意这里的“垂直”对齐是相对于文本所在区域的上下方向,不是字与字之间的竖直排列,别理解反了。
3.3 颜色、边框、背景这些细节怎么设
报表讲究可读性,颜色能快速区分表头和数据行。x-easypdf 的文本对象支持常见的链式样式配置:
text.setColor(Color.RED) .setBackgroundColor(Color.LIGHT_GRAY) .setBorderWidth(1f) .setBorderColor(Color.GRAY);setColor是文字颜色,setBackgroundColor是文本块背景色,这两个是最常用的。边框相关的方法多用在表格场景里。
如果你要在一个页面里摆多个文本块,比如标题一个区域、正文一个区域、落款一个区域,可以创建多个PdfText对象,分别设置位置。位置坐标需要自己算,但这种“手摆”的方式其实可控性更强。我习惯在代码里定义一个常量数组维护所有区块的 y 坐标,这样页面布局一目了然:
private static final float TITLE_Y = 740; private static final float SECTION_HEADER_Y = 680; private static final float CONTENT_Y = 620;改版式的时候只需要动这几个常量,不需要逐行找魔法数字。
页面本身也可以定制。PdfPage默认是 A4 纵向,你可以调整宽高:
PdfPage page = document.createNewPage(); page.setWidth(595f); page.setHeight(420f);这种半页大小的 PDF 在一些单据打印场景里很实用,比如快递面单、小票。
4. 真实业务场景:报表里的表格、图片与批量生成
4.1 PdfTable 快速铺一个结构化报表
业务报表里大量使用表格,x-easypdf 的表格 API 设计得比较顺手。先建一个表格,设置总宽度和边框样式:
PdfTable table = new PdfTable(page); table.setWidth(480f) .setBorderWidth(1f) .setHorizontalStyle(HorizontalStyle.CENTER) .setVerticalStyle(VerticalStyle.MIDDLE);然后逐行创建单元格:
PdfTable.Row headerRow = table.createRow(); headerRow.createCell().setText("订单编号"); headerRow.createCell().setText("客户名称"); headerRow.createCell().setText("金额"); PdfTable.Row dataRow = table.createRow(); dataRow.createCell().setText("SO20250101"); dataRow.createCell().setText("示例客户"); dataRow.createCell().setText("1288.00");表格创建完成之后,还需要把表格渲染到某个位置。x-easypdf 一般采用table.write()或者设置表格的位置后自动渲染。具体调用方式在 2.x 小版本之间略有差异,但整体思路是一致的:先定义行和单元格,再渲染到页面。
这里给两个建议:
- 表头和数据的字体大小、颜色可以分开设置,让表格层次更清楚。表头字体加粗或用深色背景,数据行默认即可。
- 如果表格行数很多、会超过一页,x-easypdf 支持分页渲染,但需要你在页面循环里处理断页逻辑。我的做法是预估每行高度,在代码里算出“这一页还能放几行”,超过就
createNewPage()再建一张表。虽然土,但可控。
4.2 插入 Logo 图片:位置与尺寸控制
报告类 PDF 基本都要带公司 Logo 或产品图。x-easypdf 的图片对象用法和文本类似:
PdfImage image = new PdfImage(page); image.setImage("logos/company-logo.png") .setPosition(50, 750) .setWidth(100) .setHeight(40) .write();图片路径支持文件路径、URL,也支持直接传字节数组。如果你的图片放在 classpath 下,用getResourceAsStream读出字节数组再传进去更保险,避免路径分隔符在不同操作系统上的差异:
byte[] logoBytes = getClass().getClassLoader() .getResourceAsStream("logos/company-logo.png") .readAllBytes(); PdfImage image = new PdfImage(page); image.setImage(logoBytes) .setPosition(50, 750) .setWidth(100) .setHeight(40) .write();缩放图片时有个细节:如果只设置setWidth,不设置setHeight,库会自动按原图比例计算高度,这样不会变形。两个都设置反而可能拉伸。我建议宽高只定一个,除非你明确知道图片的原始宽高比。
4.3 批量导出的线程安全与性能建议
真实项目里很少有“导一份 PDF”的需求,更多的是“把这个月的 3 万条订单导成 PDF 报表”。批量场景要注意两个问题:线程安全和内存。
PdfDocument不是线程安全的,至少我没有在文档里看到它可以跨线程共享的说明。正确做法是每个请求线程创建自己的PdfDocument,用完即关。Spring Boot 默认的 Controller 就是每请求一线程,天然满足这个要求。
内存方面,如果一次循环生成几百页,中间不释放任何对象,JVM 堆会很快飙升。我的做法是每生成一个PdfDocument就立即save和close,不要在内存里积攒。如果是异步导出大文件,先生成到临时文件,再让用户下载,比同步占着 HTTP 连接等半小时合理得多:
@Async public CompletableFuture<String> generateAsyncReport(Long reportId) { String filePath = tempDir + "/report_" + reportId + ".pdf"; PdfDocument document = new PdfDocument(); try { // 生成内容... document.save(filePath); return CompletableFuture.completedFuture(filePath); } finally { document.close(); } }异步接口在做后台管理系统时几乎是标配:用户点击导出,后端先返回一个任务 ID,前端轮询任务状态,完成后再展示下载链接。这样既不阻塞线程,也不会让浏览器等着超时。Spring Boot 里启用@Async只需要在主类加一个@EnableAsync,配一个线程池即可。
5. 生产环境踩坑记录:字体、编码与资源关闭
5.1 Linux 服务器上字体变了样的根因
我在第一个项目里吃过这个亏,症状是这样的:本地 Windows 开发,用 x-easypdf 生成 PDF 一切正常,发布到 Linux 测试环境后,同样代码生成的 PDF,中文全部变成了方框。
排查过程不复杂。先怀疑是不是库版本问题,后来逐步确认是“自己注册字体”导致的。我当时贪心,没有用内置FontFamily,而是像 iText 一样痴迷于“用系统微软雅黑字体”,在代码里加载了 Windows 的字体文件。到了 Linux,那个字体文件根本不存在,字体加载失败后回退到默认字体,中文自然全部丢失。
改用FontFamily.HEI_TI后问题立刻消失。所以我的建议很直接:优先用内置字体,别自己注册系统字体。内置字体的字重和样式可能没有你老板指定的“思源黑体 Semibold”那么精确,但报表场景下,干净、稳定比字体重更重要。
5.2 中文乱码与文件编码问题的排查思路
有时候不是字体问题,而是编码问题。典型的场景是:模板里写死的中文正常,从数据库查出来的中文却乱码。这通常不会发生在 PDF 库层面,而是在数据读取或文件保存环节。
我遇到过一次诡异的事:同一个字段,在日志里打印出来是正常中文,写到 PDF 里却变成乱码。后来发现日志打印时经过了 IDE 的编码处理,反而是 PDF 保存时的文本来源没走 UTF-8。排查时不要动不动怀疑 PDF 库,先在数据链路每一段打印字段的字节长度,确认数据到 PDF 的入口时已经是解码后的正确字符串。
另外,生成 PDF 后如果又转成了其他格式(比如再次读取 PDF 文本),可能因为 PDF 内置字体的 ToUnicode 映射不完整导致提取乱码。这种是次要问题,一般业务不会关心 PDF 里能不能被文本提取器搜索到。
5.3 资源关闭与并发控制的经验
PdfDocument.close()这句代码,新手特别容易漏掉。它不只是释放一个 Java 对象,背后还有 PDFBox 对文档资源的引用、字体缓存、图片数据流。长期不关,在并发量上来之后一定会出问题。
我在压测环境见过一次事故:导出接口平均响应时间持续上升,GC 频率翻了几倍,最后 Java 进程直接 OOM。当时就是因为在try里生成了大量PdfDocument,save之后没有close,导致 PDFBox 的资源没有及时释放。查代码后发现异常路径上甚至没有 finally 块。
正确写法是 try-finally 包住:
PdfDocument document = new PdfDocument(); try { // 所有生成逻辑 document.save(outputStream); } finally { document.close(); }还有一次踩了重复关闭的坑。某个版本里我在 finally 里 close 之后,后面的业务代码调用了 document 的一些查询方法,结果抛了异常。后来统一了原则:close 之后绝对不再碰 document 的任何方法。如果你需要先验证内容再提交,可以在 close 前完成所有操作。
并发控制方面,除了每个线程独立创建 document,还要注意输出流不能共享。如果同一个OutputStream被多个线程同时写,PDF 文件会被写花。当你用异步批量导出时,每条任务用独立的文件路径或独立的ByteArrayOutputStream,最后再统一汇总。
进阶:HTML 转 PDF 的兜底方案
说到最后,我想提一个经常被忽略的模块。如果你发现用链式 API 拼复杂排版实在太痛苦——比如要做一个居中标题、两栏布局、带圆角边框的封面——别死磕代码,直接上 x-easypdf 的 HTML 转 PDF 能力。
引入easypdf-openhtmltopdf模块后,你可以把 HTML 模板和 CSS 样式交给前端同事或自己用模板引擎渲染,后端只做一个转换动作:
HtmlToPdfConverter converter = HtmlToPdfConverter.getHtmlToPdfConverter(); converter.convert("<html><body><h1>你好,HTML 转 PDF</h1></body></html>", "out.pdf");HTML/CSS 本身就是为排版设计的,处理文字换行、对齐、间距、页边距这些事远比手写 API 直观。碰上那种业务方反复改样式的场景,HTML 方案的最大优势是“改样式不用动 Java 代码”。我现在的项目里,纯数据类报表用链式 API,带复杂视觉设计的合同、封面、宣传页一律走 HTML 模板,两者配合能把各种需求都接住。
这套整合方案用下来,我最大的感受是:以前写 PDF 导出的节奏是“先跟字体纠缠一小时,再跟坐标纠缠一小时”,现在多数时间花在写业务字段上。如果你也遇到类似的场景,记住两句话——能用内置字体就别自己注册字体,能让库自动换行就别手写换行逻辑。x-easypdf 不是万能的,但至少它把那些最烦人的基础问题挡在了门外。