最近在做合同单据自动生成的时候,我一度被“Word 怎么转成 FTL 模板”这件事卡了两天。搜出来的结果非常一致:先把 Word 另存为 Word 2003 XML,然后把 XML 后缀改成 ftl,接着在 Java 里用 FreeMarker 填充数据。我对这个结论的第一反应是抗拒,都到 2025 年了,为什么还要用 Office 2003 年留下的老旧格式?等我真的把 docx、doc、Word 2003 XML 三种格式全部手工拆开看了一遍,才明白网上那些教程并不是老古董,它们只是选了一条最省事、最少踩坑的路。
这篇东西就给所有正在被 Word 转 FTL 折磨的人一个完整解释:Word 2003 XML 到底解决了什么问题,完整的操作步骤是什么,以及教程里很少提到的那些坑——占位符被 Word 拆散、特殊字符把 XML 搞坏、表格循环怎么写。我的目标是让看完的人能直接在自己的项目里复现,而不是只得到一句“用 2003 XML 就行”。
1. 先解掉一个常见的概念混淆:FTL 不是 Word 格式
1.1 你真正想做的事情
先说结论:Word 转 FTL,本质不是“格式转换”,而是“把一份排版好的 Word 文档,加工成一个可以重复填数据的模板文件”。
FTL 是 FreeMarker 模板文件的后缀,它本身只是一段带占位符的文本。FreeMarker 在渲染时读取这份文本,把里面的 ${变量} 替换成真实数据,把 [#list] 这样的指令展开成重复内容,最后输出一个完整的、不再包含任何模板指令的结果。
问题是 Word 文档并不是文本。doc 是二进制格式,不能直接打开编辑它的内容结构;docx 虽然内部是 XML,却被 zip 压缩成了一个包,你要改里面的内容必须先解压、再修改、最后重新打包;而 Word 2003 XML 是一个没有压缩、没有二次包装的纯 XML 文件,既能被 Word 打开编辑,又能直接用普通文本编辑器处理。
所以“Word 转 FTL”实际上是一条两步链路:
text复制Word 原文件 -> 可编辑的中间 XML -> 加入占位符和 FreeMarker 指令 -> 得到 .ftl 模板
1.2 “转 FTL”不是一次另存为,而是一次模板化加工
很多人一开始会误以为“转 FTL”就是在 Word 里点击“另存为”,然后选一个 FTL 格式。这个操作不存在,Word 并不认识 FTL。
你真正要做的是:
- 把 Word 文档里的固定内容保留;
- 把需要动态变化的位置替换成
${字段名}; - 把需要重复的段落或表格行用
[#list]包起来; - 把需要条件展示的内容用
[#if]包起来; - 将整个文档另存为机器能识别的 XML 文本;
- 再将扩展名改成
.ftl交给 FreeMarker 使用。
如果你做的事只是“给一个现成的 Word 文档替换几个名字”,完全没必要上 FTL。直接用 Apache POI 操作 docx 就行。只有当文档结构复杂、需要循环表格、需要根据不同数据决定是否显示某一段、需要大批量生成几百份不同内容文件时,FTL 模板方案才真正体现出优势。
这也解释了为什么“Word 转 FTL”这个搜索词会被大家频繁搜——它不是某一个标准功能,而是一套由 FreeMarker 项目衍生出来的民间实践。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么教程钟爱 Word 2003 XML:两个格式之间的差距不在“新旧”
2.1 DOCX 是压缩包,2003 XML 是一份纯文本
如果只是“Word 能不能转 FTL”,理论上用 docx 也可以。docx 本质是一个 zip 压缩包,里面的核心内容是 word/document.xml。你完全可以把 document.xml 解压出来,加上 ${占位符},再压缩回去。但为什么 90% 的教程不这么做?
因为 docx 的包结构太复杂了。一个最简 docx 解压出来至少有这些文件:
text复制[Content_Types].xml
_rels/.rels
word/document.xml
word/styles.xml
word/settings.xml
docProps/core.xml
docProps/app.xml
真正要填充的内容藏在 word/document.xml 里,而这个文件本身又带着一长串命名空间属性:
xml复制<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing">
你在这个文件里做文本替换不是不行,但你要非常小心:
- 替换的内容如果涉及 XML 特殊字符,一不小心就破坏了 document.xml 的合法性;
- 模板里的指令只要放错位置,重新打包后 Word 就会提示文件损坏;
- 因为 docx 内部有
[Content_Types].xml和.rels,你不能只替换 document.xml,还要保持包内关系完整; - docx 里的文字经常被 split 成多个 run,格式一变,
${name}可能被拆成${和name}两段,程序替换什么都匹配不到。
Word 2003 XML 则完全没有这些问题。它的后缀虽然是 .xml,但整个文件就是一个扁平结构的 XML,打开后可以直接看到 <w:wordDocument>、<w:body>、<w:p>、<w:r>、<w:t> 这些标签。文本内容基本都集中在 <w:t> 节点里,你拿 VS Code、Notepad++,甚至一个简单的正则脚本,就能完成占位符的检查和修改。
2.2 2003 XML 的标签足够少,手工能改得动
我摘一段典型 Word 2003 XML 的结构给你看,直观感受一下它的简单:
xml复制<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<?mso-application progid="Word.Document"?>
<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml">
<w:body>
<w:p>
<w:r>
<w:t>收款人:${payee}</w:t>
</w:r>
</w:p>
</w:body>
</w:wordDocument>
除了几个 XML 声明和根节点上的 xmlns:w 之外,剩下的内容很直白。w:p 是段落,w:r 是文本片段,w:t 是真正的文字。你写个脚本替换 <w:t> 节点里的 ${payee},不会影响其他 XML 结构。
而 OOXML 风格的 docx 版本长这样:
xml复制<w:p>
<w:r>
<w:rPr>
<w:rFonts w:ascii="宋体" w:eastAsia="宋体" w:hAnsi="宋体"/>
<w:sz w:val="24"/>
</w:rPr>
<w:t>收款人:${payee}</w:t>
</w:r>
</w:p>
多出来的这些 <w:rPr> 节点不是问题,问题是 docx 的内容散落在多个文件里,且正文文件和样式文件分离,你每次搜索占位符都要先确认自己改的是不是 document.xml,而不是 styles.xml 里的同名文本。两者都很繁琐,但旧版 XML 的“单文件”属性太适合模板化输出了。
2.3 历史惯性:老教程为什么到今天还能用
还有一个不能忽略的现实原因:这些教程大多写在 2010 年前后。那时候 Office 2003 还有大量用户,微软在 Office 打开/另存为菜单里专门保留了一个“Word 2003 XML 文档”格式。后来的 Office 版本为了兼容性,一直没有删除这个选项。于是这个格式成了 Word 和文本处理之间最稳定的桥。
后来 Online 教程越传越广,新作者也是照着老教程试,发现确实能跑通,就继续沿用。久而久之,“Word 转 FTL”就几乎和“保存成 Word 2003 XML”绑定了。
它不是什么官方标准,也不代表 FreeMarker 官方推荐。它更像“一条大家都验证过、成本最低、坑最少的路”。
下面我横向对比一下三种常见方案的差异:
| 方案 | 文件本质 | 模板化难度 | 踩坑风险 | 适用场景 |
|---|---|---|---|---|
| DOC | OLE 二进制容器 | 高,必须上 API | 高,无法手工检查 | 只能在旧程序环境跑 |
| DOCX | ZIP + 多 XML | 中,也能做 | 中等,打包容易损坏 | 对格式有严格标准的项目 |
| Word 2003 XML | 单一 XML 文本 | 低,记事本可改 | 低 | 快速实现模板填充场景 |
看到这个表你就明白,所谓“90% 教程都让转 Word 2003 XML”,不是因为这些教程观念陈旧,而是因为对这个使用场景来说,它确实是最合适的方案。
3. 落地实操:把一份合同 Word 变成可填数据的 FTL 模板
3.1 准备文档,占位符的写法决定成败
我先拿一个真实场景举例:现在要做一份“付款确认函”,需要动态填充收款人、金额、日期,还要循环列出多笔付款明细。最终生成的 Word 文件要保持原来的字体、字号、加粗、对齐方式。
第一步是在 Word 里把骨架先排好,然后把动态部分写成占位符:
text复制付款确认函
兹确认我司已于 ${payDate} 向 ${payee} 支付以下款项:
[#list items as item]
项目:${item.name} 金额:${item.amount}
[/#list]
总计:${total}
这里的关键是:控制指令必须使用 FreeMarker 的方括号语法 [#list],而不是默认的尖括号语法 <#list>。
原因非常实际:Word 2003 XML 本质是 XML,在 XML 的文本节点里,尖括号 < 是保留字符。如果你在 Word 里直接敲 <#list items as item>,另存为 XML 时要么报错,要么被自动转义成 <#list items as item>。FreeMarker 看到的是普通文本 <#list>,而不是指令。方括号语法不涉及 XML 保留字符,可以在 Word 里直接输入、直接保存,FreeMarker 也默认支持这种写法。
3.2 另存为 Word 2003 XML
占位符写完后,在 Word 里点击“文件 -> 另存为”,在保存类型下拉框里找到“Word 2003 XML 文档”。不同 Office 版本菜单位置略有差异,但关键词就是两个:XML 和 2003。
保存之后你得到的是一个全新的 .xml 文件。如果怕覆盖原文件,可以单独建一个 template_source 目录存放原 Word 模板。这样后续改动模板样式时,只需要改原 Word,再重新另存一次,不用在 XML 里手改样式。
然后把这个 XML 文件用 VS Code 打开,搜索你的占位符。正常情况下你会看到类似这样的内容:
xml复制<w:p>
<w:r>
<w:t>兹确认我司已于 ${payDate} 向 ${payee} 支付以下款项:</w:t>
</w:r>
</w:p>
如果是这样的话,基本成了。你只需要把这个文件的扩展名从 .xml 改成 .ftl,放到 FreeMarker 能扫描到的地方,模板就算建好了。
3.3 手工微调 XML 与 FreeMarker 指令
但很多模板的需求不会这么简单,尤其是表格循环。Word 里手动输入 [#list items as item] 只能把循环写到某个段落或单元格内部。如果你想要的效果是“根据数据生成多行表格”,那么指令必须包在 <w:tr> 外面,否则循环只会在一个单元格里面复制文字,不会新增行。
这时候就需要手工打开 XML,找到表格对应的 <w:tbl> 区域,在需要重复的行前后插入指令:
xml复制<w:tbl>
<w:tr>
<w:tc>
<w:p>
<w:r>
<w:t>表头一</w:t>
</w:r>
</w:p>
</w:tc>
<w:tc>
<w:p>
<w:r>
<w:t>表头二</w:t>
</w:r>
</w:p>
</w:tc>
</w:tr>
[#list items as item]
<w:tr>
<w:tc>
<w:p>
<w:r>
<w:t>${item.name}</w:t>
</w:r>
</w:p>
</w:tc>
<w:tc>
<w:p>
<w:r>
<w:t>${item.amount}</w:t>
</w:r>
</w:p>
</w:tc>
</w:tr>
[/#list]
</w:tbl>
这段话是 FreeMarker 模板里最常见的“行循环”写法。渲染时,[#list] 会展开成多个 <w:tr>,输出的 Word XML 就是完整的表格。
需要提醒的是:手工调整 .ftl 后,不要再用 Word 去打开它编辑。Word 一旦打开这个带 FreeMarker 指令的文件,很可能会认为 XML 结构有问题,或在保存时把方括号指令当成普通正文重新处理。正确的流程是只改源 Word 文档,然后重新另存为 XML,再重复一次人工插入行循环指令。这就是模板维护的常态。
3.4 用 FreeMarker 渲染并输出可打开的 Word 文件
模板文件准备好之后,Java 端就很常规了。先加依赖:
xml复制<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version>
</dependency>
然后写一个最小渲染逻辑:
java复制Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setDefaultEncoding("UTF-8");
cfg.setDirectoryForTemplateLoading(new File("templates"));
Map<String, Object> data = new HashMap<>();
data.put("payDate", "2025-01-15");
data.put("payee", "杭州某科技有限公司");
data.put("total", "12,600.00");
List<Map<String, Object>> items = new ArrayList<>();
items.add(Map.of("name", "软件实施服务", "amount", "9,800.00"));
items.add(Map.of("name", "年度维护费", "amount", "2,800.00"));
data.put("items", items);
Template template = cfg.getTemplate("payment-confirm.ftl");
try (Writer out = new OutputStreamWriter(
new FileOutputStream("output.doc"), StandardCharsets.UTF_8)) {
template.process(data, out);
}
我自己在项目里会再封装一层,把文件生成、文件名处理、下载响应头分开。响应头关键就两个:
Content-Type: application/mswordContent-Disposition: attachment; filename=xxx.doc
Word 2003 XML 的文件头部分自带 <?mso-application progid="Word.Document"?> 这个处理指令,所以即使文件后缀是 .doc,很多版本的 Windows 版 Word 也能识别并直接打开。如果有些环境打开后显示乱码或提示格式错误,不要纠结,直接把生成结果后缀改成 .xml 再下发,或者用 LibreOffice 转成真正的 docx 后再给用户。
3.5 数据插入后要不要转成 DOCX
一个常见疑问是:我生成的还是 2003 XML,不是 docx,会不会显得过时?
这取决于你的使用者。如果用户只是要看内容、打印、归档,Word 2003 XML 完全可以。你直接把渲染后的 XML 当成真实交付物即可,Word 能打开,打印也能正常显示。
如果下游要求必须是 docx,那就在渲染完成后加一步格式转换。生产环境里我一般用 JODConverter 调 LibreOffice 完成,虽然速度慢一点,但稳定。等转换完再把临时 XML 文件删掉。这里不建议自己写底层 docx 转换,成本和风险都高。
4. 踩坑清单:占位符被拆散、特殊字符、表格循环
4.1 Word 运行时“好心”拆散你的占位符
新手最常遇到的坑是:在 Word 里明明写的是 ${payee},另存为 XML 之后变成了:
xml复制<w:t>${</w:t>
</w:r>
<w:r>
<w:t>payee}</w:t>
这是因为 Word 把不同格式的文本分成多个 run。只要你输入 ${payee} 时中间经历了字体切换、拼写检查的自动修正、或者输入法的特殊状态,Word 就可能把一个完整的占位符拆成两段。FreeMarker 解析模板的时候是逐字匹配的,${ 在一个 <w:t> 里,payee} 在另一个 <w:t> 里,它就不知道你写的是什么变量了。
排查方法很笨但有效:把生成的 XML 用文本编辑器打开,搜索 $ 或 [,看占位符是否完整地出现在同一个 <w:t> 节点内部。如果被拆开,最快的修法是在 Word 里重新输入占位符,并且输入时不要改变任何格式,不要使用自动更正功能,输入完成后再全选设置统一字体。
我自己的习惯是把所有占位符先写成一个纯文本段落,等确认显示正常后再复制到目标段落里。这能减少 Word 对小片段的格式重排。
4.2 数据插入后 XML 是否仍然合法
Word 2003 XML 本身是 XML,里面有严格的转义规则。如果你在 FTL 里直接写 ${remark},而数据库里 remark 的值是:
text复制到账时间晚于 2025-02-01 & 需加急处理
FreeMarker 不会帮你做 XML 转义,它只会把这个字符串原样拼进输出。结果 XML 文本节点里就出现了一个裸的 &,这个 XML 就不能被解析了。Word 打开时会提示文件损坏或显示异常。
解决办法是在插值处加上 FreeMarker 的 ?xml 内置函数:
xml复制<w:t>备注:${remark?xml}</w:t>
这样输出时会把特殊字符转换成 &、< 等合法实体。不要嫌麻烦,这是必须的。如果模板里有大量插值,建议统一约定所有文本变量都带 ?xml。虽然写起来繁琐,但能避免线上生成一堆坏文件。
4.3 表格循环不能直接在单元格里画圈
我再强调一遍:[#list] 放在 <w:t> 里面和在 <w:tr> 外面,效果完全不同。放在单元格里,它只会循环单元格内部的文字;放在 <w:tr> 外层,它才会复制整个行。
有些教程为了让 Word 能正常保存,会教你把 [#list] 写进一个隐藏段落里。实际项目里我不推荐这种绕法,因为你很难保证隐藏段落的样式不影响最终表格。最稳妥的方案是维护两个东西:一份源 Word 文档用于排版,一份经过人工微调的 .ftl 用于渲染。每次修改模板结构时,重复一遍“源 Word 另存为 XML -> 手工把循环指令放到 <w:tr> 外层”的流程。
4.4 编码问题的判断顺序
“中文乱码”和“文件开头多一个尖括号”是 Word 转 FTL 的高频问题。按这个顺序排查:
- 打开
.ftl文件,看第一行的<?xml version="1.0" encoding="...">,确认声明的是 UTF-8 还是 GB2312; - 确认 Java 端 FreeMarker 配置的
setDefaultEncoding("UTF-8")和文件实际编码一致; - 如果文件带 BOM,把 BOM 去掉,否则 FreeMarker 读取时第一行可能会解析出错;
- 确认写入文件时用的
OutputStreamWriter也指定了UTF-8,不能只设置配置类。
很多“第一个字乱码”的问题,最终原因都不是 FreeMarker,而是 Word 保存 XML 时用了系统本地编码,又没有在 XML 声明和 Java 程序之间统一。
5. 什么时候不要死守 Word 2003 XML:更现代的替代方案
5.1 官方 Content Controls + docx4j
如果你的项目文档结构非常复杂,需要让非技术人员在 Word 里维护模板,并且要求未来的版本兼容性,那 Word 2003 XML 确实不够体面。
现代 OOXML 提供了内容控件(Content Control),对应 XML 里的 <w:sdt> 标签。你可以在 Word 的“开发工具”选项卡插入内容控件,给控件设置标题或标签,然后配合 docx4j 读取这些控件并向其中写入数据。这个方案保留了完整的 docx 格式,不涉及老旧的 2003 命名空间,也更接近微软当前的技术栈。
但代价是学习曲线陡很多。你需要理解 OOXML 的 sdt、sdtPr、sdtContent 结构,还要处理 docx 解压后的多文件关系。如果团队里只有你一个人搞,那我不建议为了一点“现代感”硬上这个方案。
5.2 用 POI 生成 docx 后转 PDF
如果你的输出目标其实是 PDF,而不是 Word 文件,可以完全抛开这个老 XML 思路。用 Apache POI 的 XWPF 操作 docx 文档,填充文字和表格,再用 LibreOffice 转 PDF。整个过程控制力强,代码里可以直接定义表格行数、列宽,不需要折腾模板语法。
不过 POI 的方案写起来代码量大,每次模板样式变化都可能要改代码。如果是商务合同、标书这类样式频繁调整的文档,我仍然推荐 FTL 模板,毕竟改模板文件比发布一次代码快得多。
5.3 如果项目允许,直接生成 HTML 再导出
还有一个在中小型项目里被严重低估的方向:用 FreeMarker 生成 HTML,再用 NPOI/Aspose/LibreOffice 把 HTML 转成 Word 或 PDF。
HTML 本质上和 Word 2003 XML 一样,是文本,而且带有一套成熟的 CSS 布局规则。你写起来更顺手,遇到复杂表格、列表、条件渲染也更舒服。缺点是 HTML 转 Word 时样式保真度不稳定,要求高的话仍需人工校对。
我给的建议是:先问自己“用户到底要什么格式”。只要 Word 能打开、样式不崩,2003 XML 完全够用;要生成真正的 docx 且样式要求极其严格,就花时间研究内容控件或 docx4j;目标只是 PDF,则直接走 HTML 渲染更划算。
最后分享一个我在实际项目里转变的思路:不要追求“一次成型”,Word 模板化项目几乎都是两段式——先把静态样式做好,再把动态指令手动插入。Word 2003 XML 之所以能从这么多格式里胜出,不是因为它的技术有多先进,而是因为它足够简单、足够透明,让这个过程可以被人为观察和修正。如果你能把这份文件和 FreeMarker 的运行方式分开理解,后面的问题就都不算问题了。
