ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot + FreeMarker 实现 Word 模板导出:告别 POI 硬编码排版

2026/9/2 4:32:49 拓冰建站 浏览量
Spring Boot + FreeMarker 实现 Word 模板导出:告别 POI 硬编码排版 简介这是一套面向Java开发者的Spring Boot FreeMarker生成Word文档的实战Demo针对动态报表、合同、通知等场景中Word输出与图片插入的痛点提供了一条从模板到成品docx的完整实现路径。压缩包内共30个文件整体仅77KB涵盖java源码、ftl模板、xml与yml配置、properties属性文件以及构建相关文件目录划分清晰可快速导入IDE学习。目前已有1634人学习/下载。资源完整演示了如何添加Apache POI依赖、编写template.ftl模板变量、通过WordService渲染HTML并借助自定义HtmlToWordConverter将HTML转换为XWPFDocument其中使用cid:image1方式将图片以Base64内联进Word是该方案的关键技巧。读者可获得可直接运行的demo、pom依赖清单、模板与工具类源码以及处理HTML转Word时图片嵌入的排错思路适合作为Spring Boot文档生成功能的起步脚手架便于按需扩展样式和模板。 如果你接到的需求是“系统里导出一份合同Word格式得像我们平时的红头文件一样”第一反应大概率是打开IDE写POI。我早年也这么干过但很快发现一个问题业务部门今天说标题居中一点明天说表格加一列后天说落款靠右每一次都要动代码重新上线。直到我换成Spring Boot FreeMarker生成Word把样式全放进模板代码只负责传数据世界才算清净。这篇文章就把这套方案的完整思路、可运行代码和踩坑记录一次讲清楚适合正在做合同、报告、审批单据导出或者被POI折磨得想换方案的同学。1. 从POI切换FreeMarker不是炫技是头痛医头1.1 用POI画表格时的状态我最早实现导出功能用的是Apache POI的XWPFDocument。说实话POI功能很强但它把“创建文档”和“排版”这两件事都塞给了代码。比如做一个简单的合同表格你要先创建XWPFTable再设置列宽、边框、对齐稍微复杂一点还要合并单元格。这些操作对应的API又长又绕写完自己都不想维护。最痛苦的是需求变化。业务人员常常说“落款的地方加一个右对齐的日期”“表头加一行单位名称”。在POI方案里这意味着我要找到创建这个元素的代码块调整参数重新编译再走一轮测试发布。一个小改动前后折腾一两个小时是常有的事。如果模板里还有图片、页眉页脚、嵌套表格那工作量直接翻倍。1.2 FreeMarker方案的工作流程后来我在一个老项目里看到别人用FreeMarker做Word导出当时觉得挺新奇看完才明白原理并不复杂Word文档支持另存为XML格式而XML本质上是带标签的纯文本。FreeMarker是模板引擎擅长处理文本模板。所以你可以先用Word或WPS把目标文档做好另存为XML然后在需要动态变化的地方替换成${}占位符再把整个XML作为FreeMarker模板交给Spring Boot项目里的FreeMarker引擎渲染最终输出的还是XML但内容已经被数据填好了用Word打开就是一份新文档。这个流程把“样式排版”和“数据填充”彻底分开。样式出了问题去改Word模板数据出了问题去看Java代码两边互不干扰。对我这种靠业务吃饭的人来说最直接的价值是下次业务方再改格式我只需要把模板文件重新发给他们改或者自己打开模板改一下保存不需要动一行代码。2. 模板文件制作Word里的占位符决定后续省不省事2.1 选择适宜的XML格式模板文件的格式是整个方案里最容易被忽略的环节。刚开始我直接用Word 2019“另存为XML”格式发现生成的XML文件很大里面堆满了命名空间和样式定义FreeMarker解析起来没问题但模板里到处都是乱七八糟的标签要找到自己输入的占位符非常痛苦。所以我的建议是优先使用WPS或Word另存为“Word 2003 XML文档*.xml”。这个名字看起来老但它生成的XML结构简单标签语义清晰和Freemarker配合几乎不需要额外处理。如果你手上只有docx不想转格式也可以把docx的后缀改成zip解压后修改document.xml再重新打包成docx但这种方式操作起来多一道压缩和解压用脚本处理还好手动维护就比较麻烦。实际项目中我统一要求模板提供方输出2003 XML省心很多。2.2 手把手给模板添加占位符模板里需要动态替换的地方直接输入${}形式就好了比如${contractNo}、${customerName}。在Word文档里输入这些字符时要注意不能出现中文全角括号也不能把$和{拆分到不同的文本块里。有时候Word会自动做拼写检查把${name}拆成多个片段保存到XML里比如w:t$/w:tw:t{name}/w:t这种拆分会直接导致FreeMarker找不到变量。遇到这种情况最简单的办法是先在文本编辑器里打开保存后的XML搜索${name}如果看到被拆开了就把拆开的标签合并到同一个w:t节点里再重新保存。另外循环和条件判断的占位符也直接写在模板里。比如动态表格需要把#list items as item放在要重复的那一行外结束符/#list放在行尾之后。这个点很多人都踩过坑后面我会单独演示。模板里还可以写FreeMarker的格式化表达式比如金额${amount?string(0.00)}这样数字就不会出现科学计数法。3. 项目中的真实代码依赖、配置和导出接口3.1 最小依赖列表在Spring Boot项目里集成FreeMarker非常快只需要一个官方starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-freemarker/artifactId /dependency这个依赖会引入FreeMarker的核心库版本由Spring Boot父工程统一管理不需要自己操心。如果你的项目没有引入spring-boot-starter-web记得把Web依赖也加上因为导出接口要用到HttpServletResponse。3.2 模板加载配置在application.yml中配置模板路径和后缀spring: freemarker: template-loader-path: classpath:/templates/ suffix: .xml charset: UTF-8这里有个小心机把默认后缀改成.xml然后模板文件直接放到src/main/resources/templates/目录下。有人说freemarker后缀应该用.ftl但对于Word模板我用.xml是为了让IDE识别成XML代码高亮和格式校验都更友好。当然你完全可以保持.ftl只是后续编辑模板时要忍受没有XML高亮的不便。3.3 写一个通用导出Service核心渲染代码其实只有几行。我习惯封装一个通用的WordService方便多个接口复用import freemarker.template.Configuration; import freemarker.template.Template; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import javax.servlet.http.HttpServletResponse; import java.io.Writer; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Map; Service public class WordService { Autowired private Configuration freemarkerConfig; public void export(String templateName, MapString, Object data, HttpServletResponse response) throws Exception { Template template freemarkerConfig.getTemplate(templateName); response.setContentType(application/msword); String fileName URLEncoder.encode((String) data.get(fileName), StandardCharsets.UTF_8.toString()); response.setHeader(Content-Disposition, attachment; filename\ fileName .doc\); Writer writer response.getWriter(); template.process(data, writer); writer.flush(); writer.close(); } }Controller就很简单了RestController public class ExportController { Autowired private WordService wordService; GetMapping(/export/contract) public void exportContract(HttpServletResponse response) throws Exception { MapString, Object data new HashMap(); data.put(fileName, 采购合同); data.put(contractNo, HT-2025-001); data.put(customerName, 某某科技有限公司); data.put(totalAmount, 128000.50); wordService.export(contract.xml, data, response); } }这里有个容易踩的点freemarkerConfig.getTemplate(templateName)如果配置了suffix: .xml那么templateName传contract就行传contract.xml也没问题FreeMarker会把后缀加上或直接找到匹配文件。但是不要在两个地方重复配置否则可能找不到模板。前端调用时最简单的方式是直接用浏览器访问这个接口或者用window.open文件就会自动下载。如果接口有权限校验可以用fetch拿到blob再做下载。4. 调试到深夜的三个坑xml转义、表格线和Spring Boot版本4.1 XML特殊字符导致文件打不开这是最阴间的坑。模板里如果有个${remark}数据库里对应的值是“研发测试”渲染出来的XML里就会出现裸的字符。Word打开这个文件时会提示XML错误文件打不开。第一次遇到时我还以为是模板标签写错了排查了半天。FreeMarker提供了一个非常实用的内置函数?xml用它转义字符即可${remark?xml}这样渲染出来的会变成amp;Word就能正常识别。我的习惯是所有动态填充的字段只要可能包含中文标点、英文符号、特殊字符统一加?xml不要心存侥幸。4.2 表格线变成单线/变细的真相有段时间用户反馈导出的表格边框线“双线变单线”有些线会消失。我对比模板文件发现模板里表格是正常的但渲染后样式被改变。排查后发现原因出在Word 2003 XML中的表格边框设置。模板里的表格如果应用了Word内置样式边框属性往往会放在样式定义里而不是每个单元格上。FreeMarker渲染时如果模板里的w:tcPr节点不包含显式边框定义某些版本的Word/WPS会回退到默认样式于是出现单线或粗细不一。解决办法是回到模板不要依赖表格样式直接对每个单元格设置“边框和底纹”也就是把边框定义写到单元格级别。虽然操作起来繁琐一点但渲染结果最稳定。如果模板已经有这个问题可以在XML里全局搜w:tcBorders没有的话手动补上边框定义。这个坑没有技术上的捷径建议在模板阶段就做细致。4.3 Spring Boot 3与HttpServletResponse的命名空间如果你用的是Spring Boot 3.x上面的代码中import javax.servlet.http.HttpServletResponse会编译失败。Spring Boot 3全面转向Jakarta EE这个类变成了jakarta.servlet.http.HttpServletResponse。把import换掉就行其他逻辑不用动。另外“springboot版本太高”也是网上常见热点。其实Spring Boot 3里配套的FreeMarker starter仍然维护得很好模板渲染本身没有不兼容。遇到问题多半是项目里手动依赖了一个老版本的FreeMarker或者模板目录没有打进jar包。检查一下target/classes/templates下有没有模板文件没有的话在pom.xml里确认resources配置把src/main/resources包含进去即可。5. 再往下走循环表格、图片和批量导出5.1 用#list生成多行表格动态表格是Word导出最常见的需求。做法是先做一个单行的表格把这一行以及内嵌的单元格都作为循环体。假设模板中有一行两个单元格分别展示商品名称和数量#list items as item w:tr w:tc w:p w:r w:t${item.productName?xml}/w:t /w:r /w:p /w:tc w:tc w:p w:r w:t${item.quantity}/w:t /w:r /w:p /w:tc /w:tr /#list关键是#list必须放在w:tr外层不能只包住单元格内容。否则循环展开后XML结构错乱表格行不会重复。如果表格需要固定行数可以用#list 1..10 as i生成空行再配合数据填充。FreeMarker的循环能力足够应付大多数业务场景。5.2 模板中嵌入图片的做法纯FreeMarker处理图片会稍微绕一点。图片在Word 2003 XML中通常以base64文本保存在w:binData节点里。我的做法是先在Word里插入任意一张占位图片另存为XML找到w:binData那一整段把里面的base64字符串替换成${imgBase64}。然后在Java代码里把真实图片转成base64import java.nio.file.Files; import java.nio.file.Paths; import java.util.Base64; byte[] bytes Files.readAllBytes(Paths.get(/path/to/signature.png)); String base64 Base64.getEncoder().encodeToString(bytes); data.put(imgBase64, base64);渲染时占位图片的base64就会被真实图片替换。要注意图片大小如果图片太大XML体积会膨胀下载速度变慢。如果是印章、签名这类小图这个思路完全够用。如果图片数量多且需要动态排版建议用FreeMarker生成不包含图片的Word再用POI的XWPFDocument打开后插入图片属于混合方案但工作量更大。5.3 批量生成时的内存与并发考虑业务上经常需要批量导出多个合同有的还要打包成zip。如果直接循环调用上面的export方法每个请求都生成一个文件流到响应里内存能抗住但并发一高容易阻塞。我常用的做法是先保存到临时目录再使用ZipOutputStream打包导出。写文件时用FileOutputStream避免一次性把几十个合同的输出塞进一个StringWriter。另外FreeMarker的Template实例是线程安全的同一个模板可以被多个线程同时渲染不需要每次都重新加载模板。但如果你在模板里用了自定义指令或共享变量要确保它们是线程安全的。如果要再扩展还可以把生成的Word文件上传到OSS返回文件URL给前端下载压力转移到存储服务上。如果客户要求最终文件是PDF可以在Word生成后用LibreOffice的命令行转换一条命令soffice --headless --convert-to pdf 合同.doc在Java里用ProcessBuilder调这个命令就能把Word批量转成PDF这也是我目前在生产环境用的方案。最后再分享一个小技巧模板文件多的时候可以用FreeMarker的#include把公共片段拆出来比如页眉、页脚、公司落款统一维护。开发时直接在浏览器访问导出接口把返回的内容保存成.xml用文本编辑器打开如果Word提示XML错误错误信息中会带具体行号定位起来比想象中快。这套方案我已经维护了好几年格式调整基本不写Java代码省下来的时间足够多做两个需求了。本文还有配套的精品资源点击获取