资讯动态

Spring Boot 集成 Poi-tl 实现动态 Word 文档生成:模板渲染、表格行循环与多图水印

发布时间:2026/10/16 9:04:21 来源:尧图企业网站定制
业务系统里生成合同、报告、标书这些活儿一旦落到开发手里往往要从 Word 模板开始。模板是商务同事用 Word 画好的固定样式、页眉页脚、表格边框我们要做的事情就是把运营数据库里的数据填进去按流程产出最终版 docx。最开始我用 Apache POI 写过一次。从新建 XWPFDocument 开始一个段落一个段落码样式一个表格一个表格拼单元格。写完第一版合同导出光代码就一千多行后面想改一下字体颜色都要在代码里翻半天。后来发现 Poi-tl 这个模板引擎底层还是 POI但只用管填数据。如今公司的合同导出基本都走这条路这里记录一下实战要点。为什么不直接用 POI 硬编码而是模板渲染直接拿 POI 写 Word等于在 Java 代码里重新实现一遍 Word 排版。我们要生成的合同往往几十个字段、两三张动态表格、多个合并单元格POI 的 API 对这种操作友好度很差。反而前端拿 Word 画个模板很容易把变量写成{{contractNo}}这种形式Poi-tl 就能识别并替换。这样开发只需要准备数据映射样式和布局可以完全揉在模板里以后改版式也不用动代码。对比 iText/EasyExceliText 只能处理 PDFEasyExcel 面向 Excel 的数据导入导出Word 这块还是 Poi-tl 顺手。如果下游系统需要 PDF 预览常见处理是用 LibreOffice 把生成后的 docx 转一下这个后面说。依赖和模板约定Spring Boot 3.x 里引入 poi-tl 时建议把 POI 版本也显式声明不然容易出现底层 API 冲突。我当前项目用的版本如下。dependencygroupIdcom.deepoove/groupIdartifactIdpoi-tl/artifactIdversion1.12.1/versionexclusionsexclusiongroupIdorg.apache.poi/groupIdartifactIdpoi-ooxml/artifactId/exclusion/exclusions/dependencydependencygroupIdorg.apache.poi/groupIdartifactIdpoi-ooxml/artifactIdversion5.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 合并。先落地一个最简单的渲染接口假设我们需要在页面上导出一份“月度合同统计报告”包含标题、月份、金额等固定字段。接口大概是这样RestControllerRequestMapping(/report)publicclassReportController{privatefinalMonthlyReportServicereportService;GetMapping(/monthly)publicvoidmonthly(RequestParam(month)Stringmonth,HttpServletResponseresponse)throwsIOException{MapString,ObjectdatanewHashMap();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; filenamereport.docx);try(InputStreamisgetClass().getResourceAsStream(/templates/monthly.docx);XWPFTemplatetemplateXWPFTemplate.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里绑定渲染策略ConfigureconfigConfigure.builder().bind(detailLink,newHyperLinkRenderPolicy()).build();MapString,ObjectdatanewHashMap();data.put(detailLink,newHyperLinkTextRenderData(查看详情,https://example.com));模板里对应的位置写{{detailLink}}最终会渲染成可点击的链接。文本和链接地址分别是HyperLinkTextRenderData的第一个和第二个参数。老实说合同里链接这种动态字段极少能用模板里直接插静态链接就不用折腾这段代码。日期标签 Poi-tl 没有内置统一在 Java 层格式化。遇到一个ListLocalDate需要循环就给对象加一个 String 类型的dateStr属性别在模板里调用方法。动态表格核心的行循环Word 文档里最常见的复杂结构是商品明细表。模板可以画成这样-------------------------------- | 序号 | 商品名称 | 数量 | 备注 | -------------------------------- | {{items}} | {{name}} | {{num}} | {{remark}} | --------------------------------第一行是表头第二行是循环行。{{items}}写在循环行的任意列我们需要在代码里绑定LoopRowTableRenderPolicyConfigureconfigConfigure.builder().bind(items,newLoopRowTableRenderPolicy()).build();ListGoodsgoodsListbuildGoodsList();MapString,ObjectdatanewHashMap();data.put(items,goodsList);data.put(totalPrice,¥ 2,369.00);XWPFTemplate.compile(newByteArrayInputStream(tplBytes),config).render(data).write(out);这里goodsList里的对象属性name、num、remarkPoi-tl 会直接作为该行数据的渲染上下文所以模板第二行的标签不需要带前缀。如果第一列要序号老老实实在代码里给每个对象加index模板写{{index}}。整行渲染时模板行原有的字体、边框样式会带到新行。条件区块处理“有就显示/没有就删掉”特别方便{{?special}}该合同含保密条款且不得对外披露。{{/special}}Java 侧给special放true/false为false时整段内容消失。{{#items}}循环一段普通段落类似适合项目符号列表代码不多说了。如果需要合并单元格特别是根据数据突然要把几行合并Poi-tl 自身没有模板指令需要渲染完成后用底层 POI 操作。思路是先拿XWPFDocument再针对XWPFTable里的 cell 设置 vMerge。对这种动态合并场景要额外小心如果模板里本身就有合并单元格行循环复制时行为可能不如预期建议先做原型测试。页眉、页脚、水印的处理上文说过Poi-tl 标签渲染的主体是正文。页眉页脚里的占位符能不能渲染要看具体版本我没有在里面放动态文本原因很简单页眉往往要求每页相同如果还做动态替换出了问题整篇都炸。所以对于页眉内容我是先渲染好正文再用底层 POI 改 header。XWPFDocumentdocumenttemplate.getXWPFDocument();for(XWPFHeaderheader:document.getHeaderList()){for(XWPFParagraphp:header.getParagraphs()){// 直接把 run 里的占位符替换掉注意 run 可能被拆开for(XWPFRunrun:p.getRuns()){Stringtextrun.getText(0);if(text!nulltext.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}}可能是{{name}}。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能省不少事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑