☰
Java按模板生成Word文档:docx与doc方案对比及poi-tl实战
2026/10/21 9:07:20 网站建设 项目流程

做Java后端的这些年,处理过的“按模板生成Word文档”需求没有十次也有八次。合同要批量出、报告要按天生成、报销单要套固定格式,业务方开口都是同一句话:“模板我们设计好了,你把数据填进去就行。”听起来简单,真正动手才发现坑很多:用Apache POI从头画一个文档,样式控制能让人写代码写到怀疑人生;直接在Word里写${name}再转XML,又会遇到标签被拆开、图片串行、生成的文档打不开之类的问题。

这篇文章就围绕“JAVA 根据模板生成doc文件”这条主线,把doc和docx的本质区别、几种主流方案的适用场景、两套可以直接抄的实操流程,以及我实测踩过的坑一并梳理清楚。不管你是刚接手这个需求的新人,还是想替换掉现有脆弱方案的开发,都可以参考。

1. 先搞清楚一件事:你要的到底是 doc 还是 docx

1.1 doc 和 docx 的本质差异

做方案之前必须先分清一个概念:需求方嘴里说的“doc文件”,很多时候实际指的是Word文档,而Word文档现在的主流格式是docx。doc是Office 97到2003时代使用的二进制格式,内部是一个复合文档结构,早期Java读写这种格式只能靠POI里的HWPF模块,操作体验非常原始。docx是2007年之后推出的新格式,本质是一个zip压缩包,里面按固定的目录结构存放着document.xml、styles.xml、media等文件。

这个差异决定了技术选型的方向。docx可以被解压、被解析、被替换内容后再重新打包,整个流程非常规整;而doc是二进制格式,想在二进制流里定位一段文本并替换,难度和风险都高得多。我见过不少团队在“doc还是docx”这个问题上没问清楚,直接上了POI的HWPF,最后折腾半个月,还是绕回到docx方案。

真正在意老格式的场景通常有两种:一种是历史老系统之间的对接,接口约定死了扩展名必须是.doc;另一种是客户侧还在用Office 2003之类的老环境。除此之外,绝大多数业务场景用docx就能满足“能打开、能打印、排版不跑偏”的全部要求。所以接到需求后第一件事,就是确认对方在乎的是扩展名还是Office版本兼容性。

1.2 docx 为什么能被“模板化”

解压一个docx文件,你会看到word目录下有一堆XML,其中word/document.xml存的是正文内容,页面设置、样式、主题分别在styles.xml、settings.xml和theme目录里。docx能模板化,核心原理就是直接操作document.xml中的文本节点,把占位符替换成实际数据。

可以这样理解:你在一份Word文档里写了一个特殊标记{{contractNo}},代码解析的时候去document.xml里找这个文本段,找到就替换成真正的合同编号,再用zip工具把所有文件重新压缩成docx。整个过程和前端模板引擎渲染HTML非常相似,只不过容器从浏览器换成了Word。

这种思路最大的价值在于:排版、字体、页眉页脚、公司logo这些工作全部交给Word完成,业务方自己就能维护模板,Java侧只负责把数据填进去。模板和代码解耦,需求变更时改模板比改代码快得多。这也是模板生成方案对比“纯代码绘制文档”最大的优势。

1.3 老版 doc 的曲线实现方式

如果确实要生成老版.doc,也有办法绕开HWPF。可以把Word文档另存为“Word 2003 XML文档”,也就是WordML格式,然后在这个XML里埋入FreeMarker占位符,渲染后输出为.doc扩展名。生成的文件内容声明了一个特定的MS Office标识,老版Word会识别并正常打开。它本质上不是纯二进制格式,但对绝大多数“能打开、能编辑、能打印”的业务需求来说,完全够用。

如果需求是真正的二进制.doc,那就只能走HWPF。但HWPF对表格、图片、样式的操作能力非常有限,模板化支持也很弱。我在后面的章节会专门讲为什么不做特殊要求时,不建议优先考虑这条路线。

2. 核心原理:为什么 Word 文档可以「模板化」

2.1 模板化的本质是“文本替换 + 重新打包”

对docx来说,模板化的完整链路是三个步骤:把模板中预埋的占位符找出来,替换成目标数据,重新压缩成docx文件。思路听起来简单,但实现细节决定方案的稳定性。

最大的坑是Word会把文本拆分成多个run节点。Word的正文段落是w:p,run是w:r,真正的文字放在w:t里。你在Word里连续输入一段文字,可能在同一个w:r里,但只要你做了局部修改、插入了批注或者自动校正,Word就可能把一个词拆到两个w:r里。如果占位符${name}被拆成${na和me}两段,简单的字符串替换根本匹配不上。

解决方式有两种。一种是模板制作时尽量让占位符保持独立,这个在操作规范上要注意;另一种是直接用poi-tl这类专门处理模板的库,它内部会主动合并同一个段落里的run再匹配标签,能解决大部分被拆分的问题。

2.2 docx 目录结构和命名空间

你准备操作XML模板之前,先弄明白docx的命名空间。word/document.xml的根节点是w:document,所有正文标签都以w:为前缀,段落是w:p,run是w:r,文本是w:t。如果你用FreeMarker直接处理WordML模板,${}是FreeMarker的取值语法,和XML标签混在一起没问题,但FreeMarker的循环指令<#list>、判断指令<#if>如果出现在XML标签内部,可能会干扰XML结构。

实际经验是:在WordML模板里尽量只用${}做数据替换,循环和判断指令放在w:tr或w:p这样的完整节点外层。这样FreeMarker输出后的结果仍然是一个合法的XML,Word才能正常解析。

还有一个高频翻车点是XML特殊字符。模板里一旦出现&、<、>,在XML里必须写成&amp;、&lt;、&gt;。Word生成的XML会自动处理这些问题,但如果你用文本编辑器手动修改模板,很容易在这里踩坑。

2.3 编码是一切中文乱码的根源

Word XML文档的编码声明是UTF-8,Java侧从读取模板、渲染数据到写出文件,全程都要统一UTF-8。很多生成出来的文档中文乱码,就是因为读模板用了GBK,或者输出流用了平台默认编码。

FreeMarker这边,配置里要显式setDefaultEncoding("UTF-8"),写出文件时用OutputStreamWriter并指定UTF-8。poi-tl的输出也是同理,文件流用OutputStream,不要把文档内容当作文本用Writer去写。一个容易忽略的点:用HttpServletResponse输出下载时,也不要手动设置字符编码,文件流交给Servlet容器处理就好。

编码这块我吃过亏:早期有一个项目在生产环境偶发中文乱码,排查到最后是部署的Linux服务器默认编码是POSIX,和本地Windows不一致。后来统一在代码层强制指定UTF-8,问题才彻底消失。

3. 实操一:poi-tl 模板方案(推荐,docx 方向)

3.1 依赖引入和版本匹配

如果目标格式是docx,目前最推荐的方案是poi-tl。它是在Apache POI基础上封装的一层模板引擎,把run合并、标签解析、图片渲染这些底层工作都处理好了,Java侧代码非常简洁。

<dependency> <groupId>com.deepoove</groupId> <artifactId>poi-tl</artifactId> <version>1.12.1</version> </dependency>

注意poi-tl 1.12.x内部依赖POI 5.2.x,如果你的项目里还有其他组件引用了旧版POI,比如项目中已经在用POI 3.17导出Excel,运行时会直接NoSuchMethodError。排查这种冲突很费时间,建议在pom里把整个项目的POI统一升级到5.x,或者用依赖排除把旧版本剔除。

3.2 模板制作:{{}}标签的写法

poi-tl的模板直接用Word新建就行,不需要转格式。打开Word文档,在需要动态填充的位置直接写{{字段名}},字段名建议用英文或拼音,避免中文在跨平台时编码异常。

比如合同编号写{{contractNo}},客户名称写{{customerName}},签订日期写{{signDate}}。需要注意一点:标签不要被人为打断。虽然poi-tl会主动合并run,但如果你在标签中间敲了回车、分页符,或者用自动更正把标签拆开,渲染结果就可能不完整。最稳妥的做法是让每个短标签单独成行,或者至少保证标签在同一个段落内连续。

这个环节有个特别实用的经验:把“字段命名规范”以文档形式固定下来,交给业务方维护模板。字段变更时业务方自己改Word模板,开发只改Java侧的数据组装逻辑,需求变更的沟通成本会立刻降下来。

3.3 渲染代码与数据组装

模板写好、依赖配好,Java侧核心代码就是三行的事:

Map<String, Object> data = new HashMap<>(); data.put("contractNo", "HT-2024-001"); data.put("customerName", "某科技有限公司"); data.put("signDate", "2024-01-15"); XWPFTemplate template = XWPFTemplate.compile("template.docx").render(data); try (OutputStream out = new FileOutputStream("result.docx")) { template.write(out); } template.close();

compile接收的是模板路径,render做的事情就是解析XML、合并run、替换占位符,最后write输出。模板路径我建议放在classpath下的templates目录里,通过类加载器读取,避免本地开发环境和服务器部署环境路径不一致的问题。

InputStream is = getClass().getResourceAsStream("/templates/template.docx"); XWPFTemplate template = XWPFTemplate.compile(is).render(data);

这样打包成jar也能正常工作。

3.4 动态表格、图片与循环列表的处理

模板化的场景通常不止简单的字段替换,还涉及明细表、图片、列表等复杂对象。poi-tl对这些场景也有对应语法。

循环表格行的做法:在表格行的第一个单元格里写{{?list}},在最后一行写{{/list}},list对应一个List<Map<String, Object>>类型的数据。poi-tl会按照模板中这个表格行的样式进行复制,动态生成多行数据。

List<Map<String, Object>> items = new ArrayList<>(); Map<String, Object> item1 = new HashMap<>(); item1.put("name", "苹果"); item1.put("count", "2"); items.add(item1); data.put("list", items);

这种方式生成的表格行会自动继承模板行的格式,不需要代码里手动设置边框、字体、对齐方式,非常省事。

图片处理方面,poi-tl默认的{{photo}}标签不能直接渲染图片,需要用图片标签语法{{@photo}},并且数据对应一个PictureRenderData对象:

byte[] imageBytes = Files.readAllBytes(Paths.get("sign.png")); data.put("photo", new PictureRenderData(120, 100, "png", imageBytes));

这里的120和100是显示尺寸,单位是像素,图片如果是base64编码的请求数据,也可以先转成byte[]再传入。实际项目里图片数据经常来自数据库或接口返回,用byte[]传参是最稳妥的。

4. 实操二:FreeMarker + WordML 模板方案(老版 doc 方向)

4.1 模板文件的制作步骤

如果需求方明确要求老版.doc兼容,推荐走Word 2003 XML + FreeMarker的路线。制作模板的步骤不复杂,但有一步非常关键。

第一步,用Word打开做好的文档,点击“另存为”,在文件类型里选择“Word 2003 XML文档”。这里会生成一个.xml文件,这个文件就是模板的基础。第二步,用文本编辑器打开这个XML,找到需要动态化的文本位置。第三步,把固定文本替换成FreeMarker占位符,比如${customerName}。

这里有个绕不开的坑:Word在保存XML时会把文本拆分成多个<w:r>,导致${customerName}这样的占位符跨run存在,FreeMarker替换时无法匹配。我常用的处理技巧是:先在Word里把占位文本写成一个不容易被拆分的字符串,比如--customerName--,保存成XML后用文本编辑器的全局替换功能,把--customerName--替换成${customerName}。这样能巧妙地避开Word的run拆分问题。

4.2 FreeMarker 渲染的核心代码

FreeMarker部分的核心代码结构如下:

Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); cfg.setDefaultEncoding("UTF-8"); cfg.setClassLoaderForTemplateLoading(getClass().getClassLoader(), "/templates"); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); Template template = cfg.getTemplate("contract-template.xml"); Map<String, Object> data = new HashMap<>(); data.put("customerName", "某科技有限公司"); data.put("amount", "12800.00"); data.put("signDate", "2024-01-15"); data.put("remark", "按期交付"); try (Writer writer = new OutputStreamWriter(new FileOutputStream("result.doc"), "UTF-8")) { template.process(data, writer); }

几个必须注意的细节:Configuration.VERSION_2_3_32来源于FreeMarker 2.3.32版本,依赖版本要匹配;Writer必须显式指定UTF-8,否则中文必乱;输出文件的扩展名是.doc,但文件内容仍然是XML,老版Word会根据XML开头声明的MS Office标识自动识别为Word文档。

4.3 WordML 模板中循环与条件判断的写法

WordML里做循环,最典型的是表格行重复。假设明细表有两列,品名和数量,模板里的表格行写成:

<#list details 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.count}</w:t></w:r></w:p></w:tc> </w:tr> </#list>

<#list>指令写在<w:tr>外层,FreeMarker先解析循环生成多组<w:tr>,再交给后续的XML处理。这样输出后的XML是完整的,Word不会有任何问题。

条件判断的写法也类似:

<#if item.remark??> <w:tc> <w:p><w:r><w:t>${item.remark}</w:t></w:r></w:p> </w:tc> <#else> <w:tc> <w:p><w:r><w:t></w:t></w:r></w:p> </w:tc> </#if>

空值处理建议在模板合约里统一约定:普通字段用${xxx!" "}给定默认值,数字字段在Java侧先转成字符串再传入,避免null直接显示成“null”这种尴尬情况。

4.4 为什么 HWPF 不是好选择

Apache POI的HWPF模块能读写老版.doc,但API对模板化的支持很弱。操作段落和表格需要手动遍历XML级别的节点,图片插入流程复杂,样式控制更是费劲。我见过某个团队把HWPF写成“能跑但不敢动”的状态:改动一行模板结构,代码就要跟着调半天。

所以我的建议是,除非业务必须生成真正的二进制.doc,而且格式极其简单、只有纯文本段落,否则WordML是更稳妥的选择。它保留了Word的完整样式,业务方改模板也方便,Java侧维护成本明显低。如果后续业务升级换到docx,模板迁移的代价也相对可控。

5. 常见问题排查与避坑实录

5.1 输出文件名中文乱码

通过浏览器下载生成的文档时,Content-Disposition里设置中文文件名大概率乱码。这里需要做一次编码转换:

String fileName = URLEncoder.encode("业务报告.doc", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName);

filename*是RFC 5987规范定义的写法,带上UTF-8''前缀后,现代浏览器都能正确解析中文文件名。这里注意一个细节:URLEncoder会把空格编码成+号,而URL路径中的空格应该用%20表示,所以需要replaceAll处理一下。

5.2 生成的文档打不开

打不开的原因集中在三种情况。第一种是模板XML经FreeMarker渲染后格式不合法,解决方法是把渲染结果单独输出为.xml文件,用浏览器或文本编辑器打开检查,看看标签闭合是否完整。第二种是占位符残留,生成的文档里还能看到${xxx}文本,说明FreeMarker配置了忽略缺失值或者数据Map里没有对应键,建议在模板中显式处理缺失值。第三种是图片数据异常,WordML里的图片是base64字符串,如果传入的数据里有非法字符或者被插入了换行,整个文档就会损坏。

5.3 图片不显示或尺寸异常

poi-tl固定尺寸的PictureRenderData适合图片字段固定、服务端预先知道图片路径的场景。如果图片来自业务数据,数量可能变化,一个更灵活的方式是使用图片标签{{@productImg}}并传入PictureRenderData对象,结构上更通用。

图片尺寸异常的问题,常见原因是模板里标签所在表格单元格设置了固定行高和列宽,图片被强制压缩。建议模板中图片位置不要设置固定行高,让图片跟随内容自适应。

5.4 数据为空时的“null”问题

poi-tl默认把null值渲染为空字符串,体验相对友好。FreeMarker则需要显式处理:

${customerName!"(无)"}

或者更细粒度地使用<#if>判断。业务上遇到某些字段不存在时希望整段隐藏的情况,推荐把判断逻辑写在模板里,而不是在Java代码里塞默认值。这样模板自解释能力更强,后期改需求也方便。

5.5 WPS 和 Office 的兼容性差异

用WPS编辑模板再另存为docx,用poi-tl渲染偶尔会出现样式异常,甚至文档打不开。主要原因在于WPS生成的docx结构不够标准,部分XML节点缺失或顺序和Office不同。我实际踩过几次坑之后,形成了两个习惯:模板制作、修改统一用Microsoft Office,WPS只作为查看工具;如果客户侧坚持用WPS改模板,要求改完再另存为docx,并且做一次完整渲染回归。

另外,页眉页脚、分节符这类结构尽量在模板里固定好,不要在代码里动态追加。项目做到后期,我最大的感受是文档生成的核心技术反而没那么难,难的是模板管理、字段规范、异常兜底这些工程化细节。自己目前比较推荐的落地路径是:先跑通poi-tl的hello world,确认docx能满足需求;如果必须兼容老版Office,再把同一套模板逻辑改造成WordML。这两种方案代码都不复杂,真正值钱的功夫在模板本身。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询