业务系统里生成合同、报告、标书,这些活儿一旦落到开发手里,往往要从 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 模板时,有几个约定:
- 普通文本标签:
{{contractNo}} - 图片标签:
{{@logo}} - 条件区块:
{{?hasNote}}......{{/hasNote}} - 循环区块:
{{#items}}......{{/items}},表格行循环另有标签绑定方式,后面讲
占位符名称不要带空格,更不要带特殊符号。最关键的坑是:输入占位符时,千万别在 Word 里一个一个打。中文输入法、自动更正、拼写检查,都会把{{name}}拆成多个 XML Run。最好直接复制现成的纯文本粘贴。如果已经拆了,跑之前用工具把段落里的 run 合并。
先落地一个最简单的渲染接口
假设我们需要在页面上导出一份“月度合同统计报告”,包含标题、月份、金额等固定字段。接口大概是这样:
@RestController@RequestMapping("/report")publicclassReportController{privatefinalMonthlyReportServicereportService;@GetMapping("/monthly")publicvoidmonthly(@RequestParam("month")Stringmonth,HttpServletResponseresponse)throwsIOException{Map<String,Object>data=newHashMap<>();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(InputStreamis=getClass().getResourceAsStream("/templates/monthly.docx");XWPFTemplatetemplate=XWPFTemplate.compile(is).render(data)){template.write(response.getOutputStream());}}}文本插值没什么好说的,HashMap的 key 对应模板里的标签。字段如果是null,最好预处理成空字符串,省得渲染逻辑走特殊分支。
图片、超链接、日期格式化
图片占位符以@开头。模板里写{{@companyLogo}},Java 侧给个PictureRenderData最稳,可以自己控制宽高:
data.put("companyLogo",newPictureRenderData(240,80,PictureType.PNG,logoBytes));如果只是数据库里存了图片的 byte[],Poi-tl 也能直接识别,但大小不可控,强烈建议用PictureRenderData显式指定宽高。
超链接和图片不太一样,需要在Configure里绑定渲染策略:
Configureconfig=Configure.builder().bind("detailLink",newHyperLinkRenderPolicy()).build();Map<String,Object>data=newHashMap<>();data.put("detailLink",newHyperLinkTextRenderData("查看详情","https://example.com"));模板里对应的位置写{{detailLink}},最终会渲染成可点击的链接。文本和链接地址分别是HyperLinkTextRenderData的第一个和第二个参数。老实说,合同里链接这种动态字段极少,能用模板里直接插静态链接就不用折腾这段代码。
日期标签 Poi-tl 没有内置,统一在 Java 层格式化。遇到一个List<LocalDate>需要循环,就给对象加一个 String 类型的dateStr属性,别在模板里调用方法。
动态表格:核心的行循环
Word 文档里最常见的复杂结构是商品明细表。模板可以画成这样:
+------+----------+--------+--------+ | 序号 | 商品名称 | 数量 | 备注 | +------+----------+--------+--------+ | {{items}} | {{name}} | {{num}} | {{remark}} | +------+----------+--------+--------+第一行是表头,第二行是循环行。{{items}}写在循环行的任意列,我们需要在代码里绑定LoopRowTableRenderPolicy:
Configureconfig=Configure.builder().bind("items",newLoopRowTableRenderPolicy()).build();List<Goods>goodsList=buildGoodsList();Map<String,Object>data=newHashMap<>();data.put("items",goodsList);data.put("totalPrice","¥ 2,369.00");XWPFTemplate.compile(newByteArrayInputStream(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。
XWPFDocumentdocument=template.getXWPFDocument();for(XWPFHeaderheader:document.getHeaderList()){for(XWPFParagraphp:header.getParagraphs()){// 直接把 run 里的占位符替换掉,注意 run 可能被拆开for(XWPFRunrun:p.getRuns()){Stringtext=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 导出接口大家都会写,有两个小点提醒:
Content-Type别乱设,就是 Word 的 MIME 类型:application/vnd.openxmlformats-officedocument.wordprocessingml.document- 文件名如果有中文,用
URLEncoder编码,再按RFC 5987的filename*拼,直接放中文头浏览器会把它截断或变乱码。
模板编译是个耗时操作,尤其模板有好几页。不要@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.docxJava 端用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-getinstall-yfonts-noto-cjk如果有特殊字体,也得把字体文件放进去。按 POI 设置字体时,涉及中文需要用 eastAsia 字体,不能只设 ascii,否则中文还是宋体或系统默认。
小结
Poi-tl 算是把 Word 模板渲染这条路走了九成,正确打开方式是“模板先行”。先把 Word 版式交给文档人员,和数据解耦,开发只关心标签绑定。常见的普通文本、图片、表格行循环、区块循环都能通过内置 API 完成,难啃的页眉、复杂合并、动态水印再下探到 POI 操作,不要让业务代码和 XML 搞在一起。
如果你的系统里也经常要跑批生成合同、月度报告,可以试着搭一套模板渲染服务,暴露几个byte[]或下载 URL,能省不少事。