ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Freemarker生成Word:模板制作、表格循环与常见坑

SpringBoot+Freemarker生成Word:模板制作、表格循环与常见坑 简介这是一份基于Spring Boot与FreeMarker的示例项目面向需要动态生成Word文档的Java后端开发者重点解决模板渲染、变量填充以及文档内嵌图片等常见问题。项目以Maven工程形式提供涉及FreeMarker模板语法、Apache POI的XWPFDocument处理以及HTML到Word的转换思路。资源包为zip格式约77KB共30个文件涵盖Java源码、FreeMarker模板ftl、Maven构建配置、应用配置文件yml/properties及少量XML/JSON资源结构清晰便于直接对照学习或移植到业务系统中。目前已有1634人学习下载。项目内含完整的依赖配置与调用示例演示了如何通过Apache POI将渲染后的HTML转换为Word文档并以CID方式在正文中插入图片。读者可以基于这套demo快速搭建自己的文档生成模块进一步扩展适用于报告、合同、通知单等动态文档场景。 最近一个做合同管理系统的朋友跟我吐槽说项目里要按固定模板导出 Word 合同最开始直接用 POI 在代码里一个段落一个表格地拼结果业务隔三差五改样式每次改版式都要跟着调小半天。我直接跟他说这种固定模板、动态数据的导出需求用 SpringBoot Freemarker 生成 Word 是最省事的方案模板交给 Word 去排版后端只负责灌数据。我这两年用这套做法做了合同导出、检测报告、批量生成工牌好几个功能今天把完整思路、核心实现和踩过的坑一次性讲清楚给准备做类似功能的朋友一个可以直接参考的方案。这套方案的本质是把 Word 文档转成 XML 模板再用 Freemarker 的语法在模板里挖坑后端准备好数据渲染完直接吐一个 Word 文件给前端下载。适合哪些场景呢合同、报价单、成绩单、报告、证书这类格式固定、内容可变、需要用户自己拿 Word 再编辑的文档用它准没错。如果你也在用 SpringBoot 做 OA、CMS、合同管理系统这篇文章可以直接抄作业。1. 先说结论Freemarker 生成 Word 的选型与原理1.1 三种常见实现方案怎么选我刚开始做导出功能的时候也纠结过方案选型市面上主流的有三条路方案原理优点缺点Apache POI 直接操作用代码逐行创建段落、表格、样式灵活可控能精确操作每个元素代码量大模板一旦变动就得改代码Freemarker Word XML 模板Word 转 XML 后占位由 Freemarker 填充模板和代码解耦业务改样式只改文件模板制作有门槛XML 结构容易出错poi-tl / docx4j 等库模板中写占位标签库负责替换上手快封装好复杂表格循环支持有限受制于库的能力边界如果你要做的是一份大量固定格式、数据来自数据库的文档POI 方案基本是维护灾难。poi-tl 这类库解决占位符替换没问题但遇到嵌套表格、多行动态明细、图片批量插入还是要回到 XML 结构去处理。Freemarker Word XML 模板的优势在于Word 本身是你最强的“模板编辑器”你在 Word 里排好样式转成 XML 后把该换的地方换成 Freemarker 语法后端一次渲染完事。我最终选了它而且跑得很稳。1.2 Word 文档的本质docx 就是 ZIP 包要知道怎么用 Freemarker 生成 Word得先看一眼 Word 文件的本质。.docx 文件其实是一个 ZIP 压缩包里面装着各种 XML 文件其中最核心的是word/document.xml正文内容、表格、段落都在里面。传统 .docWord 97-2003格式虽然不是一个 ZIP 包但 Word 提供了一个叫“Word 2003 XML 文档”XML Document的另存格式本质是单个 XML 文件Word 可以直接打开。而 Freemarker 最擅长干的事情就是渲染文本模板两者一拍即合。你只需要做一件事把 Word 文档另存为 XML 文档然后在 XML 上把需要动态替换的内容改成${变量}、#list这样的 Freemarker 标签保存为.ftl文件放到项目模板目录里。后端启动时用一个FreeMarkerConfigurer加载模板传入数据 Map渲染输出再设置响应头让浏览器下载。整个过程不需要安装 Office服务器上不需要任何 Word 环境纯 Java 就能跑。2. 环境准备Spring Boot 版本与依赖坑2.1 依赖引入与版本对应关系项目基于 Spring Boot需要引入 Freemarker 的 Starter。我最初做的时候用的 Spring Boot 2.7后来新项目升级到了 Spring Boot 3.2依赖写法有点差别这里分别列一下。Spring Boot 2.x 的 pom 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-freemarker/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencySpring Boot 3.x 下依赖坐标不变但需要注意javax.servlet已经被替换成了jakarta.servlet。如果项目里用了老的第三方库或自定义 Filter会出现ClassNotFoundException: javax.servlet.Filter之类的问题。遇到这种报错优先排查是不是有旧依赖把javax.servlet-api带进来了用mvn dependency:tree看一下依赖树。Freemarker 本身不需要手动指定版本Spring Boot 的 Starter 会统一管理。但如果你在 Spring Boot 2.x 中没加版本号、只引入org.freemarker:freemarker裸依赖容易踩到版本冲突我之前就遇到过模板渲染时方法找不到的诡异问题后来全部改为使用spring-boot-starter-freemarker让 Boot 统一管理版本问题就消失了。2.2 高版本 Spring Boot 的常见坑热词里看到“springboot版本太高”我太有体会了。Spring Boot 3.x 基于 Jakarta EE 9很多老教程里的代码直接抄会翻车。就 Freemarker 生成 Word 这个场景来说最可能遇到的三个问题javax.servlet相关类找不到。Spring Boot 3.x 里必须引入jakarta.servlet-api代码里改成import jakarta.servlet.http.HttpServletResponse。Freemarker 模板路径配置差异。高版本下如果自定义FreeMarkerConfigurer建议显式设置setTemplateLoaderPath(classpath:/templates/)避免路径默认值不符合预期。依赖冲突导致的渲染异常。排查时可以在 pom 里加上spring-boot-starter-freemarker并排除掉可能冲突的org.freemarker:freemarker旧版本。我的建议是能用 Spring Boot 管理的版本就不要手动指定遇到高版本报错先看异常栈里的包名前缀是javax还是jakarta这能快速定位是不是版本换代的问题。3. 模板制作别用代码硬写 Word先在 Word 里做模板3.1 模板制作五步走这一步是整个方案的核心也是很多新手卡住的地方。做模板的正确顺序是用 Word 新建一个文档按照最终输出的格式排版好包括标题、段落、表格、页眉页脚。在需要动态填充的位置先随便写一点占位文字比如用张三、10086这种容易搜索的内容。点击文件 - 另存为文件类型选择“Word 2003 XML 文档*.xml”。用编辑器推荐 VS Code 或 Notepad打开这个 XML 文件找到占位文字替换成 Freemarker 语法。将文件后缀从.xml改成.ftl放到项目的src/main/resources/templates目录下。为什么用 Word 2003 XML 而不是直接编辑 docx 内部的 document.xml因为 docx 里的 document.xml 是压缩包内的文件每次修改都要解压再打包极其不方便。Word 2003 XML 是单个平面文件直接可以编辑Word 也原生支持打开兼容性经过了十几年的验证。生成的最终文件后缀如果是.docWord 能正常打开。3.2 XML 模板里怎么写变量和表格循环打开 XML 文件后你会看到大量类似w:p、w:r、w:t这样的标签它们是 WordprocessingML 命名空间下的元素。新手看到这一堆标签不用慌你不需要理解每一个标签的含义只需要精确找到自己写的占位文字所在位置替换即可。变量替换示例。假设你在 Word 里写了甲方公司名称XX科技有限公司在 XML 里找到这一行对应的文本把公司名称替换成w:p w:r w:t甲方公司名称${companyName}/w:t /w:r /w:p表格循环是 Freemarker 方案的杀手锏。先在 Word 里插入一个两列的表格表头写好“姓名”、“部门”下面留一行空白数据行。另存为 XML 后找到表格区域能看到w:tbl、w:tr、w:tc的嵌套结构。我们只需要把数据行那一整段w:tr.../w:tr用#list包起来w:tbl w:tr w:tcw:pw:rw:t姓名/w:t/w:r/w:p/w:tc w:tcw:pw:rw:t部门/w:t/w:r/w:p/w:tc /w:tr #list employees as emp w:tr w:tcw:pw:rw:t${emp.name}/w:t/w:r/w:p/w:tc w:tcw:pw:rw:t${emp.dept}/w:t/w:r/w:p/w:tc /w:tr /#list /w:tbl这样后端只要传一个employees列表表格行数就会自动扩展。我做过一个一次生成上千行明细报告的案例渲染速度几乎感觉不到延迟。3.3 模板制作中的三个红线模板制作有几个坑踩一次保证让你印象深刻。第一XML 文件第一行?xml version1.0 encodingUTF-8?不要动不要把 UTF-8 改成 UTF-8 BOM。BOM 会导致 Freemarker 渲染后输出文件开头多一个不可见字符生成的 Word 文件会提示“文件损坏”。第二数据中的特殊字符必须转义。如果公司名称里包含、、这些字符直接渲染会导致 XML 结构被破坏。解决办法是在模板变量处使用?html或?xml过滤器${companyName?xml}。我习惯在 Service 层统一做转义或者在模板里统一加过滤器二选一即可。第三不要手动大改 XML 结构。你可以在 Word 里用 UI 调整样式不要试图直接删掉某个不认识的属性很多属性是 Word 正常打开必需的。真要改回到 Word 里改完再重新另存为 XML。4. 后端实现从配置类到下载接口4.1 FreeMarker 配置类与工具类后端代码其实没有太多花样核心就是三步配置模板加载器准备数据模型渲染输出。先看配置类Configuration public class FreeMarkerConfig { Bean public FreeMarkerConfigurer freeMarkerConfigurer() { FreeMarkerConfigurer configurer new FreeMarkerConfigurer(); configurer.setTemplateLoaderPath(classpath:/templates/); configurer.setDefaultEncoding(UTF-8); return configurer; } }然后写一个工具类封装 Word 生成的公共逻辑public class WordUtil { public static void generateWord(HttpServletResponse response, String templateName, String fileName, MapString, Object dataMap) throws Exception { Configuration configuration SpringContextHolder.getBean(FreeMarkerConfigurer.class) .getConfiguration(); Template template configuration.getTemplate(templateName); response.setContentType(application/msword); response.setCharacterEncoding(UTF-8); String encodedFileName URLEncoder.encode(fileName, UTF-8).replace(, %20); response.setHeader(Content-Disposition, attachment; filename\ encodedFileName \); template.process(dataMap, response.getWriter()); } }4.2 Controller 接口与数据模型Controller 层就很简单了接收请求参数把数据塞进 Map调用工具类。我用一个批量生成工牌的例子来说接口接收员工 ID 列表查出员工信息后循环填充PostMapping(/export/employee-card) public void exportEmployeeCard(RequestBody ListLong empIds, HttpServletResponse response) throws Exception { ListEmployee employees employeeService.listByIds(empIds); MapString, Object dataMap new HashMap(); dataMap.put(employees, employees); dataMap.put(generateTime, LocalDate.now().toString()); WordUtil.generateWord(response, employee_card.ftl, 员工工牌.doc, dataMap); }数据模型的设计有个经验模板里用到的每一个变量都要在数据 Map 里有对应的值哪怕为空也要给一个空字符串。Freemarker 默认对空值会直接报错虽然可以在模板里用!规避但我在踩过几次坑之后养成了在 Service 层把可能为空的字段统一设置默认值的习惯。4.3 下载文件名编码细节中文文件名下载乱码这个问题几乎每个做导入导出功能的人都会遇到。上面代码里我用的是URLEncoder.encode(fileName, UTF-8).replace(, %20)这是兼容 Chrome、Firefox、Edge 的写法。如果只设置response.setHeader(Content-Disposition, attachment; filename fileName)中文文件名大概率下载后变成一堆乱码。还有一个细节response.getWriter()拿到的是字符流Freemarker 模板渲染结果本身就是字符串所以直接用 Writer 输出没问题。如果你用getOutputStream()字节流反而会因为编码转换引入乱码风险。5. 进阶处理表格样式、图片和转 PDF5.1 表格双线变单线与单元格宽度问题热词里有“word表格双线变单线”这个问题我在做表格式模板时也遇到过。现象是渲染出来的 Word 表格边框线变成了两条线看起来特别丑。根本原因在于 XML 模板中表格的边框定义重复了常见场景是模板里既定义了w:tblBorders整个表格的外边框又在w:tcBorders单元格边框里设置了边框两套定义叠加显示就会出现“双线”。解决办法优先统一在表格级别设置边框删除单元格级别的w:tcBorders定义。如果你已经生成了文件可以在 Word 里全选表格把边框重置为无再设置一次单一线条边框重新另存为 XML 模板。另一个高频问题是单元格宽度设置不生效。在 WordprocessingML 中表格列宽由两部分决定w:gridCol定义了表格网格的列宽每个w:tc里的w:tcW定义了该单元格的宽度。只改 gridCol 不改 tcW或者反过来都会导致列宽显示不对。正确做法是两处同时调整且单位要一致DXA 单位1 厘米约等于 567 twips。如果项目里用 POI 动态调宽度对应 API 是XWPFTableCell.setWidth(String width)和CTTblWidth但用 Freemarker 方案时我更推荐直接在 XML 模板里写死列宽因为宽度本身就是模板样式的一部分。5.2 图片怎么动态替换Freemarker 直接生成 Word 时图片是最麻烦的部分。Word 中的图片不是以文本形式存在 XML 里的而是通过w:drawing或w:pict标签引用一张独立的图片资源。我目前的做法是在 Word 模板中插入一张占位图片转成 XML 后在后端用 POI 打开生成的临时文件按rId找到图片位置替换图片字节流。大致思路是String templatePath renderTemplateAndReturnTempFile(ftlName, dataMap); try (XWPFDocument doc new XWPFDocument(new FileInputStream(templatePath))) { ListXWPFPictureData pictures doc.getAllPictures(); for (XWPFPictureData picture : pictures) { // 按文件扩展名或 rId 匹配占位图 byte[] newBytes imageService.loadImage(dataMap.get(picKey)); picture.setData(newBytes); } doc.write(new FileOutputStream(outputFile)); }这个方案能稳定工作但需要服务器上有临时目录来存放中间文件。还有一种思路是直接把图片转成 Base64 内嵌到 XML 里但 Word 对 Base64 图片的兼容性很差实测下来还是 POI 替换字节流最稳。5.3 Word 转 PDF 的配套需求很多导出场景最后一步不是下载 Word而是转成 PDF 展示或归档。我看到热词里也有“java word转pdf”的需求。后端做 Word 转 PDF最可靠的方案是调用 LibreOffice 的无头模式soffice --headless --convert-to pdf --outdir /output /tmp/input.docJava 里可以用ProcessBuilder调用这个命令。如果你的部署环境是 Docker需要把 LibreOffice 装进镜像里启动时会稍重一些但转换质量远比纯 Java 方案好。不要迷信那些纯 Java 转 PDF 的开源库对复杂模板的支持度普遍让人失望。6. 常见问题排查实录把我在实战中遇到的高频问题整理成一张表方便你快速对照排查。问题原因解决方案生成的 Word 打开提示“文件损坏”XML 声明被破坏、UTF-8 BOM、模板标签未闭合检查模板第一行声明用编辑器查看十六进制确认没有 BOM模板变量没替换直接显示${name}模板后缀不是 .ftl或模板加载路径不对确认模板目录为 classpath:/templates/文件后缀为 .ftl下载文件名乱码Content-Disposition 未做 URL 编码用 URLEncoder.encode 处理后替换 为 %20渲染报错undefined or null数据 Map 中缺少模板中的字段Service 层统一给空字段设置默认值表格出现双线tblBorders 和 tcBorders 重复定义统一在表格层设置边框删除单元格层边框表格列宽不生效gridCol 和 tcW 设置不一致或单位不对两处同步调整注意 DXA 单位换算SpringBoot 3 下报 ClassNotFoundExceptionjavax 与 jakarta 包名变更代码中 import jakarta.servlet.*模板中超过 100 个变量渲染很慢模板过大或数据量过多拆分成多个小模板分批渲染排查经验上我一般先看模板文件能不能单独用 Freemarker 渲染出 XML 文本把渲染结果保存为.xml文件用浏览器打开看 XML 结构是否完整。这一步能同时排查模板语法错误和数据问题。如果 XML 结构完整但 Word 打不开再考虑是不是编码和 BOM 的问题。最后分享一点我的实际体会用 Freemarker 生成 Word 这套方案我做了几年最大的体会是把模板的制作权利交还给业务人员。业务改样式只需要在 Word 里改好发给我我转成 XML 替换变量就上线了如果我在代码里用 POI 写死格式每一次改版都是一次上线风险。模板文件的版本管理很重要。我会给每个模板加上版本号后缀比如contract_v2.ftl改模板前先在 Git 里留个 tag方便出问题时回滚。另外有一点要提醒模板里的 XML 结构极其冗长不要在编辑器里手动改太多东西。我见过有的同事把 Word 2003 XML 里的w:spacing、w:ind之类的属性删掉结果 Word 打开是能打开但段落间距全乱了。要改样式就回 Word 改XML 里只做变量占位这是最稳的姿势。最后再分享一个小技巧模板做好后在 Freemarker 渲染前先写一个单元测试用固定数据渲染一次把生成的 XML 保存到target/test-output目录再用 Word 打开检查。这样每次改模板都能快速验证不用反复启动整个 SpringBoot 应用去打接口测试省的时间真的很可观。本文还有配套的精品资源点击获取
返回列表