java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot Poi-tl动态Word文档生成

SpringBoot集成Poi-tl实现动态Word文档生成

作者:(farerboy)

还在用POI硬编码生成Word,试试Poi-tl模板引擎,只需在Word模板中写好占位符,Java代码填充数据即可,轻松处理动态表格、条件区块和图片,本文分享实战经验,包括依赖配置、模板约定、动态表格行循环、页眉水印处理、转PDF技巧,以及生产环境常见坑及解决方案

引言

业务系统里生成合同、报告、标书,这些活儿一旦落到开发手里,往往要从 Word 模板开始。模板是商务同事用 Word 画好的,固定样式、页眉页脚、表格边框,我们要做的事情就是把运营数据库里的数据填进去,按流程产出最终版 docx。

最开始我用 Apache POI 写过一次。从新建 XWPFDocument 开始,一个段落一个段落码样式,一个表格一个表格拼单元格。写完第一版合同导出,光代码就一千多行,后面想改一下字体颜色都要在代码里翻半天。后来发现 Poi-tl 这个模板引擎,底层还是 POI,但只用管填数据。如今公司的合同导出基本都走这条路,这里记录一下实战要点。

为什么不直接用 POI 硬编码,而是模板渲染

直接拿 POI 写 Word,等于在 Java 代码里重新实现一遍 Word 排版。我们要生成的合同往往几十个字段、两三张动态表格、多个合并单元格,POI 的 API 对这种操作友好度很差。反而前端拿 Word 画个模板很容易:把变量写成 {{contractNo}} 这种形式,Poi-tl 就能识别并替换。这样开发只需要准备数据映射,样式和布局可以完全揉在模板里,以后改版式也不用动代码。

对比 iText/EasyExcel:iText 只能处理 PDF,EasyExcel 面向 Excel 的数据导入导出,Word 这块还是 Poi-tl 顺手。如果下游系统需要 PDF 预览,常见处理是用 LibreOffice 把生成后的 docx 转一下,这个后面说。

依赖和模板约定

Spring Boot 3.x 里引入 poi-tl 时,建议把 POI 版本也显式声明,不然容易出现底层 API 冲突。我当前项目用的版本如下。

<dependency>
    <groupId>com.deepoove</groupId>
    <artifactId>poi-tl</artifactId>
    <version>1.12.1</version>
    <exclusions>
        <exclusion>
            <groupId>org.apache.poi</groupId>
            <artifactId>poi-ooxml</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.2.5</version>
</dependency>

模板文件直接放 src/main/resources/templates/ 下,最后在代码里用 getResourceAsStream 读取。注意 poi-tl 不支持老的 .doc,模板重存一份 .docx 再交给程序,别拿 .doc 折腾,里面是 OLE 格式,解析不到 XML 标签的。

写 Word 模板时,有几个约定:

占位符名称不要带空格,更不要带特殊符号。最关键的坑是:输入占位符时,千万别在 Word 里一个一个打。中文输入法、自动更正、拼写检查,都会把 {{name}} 拆成多个 XML Run。最好直接复制现成的纯文本粘贴。如果已经拆了,跑之前用工具把段落里的 run 合并。

先落地一个最简单的渲染接口

假设我们需要在页面上导出一份“月度合同统计报告”,包含标题、月份、金额等固定字段。接口大概是这样:

@RestController
@RequestMapping("/report")
public class ReportController {

    private final MonthlyReportService reportService;

    @GetMapping("/monthly")
    public void monthly(@RequestParam("month") String month,
                        HttpServletResponse response) throws IOException {
        Map<String, Object> data = new HashMap<>();
        data.put("reportName", "月度合同统计报告");
        data.put("month", month);
        data.put("count", reportService.countContracts(month));
        data.put("totalAmount", "¥ " + reportService.totalAmount(month));
        data.put("createTime", LocalDateTime.now()
                .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));

        response.setContentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
        response.setHeader("Content-Disposition", "attachment; filename=report.docx");

        try (InputStream is = getClass().getResourceAsStream("/templates/monthly.docx");
             XWPFTemplate template = XWPFTemplate.compile(is).render(data)) {
            template.write(response.getOutputStream());
        }
    }
}

文本插值没什么好说的,HashMap 的 key 对应模板里的标签。字段如果是 null,最好预处理成空字符串,省得渲染逻辑走特殊分支。

图片、超链接、日期格式化

图片占位符以 @ 开头。模板里写 {{@companyLogo}},Java 侧给个 PictureRenderData 最稳,可以自己控制宽高:

data.put("companyLogo", new PictureRenderData(240, 80, PictureType.PNG, logoBytes));

如果只是数据库里存了图片的 byte[],Poi-tl 也能直接识别,但大小不可控,强烈建议用 PictureRenderData 显式指定宽高。

超链接和图片不太一样,需要在 Configure 里绑定渲染策略:

Configure config = Configure.builder()
        .bind("detailLink", new HyperLinkRenderPolicy())
        .build();

Map<String, Object> data = new HashMap<>();
data.put("detailLink", new HyperLinkTextRenderData("查看详情", "https://example.com"));

模板里对应的位置写 {{detailLink}},最终会渲染成可点击的链接。文本和链接地址分别是 HyperLinkTextRenderData 的第一个和第二个参数。老实说,合同里链接这种动态字段极少,能用模板里直接插静态链接就不用折腾这段代码。

日期标签 Poi-tl 没有内置,统一在 Java 层格式化。遇到一个 List<LocalDate> 需要循环,就给对象加一个 String 类型的 dateStr 属性,别在模板里调用方法。

动态表格:核心的行循环

Word 文档里最常见的复杂结构是商品明细表。模板可以画成这样:

+------+----------+--------+--------+
| 序号 | 商品名称 | 数量   | 备注   |
+------+----------+--------+--------+
| {{items}} | {{name}} | {{num}} | {{remark}} |
+------+----------+--------+--------+

第一行是表头,第二行是循环行。{{items}} 写在循环行的任意列,我们需要在代码里绑定 LoopRowTableRenderPolicy:

Configure config = Configure.builder()
        .bind("items", new LoopRowTableRenderPolicy())
        .build();

List<Goods> goodsList = buildGoodsList();
Map<String, Object> data = new HashMap<>();
data.put("items", goodsList);
data.put("totalPrice", "¥ 2,369.00");

XWPFTemplate.compile(new ByteArrayInputStream(tplBytes), config)
        .render(data)
        .write(out);

这里 goodsList 里的对象属性 name、num、remark,Poi-tl 会直接作为该行数据的渲染上下文,所以模板第二行的标签不需要带前缀。如果第一列要序号,老老实实在代码里给每个对象加 index,模板写 {{index}}。整行渲染时,模板行原有的字体、边框样式会带到新行。

条件区块处理“有就显示/没有就删掉”特别方便:

{{?special}}该合同含保密条款,且不得对外披露。{{/special}}

Java 侧给 special 放 true / false,为 false 时整段内容消失。{{#items}} 循环一段普通段落类似,适合项目符号列表,代码不多说了。

如果需要合并单元格,特别是根据数据突然要把几行合并,Poi-tl 自身没有模板指令,需要渲染完成后用底层 POI 操作。思路是先拿 XWPFDocument,再针对 XWPFTable 里的 cell 设置 vMerge。对这种动态合并场景要额外小心:如果模板里本身就有合并单元格,行循环复制时行为可能不如预期,建议先做原型测试。

页眉、页脚、水印的处理

上文说过,Poi-tl 标签渲染的主体是正文。页眉页脚里的占位符能不能渲染,要看具体版本,我没有在里面放动态文本,原因很简单:页眉往往要求每页相同,如果还做动态替换,出了问题整篇都炸。所以对于页眉内容,我是先渲染好正文,再用底层 POI 改 header。

XWPFDocument document = template.getXWPFDocument();
for (XWPFHeader header : document.getHeaderList()) {
    for (XWPFParagraph p : header.getParagraphs()) {
        // 直接把 run 里的占位符替换掉,注意 run 可能被拆开
        for (XWPFRun run : p.getRuns()) {
            String text = run.getText(0);
            if (text != null && text.contains("{{contractNo}}")) {
                run.setText(text.replace("{{contractNo}}", contractNo), 0);
            }
        }
    }
}

页脚里要显示“第 X 页 / 共 Y 页”的时候,我建议在 Word 模板的页脚里直接插入 PAGE 域,不要交给代码动态生成。Poi-tl / POI 对域字段的创建,需要处理底层 XML,写起来繁琐还没必要。

水印可以直接复用页眉方案:页眉在所有页面都会出现,所以只要在页眉里插入一张半透明图片,就能达到全局水印效果。图片用 Java2D 在内存中生成,没必要把好几个 logo 一个个定位插入文档。我一般先画一张和 A4 同尺寸、透明背景的 PNG,把 logo 以 45 度角平铺上去,整体透明度调低,再插入页眉。

水印图片生成的代码要注意一点,旋转时不要干巴巴地 g.rotate(...),那样旋转点是画板左上角,角上会空白。先设置旋转中心到画布中心再说。透明度使用 AlphaComposite.SrcOver.derive(0.2f)。之前见过有人写 SrcAtop,结果画出来的图透明区域像素都没写入,水印全丢了。

插入页眉也不是很难,只需要把 PNG 丢到 XWPFHeader 的第一个段落里。如果非要做成 Word 里那种“衬于文字下方”的官方水印效果,需要设置 behindDoc 等属性,看产品要求。一般展示用,图片放在页面顶部背景已经够看。

服务端导出的一些处理

Controller 导出接口大家都会写,有两个小点提醒:

模板编译是个耗时操作,尤其模板有好几页。不要 @Component 里保存一个 XWPFTemplate 实例然后到处 render(),底层是同一个 XWPFDocument,线程安全问题早晚爆。我用的模式是启动时把模板文件读成 byte[] 放在缓存里,每次导出都 new 一个 ByteArrayInputStream 去 compile。开销比每次从磁盘读小很多,也避开了并发复用。

如果文档很大、图片多,比如标书要几百页,Poi-tl 渲染是在内存里跑 DOM,不可能像 EasyExcel 那样流式写。这时候要么硬扛加内存,要么从一开始考虑拆分成多个 Word(但合并文档样式很难),或者干脆走 PDF 流水线。说句实在话:这类 Word 自动生成通常要求行数在几十到一两百,超过 500 行还坚持用模板,项目可能要考虑方案本身合不合理。

转 PDF 在线预览我现在的做法是调用本机 LibreOffice 的无头模式:

soffice --headless --convert-to pdf --outdir /output /docs/report.docx

Java 端用 ProcessBuilder 执行,等它返回,注意设置超时。documents4j 本质也是包一层外部 Office 转换,依赖还是得装。如果没有必须转 PDF 的需求,直接让用户下载 docx 省心很多。

几个记忆犹新的生产坑

占位符被 Word 自动拆 Run
打开模板的 XML,你会看到 {{name}} 可能是 {{ + na + me}}。Poi-tl 默认是找不到的。所以上面我说占位符一定要复制进去,不要一行行敲。已经踩坑的话,自己写个工具把同一个段落的 Run 合并后再编译,具体方法网上也有。

别用 .doc 模板
Poi-tl 只支持 .docx。客户给的模板有些是老系统导出的 .doc,必须另存为 .docx,另存后还要目测一下表格和页眉有没有错位。

模板里残留修订痕迹、内容控件
合同模板经常被不同的人来回改,可能在文档里留下修订标记或者 Content Control,这些会影响 Poi-tl 解析。模板定稿前先接受所有修订,删除多余控件。

服务器缺中文字体
在本地 Windows 上生成正常,部署到 Linux 容器后转 PDF 中文全变方块。原因就是容器里面没有中文字体。装 fonts-noto-cjk 基本能解决大部分问题:

apt-get install -y fonts-noto-cjk

如果有特殊字体,也得把字体文件放进去。按 POI 设置字体时,涉及中文需要用 eastAsia 字体,不能只设 ascii,否则中文还是宋体或系统默认。

小结

Poi-tl 算是把 Word 模板渲染这条路走了九成,正确打开方式是“模板先行”。先把 Word 版式交给文档人员,和数据解耦,开发只关心标签绑定。常见的普通文本、图片、表格行循环、区块循环都能通过内置 API 完成,难啃的页眉、复杂合并、动态水印再下探到 POI 操作,不要让业务代码和 XML 搞在一起。

如果你的系统里也经常要跑批生成合同、月度报告,可以试着搭一套模板渲染服务,暴露几个 byte[] 或下载 URL,能省不少事。

以上就是SpringBoot集成Poi-tl实现动态Word文档生成的详细内容,更多关于SpringBoot Poi-tl动态Word文档生成的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文