poi-tl + docx4j:从 Word 模板到 PDF,Java 实战全流程,最近做项目的经验分享!
写在开头
企业级 Java 开发中,发票、合同、通知书等文档生成几乎是绕不开的需求。常见的做法有三种:用 Apache POI 从零拼装(代码地狱)、用 iText 直接画 PDF(排版噩梦)、或者用 JasperReports(学习曲线陡峭)。
最舒服的方式其实是——用 Word 当模板,业务数据往里填,最后一键导出 PDF。这条链路的核心是 poi-tl(模板渲染) + docx4j(DOCX 转 PDF)。
本文从 0 到 1 带你搭建一套完整的文档生成方案,包含三种模板场景、中文字体适配、以及生产环境踩过的坑。
一、环境准备
先看 pom.xml,五个关键依赖:
<!-- docx4j 核心库,用于加载和操作 DOCX --><dependency><groupId>org.docx4j</groupId><artifactId>docx4j-core</artifactId><version>11.5.4</version></dependency><!-- poi-tl 模板引擎,用于填充 Word 模板中的 {{占位符}} --><dependency><groupId>com.deepoove</groupId><artifactId>poi-tl</artifactId><version>1.12.2</version></dependency><!-- FO 导出模块,DOCX 转 PDF 时必需 --><dependency><groupId>org.docx4j</groupId><artifactId>docx4j-export-fo</artifactId><version>11.5.4</version></dependency><!-- JAXB 参考实现,JDK 9+ 必须显式引入 --><dependency><groupId>org.docx4j</groupId><artifactId>docx4j-JAXB-ReferenceImpl</artifactId><version>11.5.4</version></dependency><!-- 日志 --><dependency><groupId>org.slf4j</groupId><artifactId>slf4j-simple</artifactId><version>2.0.13</version></dependency>
几个要点:
-
docx4j-export-fo 容易漏掉,但少了它 Docx4J.toPDF()直接报 ClassNotFound。 -
JAXB 参考实现 在 JDK 8 及以前不需要,但 Java 9 移除了 javax.xml.bind,必须显式引入,否则你会看到NoClassDefFoundError: javax/xml/bind/JAXBException。 -
poi-tl 依赖 Apache POI(作为传递依赖自动引入),不需要额外声明 POI。
Java 版本建议 11+,本项目用的是 17:
<maven.compiler.source>17</maven.compiler.source><maven.compiler.target>17</maven.compiler.target>
二、设计 Word 模板
poi-tl 的模板本质就是一个普通的 .docx 文件,用 {{ }} 标记占位符。直接在 Word 里敲就行。
2.1 文本占位符
最简单的情况,在 Word 里写上 {{name}}、{{address}}、{{phone}},像这样:
收件人:{{name}}联系电话:{{phone}}收货地址:{{address}}
模板渲染时,poi-tl 会把 {{name}} 替换成 张三,{{phone}} 替换成 13133131311。
2.2 图片占位符
图片占位符语法是 {{@image}},注意前面多了一个 @。有两种用法:
-
行内图片标签:直接在段落里写 {{@logo}},渲染时 poi-tl 会把它替换成图片,适合插入签名、图标等小图。 -
替换已有图片:在 Word 里先插入一张占位图片,然后右键图片 → “查看可选文字”(部分版本叫”替换文字”或”Alt Text”),写上 {{@image}}。代码渲染时 poi-tl 会自动用新图片替换这张占位图,适合需要精确控制位置和尺寸的场景。
2.3 表格循环占位符
需要生成多行数据时,用 {{#...}} 和 {{/...}} 包围循环区域。以下为概念示意,实际是在 Word 的表格里操作:
| 姓名 | 电话 | 地址 ||----------|--------------|----------------|| {{#items}} || {{name}} | {{phone}} | {{address}} || {{/items}} |
#items 表示循环开始,/items 表示循环结束。中间的行会被重复渲染,变量名对应 Java 对象的属性。
注意:变量名
name、phone、address必须和Item类的字段名一致(或者有对应的 getter 方法)。
2.4 模板文件结构
我们这次准备了三个模板文件:
|
|
|
|---|---|
input.docx |
|
input-image.docx |
|
mul-input.docx |
|
三、渲染模板
有了模板,下一步就是写代码往里填数据。按复杂度递进,覆盖三种场景。
3.1 文本渲染(V1)
最简单的情况:一个 Map 搞定。
publicclassDocxTemplateRenderer{publicstaticvoidrender(String templatePath, Map<String, Object> data, String pdfPath)throws Exception {// 1. 用 poi-tl 编译模板并填充数据 String tempDocx = pdfPath.replaceAll("\\.pdf$", "_temp.docx");try (XWPFTemplate template = XWPFTemplate.compile(templatePath) .render(data); FileOutputStream out = new FileOutputStream(tempDocx)) { template.write(out); // 写入临时 docx 文件 }// 2. docx4j 转 PDFtry { DocxToPdfConverter.convert(tempDocx, pdfPath); } finally {new File(tempDocx).delete(); // 清理临时文件 } }publicstaticvoidmain(String[] args)throws Exception { Map<String, Object> data = new HashMap<>(); data.put("address", "天津市西青区"); data.put("name", "张三"); data.put("phone", "13133131311"); render("input.docx", data, "out_pdfPath.pdf"); }}
流程拆解:
-
XWPFTemplate.compile(templatePath)— 加载模板 -
.render(data)— 把 Map 里的值填入对应的{{key}} -
template.write(out)— 写出填充后的 docx -
DocxToPdfConverter.convert()— 转 PDF(下一章细讲) -
finally里删除临时 docx
这 30 行代码就完成了”模板 + 数据 → PDF”的闭环。
3.2 图片渲染(V2)
图片渲染比文本多一步:需要把图片文件路径包装成 poi-tl 能识别的格式。
publicclassDocxTemplateRendererV2{// render() 方法同 V1,此处省略publicstatic DataBuilder builder(){returnnew DataBuilder(); }publicstaticclassDataBuilder{privatefinal Map<String, Object> data = new HashMap<>();public DataBuilder text(String key, String value){ data.put(key, value);returnthis; }public DataBuilder image(String key, String imgPath,int width, int height){ data.put(key, Pictures.ofLocal(imgPath) .size(width, height).create());returnthis; }public Map<String, Object> build(){return data; } }publicstaticvoidmain(String[] args)throws Exception { Map<String, Object> data = DocxTemplateRendererV2.builder() .text("name", "张三") .text("phone", "13133131311") .text("address", "天津市西青区") .image("image", "E:/001.jpg", 200, 80) .build(); render("input-image.docx", data, "out_image_pdf.pdf"); }}
核心是 Pictures.ofLocal(imgPath).size(width, height).create(),它生成一个图片渲染数据对象,poi-tl 自动匹配模板中的 {{@image}} 占位符。
DataBuilder 是个小优化,让传参语法更清晰,不用手动记住哪些 key 是文本、哪些是图片。
3.3 表格渲染(V3)
多行数据场景,需要一个实体类承载数据,然后用 List 批量渲染。
数据模型 Item.java:
publicclassItem{private String address;private String name;private String phone;private String image;// 构造函数 + getter/setter 省略}
渲染代码:
publicclassDocxTemplateRendererV3{publicstaticvoidrender(String templatePath, List<Item> items, String pdfPath)throws Exception { Map<String, Object> data = new HashMap<>(); data.put("items", items); // key 必须和模板中的 #items 对应// 关键步骤:修复 Word 拆分标签的问题byte[] mergedTemplate = mergeRuns(templatePath); String tempDocx = pdfPath.replaceAll("\\.pdf$", "_temp.docx");try (XWPFTemplate template = XWPFTemplate .compile(new ByteArrayInputStream(mergedTemplate)) .render(data); FileOutputStream out = new FileOutputStream(tempDocx)) { template.write(out); }try { DocxToPdfConverter.convert(tempDocx, pdfPath); } finally {new File(tempDocx).delete(); } }publicstaticvoidmain(String[] args)throws Exception { Item item1 = new Item("地址1", "张三", "123", "E:/001.jpg"); Item item2 = new Item("地址2", "李四", "123", "E:/001.jpg"); Item item3 = new Item("地址3", "王五", "123", "E:/001.jpg"); List<Item> items = List.of(item1, item2, item3); render("mul-input.docx", items, "mul-output.pdf"); }}
注意这里多了一个 mergeRuns(templatePath) 调用——这是生产环境最容易踩的坑,放到第五章细说。
四、DOCX 转 PDF
模板渲染完成后,拿到的是一个填充好数据的 .docx 文件。下一步用 docx4j 把它转成 PDF。
publicclassDocxToPdfConverter{publicstaticvoidconvert(String docxPath, String pdfPath)throws Exception {// 1. 加载 DOCX WordprocessingMLPackage pkg = WordprocessingMLPackage.load(new File(docxPath));// 2. 发现系统字体 PhysicalFonts.discoverPhysicalFonts();// 3. 构建字体映射器 Mapper fontMapper = new IdentityPlusMapper(); PhysicalFont defaultFont = findDefaultChineseFont();// 4. 映射中文字体 String[][] fontMappings = { {"SimSun", "SimSun", "宋体", "SimSun-18030", "@SimSun"}, {"SimHei", "SimHei", "黑体"}, {"Microsoft YaHei", "Microsoft YaHei", "微软雅黑"}, {"KaiTi", "KaiTi", "楷体"}, {"LiSu", "LiSu", "隶书"}, };for (String[] mapping : fontMappings) { String key = mapping[0]; PhysicalFont font = null;for (int i = 1; i < mapping.length; i++) { font = PhysicalFonts.get(mapping[i]);if (font != null) break; // 找到第一个可用的 } fontMapper.put(key, font != null ? font : defaultFont); } pkg.setFontMapper(fontMapper);// 5. 输出 PDFtry (OutputStream os = new FileOutputStream(pdfPath)) { Docx4J.toPDF(pkg, os); } }privatestatic PhysicalFont findDefaultChineseFont(){ String[] candidates = {"SimSun", "STSong", "Microsoft YaHei","SimHei", "NSimSun"};for (String name : candidates) { PhysicalFont font = PhysicalFonts.get(name);if (font != null) return font; }// 兜底:扫描所有字体,找包含中文名的for (String name : PhysicalFonts.getPhysicalFonts().keySet()) { String lower = name.toLowerCase();if (lower.contains("song") || lower.contains("sun") || lower.contains("chinese")) {return PhysicalFonts.get(name); } }returnnull; }publicstaticvoidmain(String[] args)throws Exception { convert("input.docx", "output.pdf"); }}
字体会出什么问题?
docx4j 在 Windows 上渲染 PDF 时,会按照 docx 文档里记录的字体名称去系统里找。如果你的模板用了宋体,但服务器是 Linux 没有 SimSun.ttf,就会用默认字体(通常是英文无衬线字体),中文全部变成方块或乱码。
这个类的策略是:
-
多别名映射 — 每种字体提供多个别名(比如 SimSun也可能是宋体、SimSun-18030),因为 Windows 上装的字体名可能是中文也可能是英文。 -
兜底字体 — 如果某个字体完全找不到,就用 findDefaultChineseFont()的结果代替,而不是返回 null。 -
兜底的兜底 — 扫描系统中所有已安装字体,匹配名称中包含 “song”、”sun”、”chinese” 的字体。
Linux 服务器上记得安装中文字体包:
# 安装字体管理工具yum install -y fontconfig# 拷贝中文字体文件到系统字体目录mkdir -p /usr/share/fonts/chinesecp *.ttf /usr/share/fonts/chinese/# 刷新字体缓存fc-cache -fvfc-list :lang=zh # 验证中文字体是否就绪
此外,docx4j 的 PDF 渲染底层依赖 AWT。在无图形界面的 Linux 服务器上部署时,必须加上 JVM 参数:
java -Djava.awt.headless=true -jar your-app.jar
否则会看到 java.awt.HeadlessException。
五、生产填坑实录
坑一:模板标签被 Word 拆成碎片
现象:模板在 Word 里看起来是 {{name}} 一个整体,但 poi-tl 就是匹配不到,渲染后原样输出 {{name}}。
原因:Word 的 XML 结构里,文本是由 <w:r>(run)元素组织的。如果编辑过程中改变了格式、删了字又重写、或者从别处复制粘贴,Word 可能把 {{name}} 拆成多个 run:
-
<w:r>{{na</w:r> -
<w:r>me}}</w:r>
poi-tl 按 run 粒度匹配,拆开后自然识别不了。
解法:渲染前把所有 run 的文本拼回一起。注意不仅要处理正文段落,还要遍历表格内的段落——{{#items}} 这类循环标签大多写在表格里。
privatestaticbyte[] mergeRuns(String templatePath) throws Exception {try (XWPFDocument doc = new XWPFDocument(new FileInputStream(templatePath))) {// 处理正文段落for (XWPFParagraph para : doc.getParagraphs()) { mergeParagraphRuns(para); }// 处理表格内的段落({{#items}} 标签通常写在表格中)for (XWPFTable table : doc.getTables()) {for (XWPFTableRow row : table.getRows()) {for (XWPFTableCell cell : row.getTableCells()) {for (XWPFParagraph para : cell.getParagraphs()) { mergeParagraphRuns(para); } } } } ByteArrayOutputStream baos = new ByteArrayOutputStream(); doc.write(baos);return baos.toByteArray(); }}privatestaticvoidmergeParagraphRuns(XWPFParagraph para){ List<XWPFRun> runs = para.getRuns();if (runs == null || runs.size() <= 1) return; StringBuilder merged = new StringBuilder();for (XWPFRun run : runs) { String text = run.getText(0);if (text != null) merged.append(text); } runs.get(0).setText(merged.toString(), 0);for (int i = runs.size() - 1; i > 0; i--) { para.removeRun(i); }}
然后用 ByteArrayInputStream 把修复后的字节流传给 poi-tl 编译,而不是直接用模板文件路径。
坑二:Linux 服务器中文乱码/方块
这个前面提到了,但值得单独强调。
docx4j 的 PDF 渲染依赖系统字体。Windows 开发环境一切正常,部署到 Linux 后中文全变成 □□□□——因为 Linux 默认没有 SimSun、SimHei 这些字体。
两个动作:
-
服务器安装中文字体(上一章的命令) -
代码中做好 fallback 链,不要假设某个字体一定存在
坑三:docx4j-JAXB-ReferenceImpl 缺依赖
如果你用的是 JDK 9+(含 11、17、21),启动后看到:
java.lang.NoClassDefFoundError: javax/xml/bind/JAXBException
这就是 JAXB 从 JDK 移除了。加一个依赖就解决:
<dependency><groupId>org.docx4j</groupId><artifactId>docx4j-JAXB-ReferenceImpl</artifactId><version>11.5.4</version></dependency>
写在最后
以上就是本次的经验分享
如果有说的不对的地方,还请批评指正
我们下期再见
夜雨聆风