SpringBoot+Freemarker模板生成Word:告别POI手拼报表合同

发布时间:2026/10/17 0:39:57
SpringBoot+Freemarker模板生成Word:告别POI手拼报表合同 简介面向后端Java开发者的Spring Boot整合FreeMarker生成Word文档示例工程解决在项目中按模板动态输出报告、合同等文档并将图片嵌入Word的需求。资源包共30个文件涵盖Java源码与编译后的class文件、FreeMarker的ftl模板、XML配置、properties和yml配置以及pom.xml与Maven Wrapper等构建文件整体仅77KB结构紧凑下载后可快速对照学习。核心代码包含WordService服务类、模板渲染逻辑与自定义HtmlToWordConverter转换器其中通过cid方式引用图片并以内联对象写入docx的处理步骤尤其值得参考能帮助绕过常见的图片丢失问题。模板本身采用HTML结构便于通过变量控制标题、正文内容工程还保留了target目录和已编译的class文件可对照源码查看编译结果。目前已有1638人学习或下载适合希望掌握Spring Boot、FreeMarker与Apache POI三者集成方式的开发者借助该Demo可打通从模板渲染到Word导出的完整链路并在此基础上扩展表格、样式等更复杂的文档需求。1. 报表和合同别再用 POI 手拼了SpringBootFreemarker 模板生成 Word 的正确打开方式实际业务里导出合同、验收单、巡检报告这类带固定样式的 Word 文档时很多团队第一反应是用 POI 一行一行去拼段落和表格。拼到后期你就会发现模板里的某个字号或表格宽度一改Java 代码就得跟着挪改完还得担心漏了哪个单元格。换成 Spring Boot Freemarker 生成 Word 的方案思路会彻底不一样把 Word 另存成 XML 格式把它改造成一份 ftl 模板后端只负责往模板里填数据模板归模板代码归代码。这样做的好处是模板可以独立维护表格循环、图片占位都能在模板层处理适合报表导出、自定义合同这类需求也适合被 XWPF API 折腾过的开发者快速切换思路。2. 模板设计原理与转换流程为什么 docx 要转成 XML 再改造成 FTL2.1 选 Word 2003 XML 作为中间格式的技术原因docx 文件本质上是一个 zip 压缩包里面装着多份 XML、样式表和图片资源。Freemarker 模板引擎只认纯文本如果直接拿一个 zip 包做文本替换必须先解压、再修改 document.xml、最后重新打包这个过程的复杂度并不比用 POI 写代码低。Word 2003 XML 则把整份文档平铺成一个 XML 文件段落、文字、样式、表格、页眉页脚全都按顺序落在一个文本文件里正好是 Freemarker 最喜欢的输入形状。另存成 Word 2003 XML 也有代价。文档里的宏、域代码、复杂控件在转换时会丢失或被打散所以这个方案更适合样式固定、以变量替换和循环填充为主的文档。如果你导出的文档包含大量动态书签或域建议先评估一下模板改造的边界不要硬上。另存的时候还要注意不要把格式选成单一网页 HTML。网页格式里会塞满无意义的 class 和样式层叠占位符会被拆分到好几个标签节点里后面替换时不是漏改就是错改。Word 2003 XML 的标签结构更稳定普通文字一般落在w:rw:t节点内定位和替换都很直接。2.2 转换前的文档整理占位符规范与编码检查打开 Word 另存为 XML 之前先做三件事。第一把文档内容定为终稿。别把待替换的文字直接写在正文里否则搜索替换时容易误伤正式内容。建议先用显眼的占位符文本比如【日期】、【客户名称】、【金额大写】。第二清理修订与批注。如果文档经过多人编辑先在审阅模式里接收全部修订并删除批注。带着修订痕迹导出的 XML 会多出w:ins、w:del这类节点渲染时会让段落结构多出冗余层级。第三确认编码是 UTF-8。另存为 XML 时会写入?xml version1.0 encodingUTF-8?声明后续用文本编辑器修改模板时也要保持 UTF-8否则渲染出来的中文很容易乱码。整理完文档之后建议先画一张占位符和数据字段的对应表避免模板里写的名称和数据模型里的 key 对不上。Freemarker 做变量替换时不会给拼写错误兜底一旦 key 不匹配轻则输出空串重则直接抛异常中断渲染。Word 占位符模板写成数据类型实际使用位置【日期】${reportDate}String正文首段或页眉【客户名称】${customerName}String合同首段【项目清单】${itemList}List表格数据行循环这张表也是后端和模板维护者之间的契约。后面启动渲染时只要对照这张表把数据模型填完整模板基本不用反复试错。2.3 XML 层面的 FTL 改造占位符、循环与转义规则用文本编辑器打开另存出来的 XML能看到整份文档的标签结构。下面是一段典型的段落文本w:p w:r w:t报告日期/w:t /w:r w:r w:t2024年01月01日/w:t /w:r /w:p改成 Freemarker 模板之后直接把日期文字替换成变量w:p w:rw:t报告日期/w:t/w:r w:rw:t${reportDate}/w:t/w:r /w:p这里有一条关键约束${}必须落在完整的w:rw:t结构里。如果你把占位符贴到w:r标签中间办公软件打开时会出现文本断行甚至内容丢失。如果数据里可能带着、这类特殊字符模板里要写成${customerName?xml}让 FreeMarker 在输出前做 XML 转义否则生成的文件会被判定为损坏。表格循环是另一个重点。Word 2003 XML 中的表格由w:tbl包裹行是w:tr单元格是w:tc。先做出表头再在数据行外层套#listw: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:tcw:pw:rw:t巡检结果/w:t/w:r/w:p/w:tc /w:tr #list itemList as item w:tr w:tcw:pw:rw:t${item.seq}/w:t/w:r/w:p/w:tc w:tcw:pw:rw:t${item.deviceName}/w:t/w:r/w:p/w:tc w:tcw:pw:rw:t${item.status}/w:t/w:r/w:p/w:tc /w:tr /#list /w:tbl注意/#list闭合标签必须放在整行w:tr之后不能只包住某个单元格。凡是循环后出现表格错位的情况大概率是闭合标签位置放错了。模板里也可以嵌套#if做条件显示但一个#if最好只管一个单元格内容不要跨单元格写判断。提示改造 XML 时建议保留一份原始 XML 备份。如果模板改坏拿原始文件重新替换比在几千行标签里逐个排查要省事得多。模板文件命名成 report.ftl放到 src/main/resources/templates 目录后面 Spring Boot 配置会直接消费这个目录。3. Spring Boot 集成实现从依赖配置到渲染下载的完整链路3.1 Maven 依赖与 Freemarker 配置使用 Spring Boot 时不需要手工引入 Freemarker 核心包直接加官方 starter 就够了。pom.xml 里加入两个依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-freemarker/artifactId /dependency /dependenciesspring-boot-starter-freemarker已经把 FreeMarker 引擎和 Spring Boot 的自动配置打包好了。版本统一由父工程管理不需要写具体 version避免升级时连带改模板语法。接着在 application.yml 里配置模板加载路径和字符集spring: freemarker: template-loader-path: classpath:/templates suffix: .ftl charset: UTF-8 cache: false settings: classic_compatible: true逐个说明参数的作用template-loader-path模板根目录对应 src/main/resources/templates。suffix模板文件后缀调用 getTemplate(report.ftl) 时要写全文件名。charset模板读取编码必须和文件实际保存编码一致。cache开发阶段设 false避免每次改模板都要重启。生产环境记得改回 true。classic_compatible设为 true 后模板里漏传的变量不会直接抛异常而是输出空字符串。这个开关救过不少只改了模板字段却忘了同步模型的现场。3.2 数据模型组装用 Map 而不是 POJOFreemarker 渲染的本质是拿数据模型和模板变量名做绑定数据模型可以是任意 Java 对象。这里更推荐嵌套 MapString, Object因为模板版本更新频繁时改模板不用改 Java 类模型可以按接口返回结构灵活组装。MapString, Object model new HashMap(); model.put(reportDate, 2024-12-18); model.put(customerName, 某公司); ListMapString, Object itemList new ArrayList(); for (int i 1; i 5; i) { MapString, Object item new HashMap(); item.put(seq, i); item.put(deviceName, 巡检设备- i); item.put(status, i % 2 0 ? 正常 : 待复检); itemList.add(item); } model.put(itemList, itemList);三处对应关系要特别注意变量 reportDate、customerName、itemList 必须和 FTL 模板里的 key 完全一致itemList 里的每一项都是一个 Map模板循环里用 item.seq 取下层字段循环变量名可以随便起但优先叫 item读模板的人一眼能看出意图。这段代码里故意不用实体类。常见误用是把数据库实体整个丢进 model模板里直接绑实体字段实体属性一改模板输出就静默少值排查起来效率很低。3.3 渲染方法封装一次完整的渲染与下载链路有了模板和 model写一个公用的导出服务Service public class WordExportService { private final freemarker.template.Configuration freemarkerConfig; public WordExportService(freemarker.template.Configuration freemarkerConfig) { this.freemarkerConfig freemarkerConfig; } public void renderWord(String templateName, MapString, Object model, HttpServletResponse response) throws Exception { Template template freemarkerConfig.getTemplate(templateName); response.reset(); response.setContentType(application/msword); response.setCharacterEncoding(UTF-8); String fileName URLEncoder.encode(巡检报告_ LocalDate.now(), UTF-8); response.setHeader(Content-Disposition, attachment; filename\ fileName .doc\); try (Writer writer new OutputStreamWriter( response.getOutputStream(), StandardCharsets.UTF_8)) { template.process(model, writer); writer.flush(); } } }controller 调用示例RestController RequestMapping(/export) public class ExportController { private final WordExportService exportService; public ExportController(WordExportService exportService) { this.exportService exportService; } GetMapping(/report) public void downloadReport(HttpServletResponse response) throws Exception { MapString, Object model new HashMap(); model.put(reportDate, LocalDate.now().toString()); exportService.renderWord(report.ftl, model, response); } }三个细节要特别留意下载文件名放在 Content-Disposition 头里时中文要先执行 URLEncoder.encode否则浏览器端会得到乱码文件名。Content-Type 用 application/msword。文件实际内容是 XML但这个类型会让浏览器弹出下载框并由办公软件关联打开。渲染方法直接抛出 Exception。模板加载失败或变量不匹配时调用方能尽快看到失败原因而不是在 service 里吞掉异常后返回一个空文件。渲染成功后可以用文本编辑器打开下载到的 doc 文件确认第一行还是 XML 声明。这个习惯能挡掉很多“生成成功但打不开”的问题。4. 模板渲染常见问题排查表格、图片、编码与模板缓存四个坑4.1 表格行循环后只剩表头数据行消失或错位现象模板里写了#list运行后下载的文件用办公软件打开表格只有表头一行下面数据全没了有些场景还会弹窗提示文件损坏需要恢复。原因最常见的是没有把/#list闭合标签放在整行w:tr之后。Freemarker 渲染时只处理被#list和/#list夹住的片段如果这段范围不完整生成的 XML 就是一个残缺的w:tbl结构。另一个隐蔽原因是从 Word 复制的行内容在 XML 里夹着多余的空文本节点这些节点被 Freemarker 输出成空白行破坏了表格行的连续性。解决回到原始 XML 模板从w:tr开始选中一整行先复制到记事本确认w:tr和/w:tr闭合完整再套进#list。如果发现行内夹着类似w:r/w:r的空段落可以整体删掉。循环体外不要放任何多余的注释或文本标记避免文本节点插到表格结构里。4.2 图片替换成红叉或直接空白现象模板里放好的静态图片能正常导出一旦想用代码动态替换图片比如合同里盖电子章渲染结果却是红叉或整块空白。原因Word 2003 XML 中的图片不是简单的一张图片文件二进制数据通常放在w:binData标签里再通过v:imagedata r:idrIdX关联。直接替换文本节点时如果只改了 rId 引用却没有替换w:binData里的内容渲染后图片数据还是旧图。解决在模板里把图片对应的w:binData内容改成${imageBase64}渲染前把图片文件读成 base64 字符串放进 modelprivate String toBase64(byte[] imageBytes) { return Base64.getEncoder().encodeToString(imageBytes); }base64 字符串很长模板里这一行不要加入人工换行。渲染后如果有空白先确认传入的 base64 是否完整再确认v:imagedata的 rId 值和对应的 binData 名称是否一致。4.3 中文乱码与 XML 特殊字符破坏文档结构现象模板和代码都标了 UTF-8运行后中文变成一串问号有些模板能渲染成功但数据里带“设备AB”这种特殊字符时渲染直接中断。原因中文乱码多半是模板文件本身不是 UTF-8 编码。用系统记事本另存 XML 时默认编码可能是 ANSISpring Boot 读取模板时按 UTF-8 解码中文自然错乱。特殊字符问题是因为 XML 文本节点中出现了未转义的或直接把文档结构打断了。解决打开模板文件后先确认右下角编码是 UTF-8不带 BOM 更好。如果模板要输出用户填写的文本使用?xml内建函数强制转义w:t${customerName?xml}/w:t?xml会把、、等字符转成 XML 实体。另外循环里做#if item.status 正常这种判断时尽量把判断结果拆成独立的布尔字段避免类型不匹配带来的模板异常。4.4 改了模板不生效或启动时报找不到模板现象本地改了 report.ftl刷新页面导出内容还是旧版本。另一些情况是启动后调用导出接口控制台提示找不到 report.ftl。原因不生效是模板缓存问题。Spring Boot 默认会缓存模板改文件后不重启应用template 对象还是旧内容。找不到模板则是目录不对模板没有随构建复制到 classpath或者 template-loader-path 配置与实际目录不一致。解决开发阶段在 application.yml 里把 spring.freemarker.cache 设为 false再不行就把spring.freemarker.settings.template_update_delay0一起设上。项目日志里显示的模板路径如果指向 target/classes核对一下实际文件是否在模板目录下。执行mvn clean compile重新构建能同步大部分资源文件不一致问题。提示template_update_delay0 只建议在开发环境使用上生产前必须把 cache 恢复为 true否则每次请求都会重新读磁盘并发高了响应时间会明显变差。5. 验证与进阶用法给导出的文档加一道防御性自检在把导出功能交给测试之前我会做三个动作。第一用文本编辑器打开下载回来的 doc 文件确认第一行是?xml version1.0 encodingUTF-8?只要看到这一行就说明渲染没有把错误堆栈塞进文件。第二把同一个文件拷贝到办公软件里打开看表格、图片、页边距是否都保留。第三在模板里临时改一个标题文本重新导出比较两次文件大小和内容差异。进一步做文件存档时可以把渲染结果从 response 里拿出来存成字节数组再转存到文件服务器或作为邮件附件ByteArrayOutputStream bos new ByteArrayOutputStream(); try (Writer writer new OutputStreamWriter(bos, StandardCharsets.UTF_8)) { template.process(model, writer); } byte[] docBytes bos.toByteArray(); // 这里接文件上传或附件逻辑这段代码就是把第 3 章里的渲染逻辑从响应输出挪到内存输出改动很小。存下来的 byte[] 可以继续写本地文件、上传对象存储或者打包发送。这里有一个隐藏参数模板内嵌图片越大整份文档体积越大生成速度也会被明显拖慢。建议导出前把图片尺寸压缩到合理范围实测对接口响应时间影响很大。从那以后我每次提交 Word 导出功能都强制走一遍“文本编辑器验证 XML 头、办公软件打开、改模板再对比导出”的流程三个动作全过才敢发测试。宁可多花两分钟做检查也不想等测试同事拿着旧模板反问为什么内容没更新。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询