去年底我接手了一个内部管理系统的开发任务,业务方要求把各种报表、合同、对账单全部以 PDF 形式导出。说实话,当时接到这个需求,我心里是有点发怵的。在 Java 生态里做 PDF,老一辈的人首先想到的是 iText,但它的商业许可让人头疼;新一代的方案比如 PDFBox、Flying Saucer 也不是不好,就是上手曲线陡,尤其是中文排版这一块,字体路径、CID 字体映射、编码问题,哪个都能把人折腾得够呛。
后来在开源社区里发现了 x-easypdf 这个项目,第一感觉是“名字起得挺直白”,第二感觉是“这玩意儿是真正站在开发者角度设计的”。项目底层基于 PDFBox,但对外提供的 API 极其友好,它允许你像操作文本流一样去构建一个 PDF 文档。最打动我的一点是它对中文排版的自动化处理,你不需要像 iText 那样手动去注册 FontProvider 或者做一堆 BaseFont 的操作,只要把 TTF 字体文件放到类路径下,maven 引入依赖后写个 Builder 链式调用,一份带各种格式、样式、水印、数字签名的 PDF 就能生成出来。
从实际体验来说,用 x-easypdf 整合进 Spring Boot 项目,从引依赖到跑出第一个 PDF 文件,5 分钟真的不是口号。这篇文章我打算把自己这套完整的落地经验整理出来,从核心设计逻辑、环境准备、代码实现到排查陷阱,尽量写得让即使没接触过 PDF 库的同事,也能拿着文章把功能做出来。
1. 为什么我会在 Spring Boot 项目里选择 x-easypdf
在做技术选型之前,我其实先把自己手头要解决的痛点理了一遍。Spring Boot 项目里做 PDF 生成,绝大多数场景不是要做一个类似 Adobe Acrobat 那样的复杂编辑软件,而是要把数据库里查询出来的结构化的业务数据,按照一定的视觉规范(Logo、标题、表格、签字栏)落成一个 PDF 文件给客户或内部系统使用。这类需求的核心矛盾在于:数据是活的,模板是固定的,生成过程要求快、稳、不乱码。
x-easypdf 恰好把这个问题拆解得非常清楚。它的核心思路是把 PDF 文档抽象成一个由 Component 组件组成的“容器”。你可以创建 Text 文本、Image 图片、Table 表格、Rect 矩形、Line 线段这类基础组件,通过构建器模式把它们组织排列在页面上。这种方式相比直接操作 PDFBox 的低级对象模型,心智负担小很多。
举个例子,在 iText 时代,你要想让中文正常显示,必须自己创建 BaseFont 并处理中文子集,一个不小心就抛出java.io.IOException: Cannot create PDF。在 x-easypdf 里,你只需要通过fontPath指定中文字体文件,或者在配置文件里指定默认字体,它会自动在内部完成字体子集嵌入和编码转换。我实测过,处理包含制表符、全角标点、中文引号的文本段落,输出效果和 Word 排版差别很小。
用 x-easypdf 还有一个很现实的考量:它不强制你使用某种重量级的模板引擎。很多团队会用 FreeMarker 或者 Thymeleaf 先把 HTML 渲染好,再借助 openhtmltopdf 转 PDF。这条路不是不行,但 HTML 到 PDF 的 CSS 兼容性差异是一个深坑,尤其是分页的时候,经常出现页眉重复、表格边框消失这些诡异问题。x-easypdf 直接绕过 HTML,采用“代码即模板”的方式,虽然初看起来要比写 HTML 多几行代码,但胜在可控、可靠,适合对输出格式有严格要求的项目。
1.1 核心优势:官方对中文排版的深度优化
说到中文排版,这里值得展开讲。中文排版和英文排版有本质上的区别,英文单词天然可以通过空格分词,一个单词放不下可以直接换行;但中文没有空格,一个汉字就是一个基本单元,所以换行算法和标点符号的避让规则(比如行首不能出现逗号、句号,左引号不能出现在行尾)都需要引擎层面支持。x-easypdf 的文本组件在处理wordBreak和hyphenation的时候,对中日韩文字(CJK)做了专门适配。我在项目里写入大段中文描述时,只需要指定宽度和行间距,它就能自动按中文习惯折行、避头尾,输出的文档非常规范。
1.2 开源协议友好,集成无License风险
因为 PDF 库涉及到商业使用,开源协议是个必须检查的关卡。x-easypdf 采用 Apache 2.0 License,这个协议对公司商用非常友好,允许自由使用、修改、分发,甚至可以把它整合到闭源商业产品中。对比之下,iText 的 AGPL 条款和商业授权费用,对于很多中小团队是一道不小的门槛。我见过不少项目因为没注意 iText 的 License 条款,最后被迫花大价钱做合规补救,这一点大家在选型时一定要注意。
2. Spring Boot 整合实操:5 分钟跑通第一个 PDF 生成接口
这个部分全程使用我的实际项目截图和现场记录。先说明一下我的开发环境:JDK 17,Spring Boot 2.7.8,Maven 3.8.6,x-easypdf 版本 2.3.1(注意不同版本 API 略有差异,建议新项目直接用最新版)。
2.1 Maven 依赖引入
x-easypdf 的核心包叫x-easypdf,是物理 jar 包。不过它又按照功能做了细分,你既可以直接引入全部功能的 fat jar,也可以按需拆分,比如引入x-easypdf-core、x-easypdf-swing、x-easypdf-image等模块。我这里使用最简单的方案,直接在pom.xml中引入完整坐标:
<dependency> <groupId>wiki.xsir</groupId> <artifactId>x-easypdf</artifactId> <version>2.3.1</version> </dependency>引入这个包差不多会连带下载 PDFBox、FontBox、Common 等几个依赖,它们都是 Apache 基金会项目,稳定可靠。依赖解析完成后,执行一下mvn compile确认没有冲突问题,这一步基本几十秒搞定。
2.2 在 Spring Boot 工程里注册 PdfGenerator 组件
x-easypdf 本身没有强制依赖 Spring 容器,我们可以把它作为一种工具类使用。但为了统一管理和便于后续配置,更推荐的做法是在 Spring Boot 的配置类里将它注册为一个 Bean。这样其它 Service 需要生成 PDF 的时候,直接注入这个 Bean 使用即可。我的写法如下:
package cn.demo.config; import wiki.xsx.core.pdf.processor.PdfProcessor; import wiki.xsx.core.pdf.processor.impl.DefaultPdfProcessor; import wiki.xsx.core.pdf.template.XEasyPdfTemplate; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class PdfConfig { @Bean("pdfProcessor") public PdfProcessor pdfProcessor() { // 创建默认的PDF处理器,内部封装了PDFBox的核心操作 return new DefaultPdfProcessor(); } }这里定义的 DefaultPdfProcessor 是 x-easypdf 中非常核心的入口。它底层封装了文档的创建、页面渲染、字体加载等操作,你后面所有组件的添加和样式设置都是基于它来完成的。
2.3 基础文本 PDF 生成:Hello World 升级版
完成以上两步,就可以写第一个生成 PDF 的 Service 了。我们来实现一个最简单的场景——根据传入的字符串,生成一个带标题和正文的 PDF 文件,直接输出到指定目录。代码逻辑非常简单,即使你之前没接触过 PDFBox 也能秒懂:
package cn.demo.service; import cn.demo.config.PdfConfig; import org.springframework.stereotype.Service; import wiki.xsx.core.pdf.component.impl.XEasyPdfText; import wiki.xsx.core.pdf.handler.XEasyPdfHandler; import wiki.xsx.core.pdf.processor.PdfProcessor; import javax.annotation.Resource; import java.io.File; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; @Service public class PdfService { @Resource private PdfProcessor pdfProcessor; public void createSimplePdf(String filePath, String title, String content) throws Exception { // 1. 创建一个 PDF 文档构建器,并设置页面大小为 A4 XEasyPdfHandler.Document.build() // 2. 添加一个页面,并设置页边距 .addPage(XEasyPdfHandler.Page.build() .setMargin(50f)) // 3. 在页面上添加一个居中的标题文本组件 .addComponent(XEasyPdfHandler.Text.build(title) .setFontSize(18f) .setFontColor(XEasyPdfHandler.Color.BLACK) .setHorizontalStyle(XEasyPdfHandler.Text.HorizontalStyle.CENTER)) // 4. 添加一段正文文本,自动换行 .addComponent(XEasyPdfHandler.Text.build(content) .setFontSize(12f) .setFontColor(XEasyPdfHandler.Color.GRAY)) // 5. 在页面底部添加一个时间戳 .addComponent(XEasyPdfHandler.Text.build("生成时间:" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) .setFontSize(9f) .setFontColor(XEasyPdfHandler.Color.LIGHT_GRAY) .setVerticalStyle(XEasyPdfHandler.Text.VerticalStyle.BOTTOM)) // 6. 完成文档构建并输出到文件 .complete() .save(new File(filePath)); System.out.println("PDF文件生成成功:" + filePath); } }其实到这里已经能体会到 x-easypdf 的风格了:几乎每个组件都有对应的 Handler 工厂(XEasyPdfHandler),它负责把复杂配置封装成链式调用的方法。你只需要关心“我要加一个标题”,而不是去想“PDFBox 里我应该 new 一个 PDPage,再 new 一个 PDPageContentStream,设置字体、设置颜色、计算坐标……”。
2.4 5 分钟验证流程回顾
我大概掐过表,完成上面三步,从新建一个空的 Spring Boot 工程(或者已有工程)到跑通这个接口,正常 5 分钟内是够的。其中最大的时间开销可能是在 IDEA 里敲代码、等 Maven 下载依赖。如果你已经有工程基础,只把配置文件写好后启动应用,调用 Controller 一发请求,磁盘上就会多出一个排版整齐的 PDF,那种成就感还是挺爽的。
这里要提醒大家一个细节:XEasyPdfHandler.Document.build()产出的是文档构建器,complete()方法会执行最终的文档渲染和字体嵌入,这一步相对耗时(小文档十几毫秒到几十毫秒不等),但对于后台异步任务来说完全可以接受。实际项目中我会把这个操作放到一个新的线程池里执行,避免阻塞请求线程。
3. 中文排版深度玩法:字体、样式、模板与数据表格
如果只是生成几个简单的文本页面,那确实五分钟就够。但真实业务里,PDF 往往需要“长得像企业自己人做的文档”。这就要深入 x-easypdf 的字体设置、文本样式编排、表格数据填充以及动态模板定制了。这一节我把这些核心细节逐一拆解。
3.1 中文字体配置与字体文件加载的技巧
x-easypdf 在处理中文字体上比很多库省心,但技术上依然有一个核心原则:处理中文必须声明中文字体,且最好是 TTF 或者 TTC 格式的字体文件。系统默认字体(如 PDFBox 内置的 Standard 14 字体)只支持 Latin-1 字符集,汉字根本显示不出来,表现出来就是白板或乱码。
在 x-easypdf 中,设置字体有三个位置可以下手:
- 全局设置:在创建 Document 构建器时使用
.setGlobalFont()设置整个文档的默认字体。 - 页面设置:在 Page 上设置,主要影响页面默认文本组件。
- 组件设置:在每一个具体的 Text 组件上通过
.setFontPath()指定。
我的建议是:项目里至少准备两个字体文件,一个用于正文(推荐思源黑体 Regular 或微软雅黑),一个用于标题(推荐思源黑体 Bold 或苹方)。文件放在src/main/resources/fonts/目录下,代码如下:
XEasyPdfHandler.Text.build(content) .setFontPath("classpath:fonts/NotoSansCJK-Regular.ttc") .setFontSize(12f)注意:如果你的字体文件是 TTC 格式(一个文件包含多种字重的 TrueType Collection),有些版本的 PDFBox 解析时可能会有一点问题。我测试下来,2.3.1 版本的 x-easypdf 对 TTC 支持还算稳定,但如果你遇到字体加载异常,可以尝试把需要的字重单独提取成 TTF 文件,再用工具转一下,基本可以解决。
另外还有一个关于体积的提醒:x-easypdf 默认会嵌入字体子集,也就是只会把文档中用到的那些字符对应的字形嵌入 PDF 文件,这样可以显著减小文件体积。一套完整的中文字体文件动辄十几兆,但一个只有几千字的 PDF 生成出来可能才几十千字节。如果是客户要求提供可编辑的 PDF(保留字体),那需要额外做完整嵌入的配置,但那种场景我这个项目里用不到。
3.2 文本更好的排布:行高、缩进、对齐与定位
做中文排版,一个很现实的问题是你不能把文本当成一个简单的字符串丢进去,必须有意识地控制它的“盒子模型”。x-easypdf 的 Text 组件在底层就维护了一个矩形区域,也就是文本占据的边界框(Bounding Box)。它的对齐、换行、垂直定位都是针对这个盒子来执行的。
我用一个实例来说明:我们在生成一份《产品验收报告》的时候,标题部分要求左对齐,正文要求首行缩进两个字符,落款要求右对齐。这个需求如果手写 PDFBox,需要自己计算每行文本宽度,再换算成一个空格字符的宽度,非常麻烦。x-easypdf 的做法则简单粗暴:
// 标题:左对齐,加粗 XEasyPdfHandler.Text.build("产品验收报告") .setFontPath(fontBold) .setFontSize(18f) .setHorizontalStyle(XEasyPdfHandler.Text.HorizontalStyle.LEFT); // 正文:首行缩进,使用全角空格占位 XEasyPdfHandler.Text.build(" (一)验收范围与依据。") .setFontPath(fontRegular) .setFontSize(12f) .setLeading(1.5f) // 设置行距比例 .setHorizontalStyle(XEasyPdfHandler.Text.HorizontalStyle.LEFT); // 落款:右对齐 XEasyPdfHandler.Text.build("北京某某科技有限公司") .setFontPath(fontRegular) .setFontSize(10f) .setHorizontalStyle(XEasyPdfHandler.Text.HorizontalStyle.RIGHT);这里的setLeading(1.5f)设置的是行距倍数。对于中文内容,1.5 倍行距是常见的排版风格,视觉上不拥挤也不散乱。需要特别注意的是,x-easypdf 中的setLeading除了影响行距,对文本组件的实际高度计算也有影响,如果你在它后面再添加其它组件,要注意留出足够的位置,防止重叠。
3.3 动态模板:占位符替换与数据填充
很多报表场景中,PDF 的框架是固定的,但里面的数据字段每次不一样。这时候有两种做法。一种是用代码把模板写成 Java 对象,每次 new 出来,再 set 对应的值;另一种是先做一个 JSON 模板描述文件,运行时把 JSON 转成 XEasyPdfTemplate 对象,动态生成。x-easypdf 原生支持 XEasyPdfTemplate 模块,可以通过 XML 或者 JSON 定义模板,然后绑定数据,实现类似 Word 邮件合并的效果。
由于这两种方式我都在项目里用过,这里把 JSON 模板方案写得细一点:
首先定义模板描述文件template.json(放在resources/templates/下),内容大致如下:
{ "title": "测试模板", "components": [ { "type": "text", "content": "${reportTitle}", "fontSize": "20", "fontPath": "classpath:fonts/NotoSansCJK-Bold.ttc", "horizontalStyle": "CENTER" }, { "type": "text", "content": "报告编号:${reportNo}", "fontSize": "11", "fontPath": "classpath:fonts/NotoSansCJK-Regular.ttc", "horizontalStyle": "LEFT" }, { "type": "table", "data": "${tableData}" } ] }然后在 Java 代码中构建一个 HashMap 作为数据源,使用XEasyPdfTemplate类解析模板并渲染 PDF:
import wiki.xsx.core.pdf.template.XEasyPdfTemplate; Map<String, Object> data = new HashMap<>(); data.put("reportTitle", "2024年度项目验收报告"); data.put("reportNo", "YS-2024-001"); data.put("tableData", Arrays.asList(...)); // 表格数据集合 XEasyPdfTemplate.build() .setTemplatePath("classpath:templates/template.json") .loadData(data) .complete() .save(new File(filePath));模板方案带来的好处是,业务人员可以单独去改模板 JSON,而不用重新发布 Java 代码。但它的学习成本比直接用 Java API 要高一些,所以我通常建议团队先学会 Java 链式 API,掌握核心组件后再尝试模板化改造。
3.4 表格组件:让报表数据变得规整
表格是报表 PDF 里占比最高的组件类型,x-easypdf 对 Table 的封装是它的另一个亮点。你不需要一行一行画线条,而是定义好表头和行数据,它会自动布局网格。以下是我写的一个不带样式的精简版示例,它足以展示表格的核心能力:
XEasyPdfHandler.Table build( List<String> headers, List<List<String>> rows ) { // 创建表格构建器 XEasyPdfHandler.Table.TableBuilder builder = XEasyPdfHandler.Table.build(); // 添加表头 for (String header : headers) { builder.addCell(XEasyPdfHandler.Table.Cell.build(header) .setFontPath(fontRegular) .setFontSize(10f) .setBackgroundColor(XEasyPdfHandler.Color.LIGHT_GRAY) .setHorizontalStyle(XEasyPdfHandler.Text.HorizontalStyle.CENTER)); } // 添加数据行 for (List<String> row : rows) { for (String cellText : row) { builder.addCell(XEasyPdfHandler.Table.Cell.build(cellText) .setFontPath(fontRegular) .setFontSize(9f)); } } return builder; }表格设计上有一个实用技巧:表头单元格和正文单元格最好使用不同的背景色或边框样式,这样在黑白打印时也能一眼区分层级。x-easypdf 的 Cell 组件支持setBackgroundColor、setBorderColor、setBorderWidth方法,你可以根据需要微调。如果表格列数比较多,算好总宽度非常关键,默认的单元格宽度分配是按照内容自适应,但如果每列都塞入长字段,可能会撑破页面,此时可以给整个表格设置setWidth(),并给表头单元格指定setWidth()比例。
3.5 添加图片、水印、页眉页脚与数字签名
除了文本和表格,常见的 PDF 需求还有 Logo、水印、页眉页脚和数字签名。x-easypdf 在这方面同样给出了相当顺手的工具方法。
图片比较简单,XEasyPdfHandler.Image.build()可以直接从字节数组或 File 生成 Image 组件,设置位置和大小即可。水印则有两种方式,一种是直接添加一个带透明度的文本或图片组件作为页面背景,另一种是用Page构建器上的水印设置。页眉页脚是页面级别的重复内容,建议在 Page 上用XEasyPdfHandler.Page.build().addHeader()和addFooter()实现,比如页码、公司名称等信息。
数字签名属于进阶功能,x-easypdf 也提供了对 PDFBox 签名封装的支持,可以加载 KeyStore 生成签名字段。但这个部分涉及 PKCS#12、证书链等专业知识,一般情况下后台系统里用得不多,我这里不展开细讲,只在代码注释里留了应对思路。
4. 集成在实际业务场景中的落地经验
再好的技术,最终都要回归到业务场景里检验。我在这个项目里一共做了几个不同类型的 PDF 文件:合同文本、对账单表格、测试报告。每种文件对排版和性能的要求都不一样,这里把几个印象深刻的经验点分享一下。
4.1 后台异步生成与服务化设计
PDF 生成不是瞬时操作,尤其当数据量大、表格行数多的时候,同步地在请求线程里生成不仅会让用户长时间处于等待状态,还容易造成请求超时。我在设计接口时采用的是一个比较稳妥的思路:前端提交“生成请求”后,后端立刻返回一个任务编号,实际 PDF 生成丢到线程池里异步执行,生成完后把文件上传到对象存储,再把下载地址更新到任务表。前端通过轮询任务状态来获取结果。
这个方案的好处是应对突发流量很轻松。我实测过一个包含 2000 行数据的对账单,生成耗时大约在 800ms 左右,稍微加一点文本排版可能要 1.5 秒。如果用户点一次按钮就同步等 1.5 秒,体验是灾难级的。走异步任务后,用户该干嘛干嘛,PDF 生成好了再通知,整个系统负载也更平稳。
4.2 大文件生成的内存控制
x-easypdf 底层是 PDFBox,而 PDFBox 对文档的操作是支持流式处理的。但 x-easypdf 在高层 API 中把文档对象都加载到了内存中,因此生成超大 PDF(比如上千页)时有内存溢出的风险。我在实测中发现,用默认 JVM 堆内存(256MB)跑 500 页文档时出现了OutOfMemoryError。
解决思路有两个层面:
- 在 JVM 层面把
-Xmx调大一些,比如 1GB 或 2GB,并给 PDF 生成接口对应的线程池设置单独的ThreadPoolExecutor,避免其它业务线程和它争抢内存。 - 从业务端限制单个 PDF 的最大页数。像对账单这种,如果在一次请求中塞入全部数据,页数很容易爆炸。更合理的做法是分页生成或者拆分单据。
我项目里既调大了内存,也做了数据分批。遇到超过 50 页的报表需求,就拆成多个 PDF 打包下载,这样每个 PDF 生成耗费的资源都可控。
4.3 与本地文件系统、对象存储的对接方式
PDF 生成完成后,保存到哪里是个实际问题。如果你是在云环境部署,建议直接对接阿里云 OSS、腾讯云 COS 或 MinIO。x-easypdf 的save()方法接收的是File类型,所以我们只需要在内存中生成字节数组再转为 File,或者生成到临时目录后由存储工具上传。
我的代码里是这样处理的:
// 生成到内存字节数组 byte[] pdfBytes = XEasyPdfHandler.Document.build() .complete() .toBytes(); // 再通过 Spring 的 Resource 工具保存或上传 File tmpFile = File.createTempFile("report", ".pdf"); Files.write(tmpFile.toPath(), pdfBytes); // 调用存储服务上传,例如OSS客户端 ossClient.putObject(bucketName, objectKey, tmpFile);这样做的好处是生成的临时文件可以及时删除,避免磁盘空间被频繁占用。
4.4 从“能用”到“好用”的细节打磨
布局方面,一定要重视页边距和分页。我刚开始做的时候,把所有组件都放在同一个页面上,当内容超过一页时,x-easypdf 的文本组件会自动整体换到下一页,但表格组件默认不一定能自动分割。这一点需要仔细阅读官方文档,有些版本对 Table 的跨页支持是通过setAutoSplit(true)实现的。如果不开启自动分页,表格超长后内容会被裁掉,这绝对是报表场景里最致命的排版问题之一。
字体方面,中文内容务必测试“半个标签页”场景。有的字体文件只包含简体常用字,遇到繁体、生僻字时会变成方框。如果业务上有显示用户姓名或地址的需求,最好选用 GB18030 字符集覆盖比较好的字体,比如思源黑体或站酷字体。
时间字段的处理上,从数据库查询出来的日期往往是LocalDateTime或Date,在写入 PDF 之前,一定要统一格式化成字符串,并且根据目标用户所在时区调整。不要直接用toString(),否则会在 PDF 中出现2024-01-01T08:30:00这种很丑的 ISO 格式。
5. 常见问题排查与避坑实录
这个章节是我个人觉得最有价值的细节合集,全是我在实战中踩过的坑,整理成速查表的形式,遇到同类问题可以直接拿来比对。
| 问题现象 | 原因分析 | 解决办法 |
|---|---|---|
| 生成 PDF 内容空白,文本显示为方框 | 没有指定中文字体或字体文件缺失 | 在 Text 组件上显式设置setFontPath("classpath:fonts/xxx.ttc"),确保文件存在 |
| 中文文本出现乱码 | 字体子集嵌入失败或 PDFBox 版本兼容问题 | 将字体转为 TTF;升级或降级 x-easypdf 版本 |
| 表格内容超出页面边界 | 未开启表格自动分页 | 调用.setAutoSplit(true);并注意表格整体宽度不要超过页面宽度 |
| 多页文档时页眉页脚重复显示 | 页眉页脚组件加在了 Page 而不是 Document 的全局配置中 | 使用 Document 构建器上的全局页眉设置,或在创建页面前先定义页眉页脚组件 |
| 生成大 PDF 时内存溢出 | 默认堆内存不足或线程池并发太高 | 调大 -Xmx,改用异步线程池并限制并发数 |
| 字体文件抛“Invalid CFF Font”异常 | 字体文件包含 CFF 轮廓,PDFBox 解析受限 | 转换字体为 TrueType 轮廓格式(如用 fonts 工具转成 TTF) |
| 生成的 PDF 在浏览器预览时文字不显示 | 浏览器内置 PDF 阅读器对字体子集兼容性问题 | 用 Adobe Acrobat 打开验证;如果确认是兼容问题,考虑嵌入完整字体 |
| 组件重叠导致文字覆盖 | 组件定位方式与页面排列模式冲突 | 给指定组件设置setRelativePosition或手动控制坐标顺序 |
5.1 字体问题的深度排查与处理
字体问题几乎是所有第一次使用 x-easypdf 的人都会遇到的。我的建议是:项目里固定维护一个fonts/目录,并写一个简单的字体加载测试方法,在系统启动时检查所有字体文件是否可以被正常注册。这个启动自检成本很低,但能帮你在开发阶段就发现大部分字体坑。
@PostConstruct public void initFontCheck() { try { XEasyPdfHandler.Font.load("classpath:fonts/NotoSansCJK-Regular.ttc"); XEasyPdfHandler.Font.load("classpath:fonts/NotoSansCJK-Bold.ttc"); } catch (Exception e) { log.error("字体文件加载异常", e); throw new RuntimeException("PDF字体初始化失败,请检查字体文件"); } }这里需要注意,XEasyPdfHandler.Font.load在实际使用时需要注意类名和方法,有的版本可能是XEasyPdfHandler.Font.loadFont,以你引入的版本 API 为准。核心思想就是在启动时候做校验,而不是等用户点“导出”时才报错。
5.2 页面布局错乱的修正思路
文本重叠、组件错位,这些问题的根源多数是坐标系统没有理解透彻。x-easypdf 默认使用 PDF 坐标系,坐标原点在页面左下角,x 轴向右侧延伸,y 轴向上延伸。这和 HTML 或 Swing 的坐标体系正好相反。如果你习惯了从上往下设定位置,很容易算错 y 值。
一种比较稳妥的布局策略是:不要手动指定绝对位置,而是利用 x-easypdf 提供的默认自动布局机制——组件按照添加顺序从上往下排列。每添加一个文本,它就会占据一定的高度,下一个组件会自动排在它下方。当你需要手动微调时,再对个别组件使用setY()或setX()覆盖位置。
我在生成对账单时发现,文本组件加得多,系统默认的间距会有点挤,于是我调整了页面的setMargin(50f),并使用setLeading(1.6f)提升了可读性。遇到组件意外重叠的情况,优先检查是否在同一个 Page 下重复添加了相同定位的组件,不要把应该放到下一页的内容强行塞进当前页。
5.3 性能优化与并发控制
PDF 生成是 CPU 密集和内存消耗型操作,Spring Boot 默认的 Tomcat 线程池如果直接用来执行 PDF 生成,在高并发下压力非常大。我在项目中专门定义了一个独立的线程池:
@Bean(name = "pdfExecutor") public Executor pdfExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(20); executor.setThreadNamePrefix("pdf-gen-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }这样设置以后,PDF 生成的异步任务不会拖垮接口线程,而且CallerRunsPolicy可以保证在队列满的时候由提交线程自己执行任务,至少不会丢弃请求。
另外还有一个优化点:PDF 文档生成的耗时主要集中在字体加载和文本布局计算上。字体加载是重操作,所以我建议在所有 PDF 生成任务中共用一个全局字体实例,而不是每次生成都去读取字体文件。幸好 x-easypdf 内部缓存了字体对象,我们只需要注意不要频繁初始化新的 Document 去加载相同字体即可。
5.4 文件路径与中英文文件名编码问题
最后提一个很多人忽略的细节:保存 PDF 时的文件路径、文件名如果包含中文,在某些 Linux 环境下可能存在编码问题。建议生成文件名时统一采用英文+数字的规则,或者 URL 编码后保存,展示时再提供一个中文别名。否则后期在做对象存储或附件下载时,很容易遇到 404 或文件名乱码。
我项目里最终采用的文件名格式是report_{taskId}_{timestamp}.pdf,业务上再维护一张表关联真实名称,这样最稳妥。
6. 从 PDF 生成到文档中台的一点扩展思考
做完这个项目后,我最大的感触是:一个成熟的 PDF 生成模块,不能只停留在“能生成文件”这个层面。再往前走一步,它完全可以进化成一个小型的文档中台服务。比如把模板管理、字体管理、任务调度、文件存储、版本记录这几个模块独立出来,理论上可以服务公司内部各个业务线的导出需求。x-easypdf 的 API 设计非常适合朝这个方向扩展,因为它是无状态的工具库,我们可以很轻松地把它封装成一个 RESTful 服务。
我在项目中预留了一个generate()统一接口,入参是任务类型、业务参数、模板编号,出参是文件下载地址。这样业务方只要调用一个接口,不用关心底层到底用的是 x-easypdf 还是 PDFBox,也不关心文件是放在本地还是 OSS 上。
如果再配合上消息队列,比如提交任务后发送一个 Kafka 消息,再由消费者去执行 PDF 生成,系统的稳定性和扩展性会更强。比如每天凌晨自动生成一批前一日的数据统计报表,或者定时把待签署的合同批量处理成 PDF,都是可以无缝接入的。这个思路后续我会单独写篇文章分享,今天先把基础功能相关的东西讲透。
整体看下来,x-easypdf 确实是 Spring Boot 项目里做 PDF 生成和中文排版的一个优秀选择。它的表达方式很符合程序员的直觉:用代码构建页面结构,用链式调用设置属性,然后用一个统一的save()方法把内容固化成一个文件。相比硬啃 PDFBox 或者处理一堆 XML 配置,这个库能帮你把精力专注在业务本身,而不是底层的排版细节上。
如果你最近也在为 Java 生成 PDF 发愁,不妨花一个下午把这个库跑通。等真正把第一份中文报表发到客户邮箱时,你就会知道这种“稀松平常”的背后是多么省心。