乐于分享
好东西不私藏

poi-tl + docx4j:从 Word 模板到 PDF,Java 实战全流程,最近做项目的经验分享!

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 对象的属性。

注意:变量名 namephoneaddress 必须和 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");    }}

流程拆解:

  1. XWPFTemplate.compile(templatePath) — 加载模板
  2. .render(data) — 把 Map 里的值填入对应的 {{key}}
  3. template.write(out) — 写出填充后的 docx
  4. DocxToPdfConverter.convert() — 转 PDF(下一章细讲)
  5. 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"20080)                .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 != nullbreak;  // 找到第一个可用的            }            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 != nullreturn 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,就会用默认字体(通常是英文无衬线字体),中文全部变成方块或乱码。

这个类的策略是:

  1. 多别名映射 — 每种字体提供多个别名(比如 SimSun 也可能是 宋体SimSun-18030),因为 Windows 上装的字体名可能是中文也可能是英文。
  2. 兜底字体 — 如果某个字体完全找不到,就用 findDefaultChineseFont() 的结果代替,而不是返回 null。
  3. 兜底的兜底 — 扫描系统中所有已安装字体,匹配名称中包含 “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() <= 1return;    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 这些字体。

两个动作

  1. 服务器安装中文字体(上一章的命令)
  2. 代码中做好 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>

写在最后

以上就是本次的经验分享

如果有说的不对的地方,还请批评指正

我们下期再见