在Java后端做PDF这件事,绕不开的是iText。最近总有人拿“iText接口api”来问,仔细一看,多数是卡在这几个点上:7.x和5.x的API完全对不上、中文字体显示成方块、生僻字直接消失、以及不知道怎么把生成逻辑包装成一个可以稳定对外调用的接口api。这篇就把这些事从头到尾捋一遍。内容对新手友好,也适合已经写过几版PDF的老手查缺补漏;哪怕你用的是C#、Python,只要了解PDF库设计,看完思路也能平移。
1. 先把iText新老接口的坑摸清楚
1.1 为什么一提“iText接口api”大家就头疼
老项目里大量使用的是iText 5.x/2.x时代的写法:
java复制Document document = new Document();
PdfWriter.getInstance(document, new FileOutputStream("foo.pdf"));
document.open();
document.add(new Paragraph("Hello"));
document.close();
这段代码在论坛里还能搜到,但它属于iText 5时代的“老接口”。iText 7从架构上做了大幅重构,接口api设计从职责分散的静态工具类改成了更接近文档对象模型的方式,类之间的协作关系也变了。如果你把5.x的代码直接粘到7.x工程里,编译期就会报一堆错。所以“iText接口api”这个热搜词背后,其实藏着三类需求:一类是搞不清iText自己的API怎么调,一类是想把生成PDF的能力封装成一个接口api给其他服务调用,还有一类是被“Flying Saucer生僻字乱码”这类问题逼着来找方案的。这三类我今天都会覆盖到。
1.2 从对象模型理解iText 7的接口设计
iText 7的核心对象建议先记五个:PdfWriter、PdfReader、PdfDocument、Document、PdfFont。PdfWriter负责把字节流写到文件、内存或网络;PdfReader负责读一份已经存在的PDF;PdfDocument是PDF文档的底层抽象,相当于一张还没排版的白纸;Document是高层门面,你平时调用的add Paragraph、add Table,操作的都是它;PdfFont负责解决“字从哪来”的问题。
这个设计的逻辑是:把所有读、写、排版、字体问题拆开,每个对象只干一件事。接口api看着分散,组合起来反而灵活。比如要给已有PDF加一页内容,不需要重新生成整份文档,用PdfReader加PdfDocument就能实现;要合并多个PDF,用一个PdfMerger对象搞定;要做表单填充,有PdfAcroForm和PdfFormField。这类小工具对象在5.x里大多是静态方法,7.x则更强调持有状态的对象,所以你在调用时要先new对象、再调方法,而不是直接调一个静态工具类。
提示:查iText 7的API时,优先看官方文档javadoc,注意版本号。网上很多教程是5.x的,你在7.x工程里直接抄会非常痛苦。
1.3 版本选择建议:5.x还是7.x
面向长期维护的项目,我建议直接上7.x。理由很实在:
| 对比项 | iText 5.x | iText 7.x |
|---|---|---|
| API风格 | 静态工厂+PdfWriter | 对象协作,链式写法 |
| 中文字体 | 依赖font-asian,配置较绕 | 也需要字体注册,但流程清晰 |
| 官方维护 | 低版本功能更新早已冻结 | 持续迭代,有商业双授权 |
| 教程存量 | 老教程多,容易误导 | 官方文档齐全,中文资料偏少 |
| 新能力 | 不支持HTML转PDF | 有独立的pdfHTML模块 |
有些老系统还停在5.x,改动成本太高,那就维持现状别硬迁。如果是从零开始,直接学7.x,不要在旧教程上耽误时间。后面我所有的代码示例都基于iText 7.x。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从一行代码到一份完整PDF
2.1 Maven依赖怎么配
iText 7拆成了多个模块,官方叫add-on。基础功能最少需要kernel、io、layout三个包。在pom里加上:
xml复制<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>kernel</artifactId>
<version>7.2.5</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>io</artifactId>
<version>7.2.5</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>layout</artifactId>
<version>7.2.5</version>
</dependency>
版本号我用的是7.2.5,你可以换成当前最新的稳定版。注意这三个包的版本必须一致,混用会导致方法签名对不上,报NoSuchMethodError。
如果要做HTML转PDF,再额外引入:
xml复制<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>html2pdf</artifactId>
<version>4.0.5</version>
</dependency>
这里有个容易踩的坑:html2pdf的版本不一定要跟kernel一致,但要跟kernel的版本兼容。比如html2pdf 4.0.x对应的是iText 7.1.x或7.2.x,具体以官方兼容矩阵为准。我第一次用的时候版本随便填,结果内部类冲突,报了一堆莫名其妙的错。后来统一按官方文档推荐的组合来,一次就过了。
2.2 生成第一份PDF并逐行讲解
从内存流生成PDF,是一个很常用的姿势,因为要对外提供接口api时,直接返回byte[]比先写临时文件再读文件高效得多。一段最小可用代码是这样:
java复制import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;
import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Paragraph;
import java.io.ByteArrayOutputStream;
public class SimplePdfDemo {
public byte[] createPdf() throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
PdfWriter writer = new PdfWriter(out);
PdfDocument pdf = new PdfDocument(writer);
Document document = new Document(pdf);
document.add(new Paragraph("Hello iText 7"));
document.close();
return out.toByteArray();
}
}
逐行说几个关键点。PdfWriter可以接收文件名、OutputStream两种类型。传OutputStream的好处是你可以把PDF生成到内存、上传到OSS、或者通过HTTP响应直接返回,非常适合做接口api。Document的close方法必须调用,因为页面的布局、字体资源释放在这里完成。如果你只做了PdfDocument.close而没关Document,可能会出现“doc还没关闭”的警告,更严重的是内容可能没有完整写入。
注意:Document构造时默认页面是A4纵向,页边距是36磅左右。如果对页面大小有要求,用PageSize.A4.rotate()或new PageSize(595, 842)来指定。
2.3 样式、表格与页面设置:参数藏在哪
做真实业务时,没人会只输出一行纯文本。段落要有字体、字号、加粗,内容要有表格。iText 7里Paragraph和Table都支持流式接口api,用起来很像链式调用:
java复制Style titleStyle = new Style()
.setFont(font)
.setFontSize(18)
.setBold()
.setTextAlignment(TextAlignment.CENTER)
.setMarginBottom(20);
Paragraph title = new Paragraph("采购订单")
.addStyle(titleStyle);
Table table = new Table(4);
table.addCell("序号");
table.addCell("品名");
table.addCell("数量");
table.addCell("单价");
for (int i = 0; i < 5; i++) {
table.addCell(String.valueOf(i + 1));
table.addCell("商品-" + i);
table.addCell(String.valueOf(i * 2));
table.addCell("9.9");
}
document.add(title);
document.add(table);
Table的列宽可以用相对宽度来分配,比如new Table(new float[]{1, 3, 1, 1}),意思就是第2列更宽,其它列平均分。这个方法在做订单、报表时非常实用,它比每次都手算绝对宽度省事得多,也方便适配不同页面大小。
段落样式里有一个我常用的技巧:先建Style对象,然后复用到多个Paragraph上。这样改一次样式,全局生效,比每个Paragraph都写一遍setFontSize、setBold干净很多。接口api要处理大量模板时,这种“样式复用”能显著减少代码量。
关于页面设置,Document构造时可以指定页边距:
java复制PdfDocument pdf = new PdfDocument(writer);
Document document = new Document(pdf, PageSize.A4);
document.setMargins(36, 36, 36, 36);
这个36的单位是points,1英寸约等于72磅,36磅就是0.5英寸。如果你的业务要求“上下左右边距各2厘米”,可以先换算一下再填参数。
2.4 动态表格与分页经验
When表格内容超过了当前页剩余空间,iText会自动把表格拆开到下一页,这个行为默认就有,不需要额外配置。但如果你想控制不拆行,比如“订单表头不能单独留在页尾”,可以这么设置:
java复制Cell cell = new Cell().add(new Paragraph("订单编号")).setKeepTogether(true);
KeepTogether是iText里特别有用的属性。它除了用在Cell上,也能用在Paragraph、Image上,告诉引擎“这一块尽量放在同一页”。如果内容过长放不下,它会把整块推到下一页。这个属性能避免很多排版惨剧,不过也别滥用,如果一个表格单元格里有几页长的大段内容,强制KeepTogether反而会报“内容太大无法适应当前页面”的异常,遇到这种情况就需要把内容拆成多个Cell或者调整字号。
3. 把PDF生成封装成接口API
3.1 接口设计原则:别让业务代码散落一地
我刚做PDF功能那会儿,业务Service里直接new PdfWriter,写到一半又要处理字体、又要处理表格,一个生成订单的方法两三百行,后面维护起来非常痛苦。后来我总结了一套更稳的封装思路:底层一个PDF服务,承接纯生成逻辑;上层一个接口api层,负责参数接收、校验、鉴权、返回文件流。
接口api层建议把请求体和返回体固定下来。请求体可以包含文件名前缀、页眉页脚、模板数据、字体类型这些参数;返回体直接返回byte[],同时通过HTTP头告诉浏览器这是附件下载,还是在线预览。大部分业务场景下,“附件下载”比“在线预览”更省事,因为浏览器对PDF内嵌渲染的兼容性参差不齐。
提示:不要为了省自己的事去网上找“免费api接口”来生成PDF。把业务数据发给第三方接口,不仅响应速度和稳定性不可控,数据安全更没法保证。自己封装一个私有接口api才是正路。接口设计上也不要太花哨,能用一个参数区分的就不要拆成多个接口,否则后面每加一个业务场景就要新写一个端点,维护成本成倍上升。
3.2 一个可运行的Controller示例
下面是一个用Spring Boot写的PDF生成接口,代码量不大,但结构完整,能直接参考:
java复制@RestController
@RequestMapping("/api/pdf")
public class PdfGenerateController {
private final PdfGenerateService pdfGenerateService;
public PdfGenerateController(PdfGenerateService pdfGenerateService) {
this.pdfGenerateService = pdfGenerateService;
}
@PostMapping("/order")
public ResponseEntity<byte[]> generateOrderPdf(@RequestBody OrderPdfRequest request) {
byte[] data = pdfGenerateService.generateOrderPdf(request);
String fileName = URLEncoder.encode(request.getFileName(), StandardCharsets.UTF_8)
.replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + fileName)
.contentType(MediaType.APPLICATION_PDF)
.body(data);
}
}
关键的请求体对象:
java复制public class OrderPdfRequest {
private String orderNo;
private String customerName;
private BigDecimal totalAmount;
private String fileName;
// getter/setter 省略
}
服务层里做的事情很单纯:接收请求参数,组织数据,调用底层PDF构建方法,返回字节数组。这个分层让PDF排版逻辑和HTTP协议完全解耦。你以后想把这个能力暴露给消息队列消费,或者改成本地文件导出,只需要换一个入口,核心生成逻辑不用动。
有意思的是,这个封装思路不仅Java能用。你在C#里用iText7的.NET版,或者Python里用reportlab,接口api层的设计基本一致,唯一的区别是包名、命名空间和IDE快捷键不同。思路一旦建立,换语言只是换工具。
文件名这里有个细节:中文字段名在HTTP头里直接放会乱码,所以我用URLEncoder转码,再设置filename*=UTF-8'',这样浏览器下载时才能正确显示中文名。这个坑我在第一个版本就踩过,当时文件名全是“%E4%B8%AD%E6%96%87”这种百分号编码,用户看得一脸懵。
3.3 参数校验与异常处理
接口api最怕的不是功能问题,而是传参不规范导致的异常。比如fileName传null,或者orderNo传空字符串,PDF生成出来下载后文件名就是乱码或空。所以Controller里最好做前置校验:
java复制if (!StringUtils.hasText(request.getOrderNo())) {
throw new IllegalArgumentException("orderNo不能为空");
}
全局异常处理可以用@RestControllerAdvice把异常统一转成JSON返回,而不是让调用方收到一堆堆栈信息。对于PDF生成失败,异常信息要尽量业务化,比如“字体文件未找到”“模板ID不存在”。
我在实战中还遇到过一个隐藏问题:业务方在接口里传了一个负数金额,前端没拦住,PDF里也没做格式化,打出来就是“-1.00”,特别难看。所以生成PDF之前,参数层面的数据清洗和格式化,应该当成接口api的一部分来设计,不要指望后端PDF引擎去自动识别语义。
4. 高频问题与踩坑实录
4.1 中文字体缺失与生僻字乱码
中文乱码是iText群里问得最多的问题,没有之一。很多人第一次生成中文PDF,发现页面上全是小方块,原因很简单:iText默认字体Helvetica不支持中文。解决方法有两个方向,一是用亚洲字体包,二是直接加载系统字体文件。
加依赖:
xml复制<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>font-asian</artifactId>
<version>7.2.5</version>
</dependency>
然后这样注册:
java复制PdfFont font = PdfFontFactory.createFont(
"STSong-Light",
"UniGB-UCS2-H",
PdfFontFactory.EmbeddingStrategy.PREFER_NOT_EMBEDDED
);
这种方式用的是iText自带的亚洲字体配置文件,优点是部署简单,不依赖操作系统里的字体文件。但要注意:STSong-Light这个内置字体对生僻字的覆盖并不完整,尤其是扩展B区以后那些“𠀾”“𠮷”这类罕用字,很容易变成空白或豆腐块。如果你处理的文本包含古籍、人名地名生僻字,强烈建议直接加载一个完整的大字符集字体文件,比如Noto Serif CJK、思源宋体,或者系统里的simsun.ttc字体,用字体文件路径创建PdfFont。
java复制PdfFont font = PdfFontFactory.createFont(
"/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
PdfFontFactory.EmbeddingStrategy.PREFER_EMBEDDED
);
这里有一个细节:通过文件路径加载TrueType Collection(ttc文件)时,因为文件里包含多个字体子集,有的API写法需要指定字体索引,比如NotoSerifCJK-Regular.ttc,0表示第一个字体;如果iText解析不到正确的子集,会报“cannot find font”之类的错。遇到这个问题,可以先把它转成单个otf或ttf文件,再重新加载。
注意:注册字体之后,所有需要在PDF里显示中文的元素,都要显式setFont,比如Paragraph和Cell都要设。很多人只在段落上设了字体,表格Cell里的中文还是乱码,不是字体文件问题,而是Cell里的文本没设置字体,默认又回到了Helvetica。
4.2 Flying Saucer做html转PDF的取舍
“itext flying saucer 生僻字”这个词最近很火,看得出有不少人走的是HTML转PDF的路子。Flying Saucer是一个老牌的HTML渲染引擎,底层依赖低版本的iText,很多早期项目用它把HTML页面直接渲染成PDF,配合CSS控制分页非常方便。但它的局限也明显:对CSS3和生僻字支持很弱,底层iText版本旧,遇到较新的Unicode字符集容易出问题。
如果你的项目是从零起步,我更推荐iText官方出品的pdfHTML模块,也就是html2pdf,它直接构建在iText 7之上,版本一致、维护同步,生僻字支持也更好。用法比Flying Saucer简洁很多:
java复制import com.itextpdf.html2pdf.HtmlConverter;
ByteArrayOutputStream out = new ByteArrayOutputStream();
String html = "<html><body><p style='font-family:NotoSerifCJK'>你好,生僻字𠀾</p></body></html>";
ConverterProperties props = new ConverterProperties();
props.setBaseUri("/tmp");
HtmlConverter.convertToPdf(html, out, props);
不过也不是说html2pdf就没有坑。它对复杂CSS的支持还没有到浏览器级别,flex布局、grid布局支持不完整,如果你拿一个现代前端页面硬转,大概率样式会错乱。我的建议是:动态内容少、样式简单的页面用html2pdf;需要精确控制流水、分页、页眉页脚的正式单据,老老实实用iText的底层接口api来拼装。
4.3 并发、内存与性能优化
生成PDF是很耗费CPU和内存的操作,尤其是包含大量中文字体嵌入时。我做过一个批量导出报表的功能,单次导出几百个PDF,直接把测试环境干到OOM。排查下来发现两个问题:一是每个PDF生成时都重新加载字体文件,几千次文件IO把内存撑爆了;二是用线程池后没有做限流,服务器并发瞬间打满。
解决思路:字体对象全局缓存。PdfFont本身可以复用,把字体文件加载一次放进一个Map里,后续所有PDF复用同一个字体实例。这一步能显著降低内存和IO压力。另外可以引入信号量限制并发数,比如同时最多5个任务执行PDF生成,其余排队等待。
还一个容易被忽略的性能点是“写入方式”。如果生成超大PDF,比如几百页的报表,不要用ByteArrayOutputStream一次性把全部数据装进内存,而是用临时文件流或者OutputStream分批写入,否则大文件场景下内存占用会成倍上升。生成完毕后再把文件返回或上传,这样接口api就不会因为一个超大PDF把整个应用拖垮。
4.4 生成的文件打不开怎么办
有时候代码没报错,但它生成出来的PDF用阅读器打开就报“文件已损坏”。这种情况十有八九是流没有关闭或者关闭顺序错了。iText 7的规则是:先关Document,再关PdfDocument,最后关底层流。如果你用try-with-resources,注意把流放到最外层:
java复制try (ByteArrayOutputStream out = new ByteArrayOutputStream();
PdfWriter writer = new PdfWriter(out);
PdfDocument pdf = new PdfDocument(writer);
Document document = new Document(pdf)) {
document.add(new Paragraph("hello"));
}
这个写法里,finally会按逆序关闭资源,顺序刚好是Document、PdfDocument、PdfWriter、out,是最稳妥的。我见过有人手动下面先关了ByteArrayOutputStream,PDF还没写完,数据就直接丢了,但是代码不报错,生成的文件必然损坏。
还有一种情况是跨平台路径问题。Windows下加载字体路径写的是C:\Windows\Fonts\simsun.ttc,部署到Linux服务器上就找不到文件,字体解析失败时会直接抛异常,而不是降级处理。解决方式是不要写死系统路径,把字体文件打进resources目录,通过classpath加载,或者让运维把字体统一放到约定目录,代码里通过配置文件读取。我在生产环境就把Noto字体文件放到了/usr/local/fonts目录,再用Spring的配置项暴露路径,这样换机器不用改代码,改配置就行。
排查这种问题时,我习惯先开iText的调试日志,看它在解析哪个字体、哪一步失败。大多数报错信息其实写得很清楚,只是被很多人直接跳过了。
5. 一点个人体会
PDF生成这个活儿,看起来是个“小众工具类”需求,真正做深了才发现里面全是细节。从iText接口api的选型,到字体处理,再到接口封装、并发控制,每一步都能写出一堆坑。我自己的建议是:不要迷信网上那些零散的copy-paste代码,一定要基于官方文档,理解iText 7的对象模型;遇到生僻字乱码,优先换大字符集字体文件,不要在编码配置上反复绕弯;最后就是把PDF逻辑和HTTP接口api分层,不要让Controller里堆满排版代码。这样不管是后续升级iText版本,还是扩展新的导出场景,你都会轻松很多。
最后分享一个小经验:做完一个PDF生成功能后,记得把字体文件和iText版本号的依赖关系记录下来,放进项目的README里。很多问题在几个月后重新回头看,就是因为换了一次环境或者升级了一个依赖才冒出来的。有记录,排查速度会快好几倍。
