接到一个导出合同的需求,合同模板有十几页,里面全是表格、图片、页眉页脚,还要根据不同的产品线动态显示不同段落。第一反应是用POI硬编码,但写着写着就发现自己掉进了泥潭:各种样式API、合并单元格、动态行数、图片占位,代码越写越长,业务一改版就是一场灾难。后来我把思路换成了 FreeMarker / Velocity 模板引擎 + Word XML,才算真正把这类需求从“写代码”变成了“填数据”。
这篇文章不是介绍POI的常规API用法,而是分享一条更贴近真实业务场景的路线:先用Word做好模板,再用模板引擎渲染底层XML,最后重新打包成docx。适合Java后端开发、需要批量生成合同/标书/报告的团队参考,尤其是模板由业务人员维护、数据结构频繁变化的项目,这套方案能省下大量改代码的时间。
1. 方案选型与整体思路
1.1 为什么不用POI硬编码
POI操作Word的能力确实很强,能创建段落、设置字体、合并单元格、加图片,几乎没有做不到的。但问题是:它是“操作对象”,不是“生成模板”。你需要在代码里把每一个段落的字体、字号、对齐方式、表格每一行的边框、每一个单元格的底色全部写一遍。简单说,用POI做一个带11列、每列宽度不同、表头有底纹有边框的表格,光建表逻辑就是几十行代码,数据再一变,代码就得跟着调。
最难受的是模板频繁改版。业务方今天说表头加一列,明天说某个段落要加粗,后天说把两个表格换一下顺序。你作为开发,不得不一遍遍打开POI文档查API,改完还要担心其他逻辑被影响。这种体验做过的人都懂。
而Word XML模板方案的本质完全不同:docx本身就是一个zip压缩包,里面装的是若干XML文件。你在Word里做的所有排版,最后都会变成document.xml、styles.xml、header.xml这些文件里的标签。既然是XML,那就可以交给模板引擎去渲染——先做一个排版好的模板docx,把需要动态变化的位置用占位符标记,程序读取XML、填充数据、写回zip,就得到了一份新docx。
“先做模板,再做数据替换”,这个思路把“代码排版”变成了“数据填充”,把“频繁改样式”变成了“改Word文件”。实际用下来,模板改版时只要不动占位符,代码基本不用动。
1.2 FreeMarker与Velocity怎么选
这两个都是Java生态里老牌的模板引擎,在Word XML场景下都能用,但体验差别不小。我在不同项目里都用过,简单给个对比:
| 维度 | FreeMarker | Velocity |
|---|---|---|
| 维护状态 | 活跃,持续更新 | 基本处于维护模式,更新少 |
| 语法 | <#if>、<#list>,与XML标签易区分 |
#if、#foreach,脚本感更重 |
| 数据类型支持 | Map/List/JavaBean 直接可用 | 也支持,但空值处理相对粗糙 |
| 空值处理 | ! 默认值语法很方便 |
需要额外判断或配置 |
| 模板复用 | 宏能力强 | 相对简单 |
| 学习成本 | 稍高,但功能全 | 低,入门快 |
我的建议很直接:新项目直接选FreeMarker,不用犹豫。原因有几个:
第一,<#list>和<#if>这种尖括号语法,放在XML模板里天然和<w:p>、<w:tr>这些Word标签区分得清清楚楚,读起来不混乱。Velocity的#foreach虽然也不难认,但在XML里混着看总觉得不够直观。
第二,FreeMarker对空值的处理更顺手。导数据时总有某个字段没值,FreeMarker里写${item.name!''}就能给默认值,Velocity要做到同样的效果得写一堆判断。
第三,FreeMarker的宏支持做复杂输出片段复用,比如统一处理电话号码脱敏、金额格式化,写一次就能在多处模板里调用。
当然,如果你的项目里已经有Velocity的历史代码,或者团队对Velocity很熟,也完全没必要为了“先进”而重构。两个方案在Word XML这条路上的逻辑完全一致,只是模板语法不同。我后面会单独用一节讲Velocity的写法,方便两边都参考。
1.3 Word XML方案的本质:docx就是一个zip
这一点值得多说两句。很多人一听“Word XML”,本能觉得是陌生技术,其实它离我们很近。你随便拿一个docx文件,把后缀改成zip,解压出来,就会看到这些关键文件:
code复制[Content_Types].xml
_rels/.rels
word/document.xml # 正文内容
word/_rels/document.xml.rels # 资源关系(图片等)
word/media/ # 图片等二进制资源
word/styles.xml # 样式定义
word/header1.xml # 页眉
word/footer1.xml # 页脚
word/settings.xml # 文档设置
正文里所有文字、表格、段落都在document.xml里。一个最简单的段落长这样:
xml复制<w:p>
<w:r>
<w:t>你好,这是一个段落。</w:t>
</w:r>
</w:p>
w:p是段落,w:r是文本运行,w:t才是真正放文字的地方。一个w:r代表一段连续格式的文本,所以加粗的文字和一个普通文字往往是两个w:r。
理解了这层结构,方案就清晰了:我们把占位符写到w:t里,然后用模板引擎替换掉占位符,再把XML写回docx。Word打开时,看到的还是在Word里原本设计好的排版。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模板准备与XML结构拆解
2.1 模板设计时的占位符规划
很多人第一次做模板,直接把${contractName}这种占位符打进Word里就完事,结果程序跑完,生成的docx要么打不开,要么占位符原样出现在正文里。这些坑大多数能在模板制作阶段避开。
占位符规范,我建议统一用${xxx}格式,中间不要有空格,命名用英文或驼峰。原因很简单:${和}在Word文本里出现概率低,不容易误替换。用过@@name@@这种自定义标记的人也不少,但没和FreeMarker语法直接对应,还得自己写替换逻辑,不推荐。
第二个要点:关闭Word的自动更正。Word默认会把直引号替换成弯引号,中文输入法下的括号也可能被替换成全角括号。写占位符时,${contractName}一旦被改成{contractName}或者引号变弯,程序就匹配不到。建议在Word的“文件→选项→校对→自动更正选项”里,把“直引号替换为弯引号”关掉。如果模板已经写好了,可以用Word的“查找替换”功能快速检查所有占位符是否格式正常。
第三点最容易忽视:占位符最好一次性完整输入,不要输入到一半再回头修改。原因和Word的文本存储机制有关,我下一节细说。
2.2 解压docx,看懂document.xml的家族关系
不管用什么工具,我建议做这个方案的第一步,永远是把一个简单docx解压开,用文本编辑器打开document.xml看一眼。不亲自看一次,后面遇到问题很难凭想象排查。
下面是一段典型的document.xml片段,演示了一个段落里有两个文本运行:
xml复制<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p>
<w:r>
<w:rPr>
<w:b/>
</w:rPr>
<w:t>合同编号:</w:t>
</w:r>
<w:r>
<w:t xml:space="preserve">${contractNo}</w:t>
</w:r>
</w:p>
</w:body>
</w:document>
注意几个细节:
- 根节点
w:document上带着命名空间xmlns:w,这是WordprocessingML的标准命名空间。模板引擎在操作XML时不要动根节点属性。 w:rPr是这个文本运行(w:r)的格式属性,里面可以定义加粗、字体、字号等。xml:space="preserve"是Word自动加上的,意思是不忽略首尾空格。你写${contractNo}前后如果有空格,要想保留,就离不开这个属性。
表格的结构对应关系也很好认:w:tbl是整个表格,w:tr是行,w:tc是单元格。一个最简单的两行两列表格,结构就是w:tbl里套两个w:tr,每个w:tr里套两个w:tc。
这些标签不用背,但至少要能看懂。看懂之后,动态行、动态列、条件显示这些需求,就是在正确的位置插入正确的标签而已。
2.3 重点难点:占位符被拆分的问题
这是整个方案里最坑、也最值得提前处理的点。Word打开一个文档后,如果你对某个占位符做了任何修改——哪怕只是删了一个字母再打回去——Word再保存时,很可能会把一个w:r拆成多个w:r。
我举个例子。你最初在Word里输入了${contractNo},它是连续文本,理论上一个w:r就够了。但如果你后来把这个字符从${contractNo}改成了${contractNo1},再改回来,Word在保存时可能把XML变成这样:
xml复制<w:p>
<w:r><w:t>${</w:t></w:r>
<w:r><w:t>contractNo</w:t></w:r>
<w:r><w:t>}</w:t></w:r>
</w:p>
FreeMarker拿到这个XML后,会去找完整的${contractNo}字符串,但XML里根本没有连在一起的这串字符,自然替换不了,最后生成的文档里占位符原样留在那里。
规避方法有两个层面。模板制作时,尽量一次性把占位符输入完整,不要反复修改。另外,保存模板前,可以用文本编辑器打开document.xml检查一下,确认占位符在同一个w:t里。
但问题在于,模板常常要经过业务人员的手,他们不会注意这些细节。所以更稳妥的方案是在代码层做一个预处理:把同一个段落(w:p)内相邻的w:r中的w:t文本合并起来,让占位符重新恢复成完整字符串。
下面是我常用的合并方法,基于DOM解析实现,核心逻辑就是把连续纯文本w:r合并成一个w:r:
java复制public static String mergeRuns(String documentXml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(documentXml.getBytes(StandardCharsets.UTF_8)));
String wNs = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
XPath xPath = XPathFactory.newInstance().newXPath();
NodeList paragraphs = (NodeList) xPath.evaluate("//*[local-name()='p']", doc, XPathConstants.NODESET);
for (int i = 0; i < paragraphs.getLength(); i++) {
Element p = (Element) paragraphs.item(i);
NodeList children = p.getChildNodes();
List<Element> runs = new ArrayList<>();
for (int j = 0; j < children.getLength(); j++) {
Node node = children.item(j);
if (node instanceof Element
&& wNs.equals(((Element) node).getNamespaceURI())
&& "r".equals(((Element) node).getLocalName())) {
runs.add((Element) node);
}
}
if (runs.size() < 2) {
continue;
}
// 合并策略:只合并那些没有w:br、w:drawing等特殊子元素的纯文本r
List<Element> mergeable = new ArrayList<>();
for (Element run : runs) {
NodeList subNodes = run.getChildNodes();
boolean pureText = true;
for (int k = 0; k < subNodes.getLength(); k++) {
Node sub = subNodes.item(k);
if (sub instanceof Element) {
String localName = ((Element) sub).getLocalName();
if (!"t".equals(localName) && !"rPr".equals(localName)) {
pureText = false;
break;
}
}
}
if (pureText) {
mergeable.add(run);
}
}
if (mergeable.size() < 2) {
continue;
}
StringBuilder sb = new StringBuilder();
Element firstRun = mergeable.get(0);
for (Element run : mergeable) {
NodeList tNodes = run.getElementsByTagNameNS(wNs, "t");
for (int k = 0; k < tNodes.getLength(); k++) {
sb.append(tNodes.item(k).getTextContent());
}
}
// 找到第一个r里的第一个t节点
NodeList firstTs = firstRun.getElementsByTagNameNS(wNs, "t");
Element firstT = (Element) firstTs.item(0);
firstT.setTextContent(sb.toString());
// 删除其他r节点
for (int idx = 1; idx < mergeable.size(); idx++) {
Element run = mergeable.get(idx);
run.getParentNode().removeChild(run);
}
}
TransformerFactory transformerFactory = TransformerFactory.newInstance();
Transformer transformer = transformerFactory.newTransformer();
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.setOutputProperty(OutputKeys.INDENT, "no");
StringWriter writer = new StringWriter();
transformer.transform(new DOMSource(doc), new StreamResult(writer));
return writer.toString();
}
这段代码里,我只合并同一段落里连续的、纯文本运行的w:r,遇到有w:br换行、w:drawing图片等特殊元素的w:r就跳过。合并后保留第一个w:r的格式,其他运行删除。这样既恢复了占位符,也不会把图片、换行等特殊内容弄丢。
这个预处理的处理逻辑不复杂,但我强烈建议你做进渲染流程的标准动作里,而不是等出了问题再去排查。实测下来,加了这步以后,模板制作阶段的“手滑”基本都能兜住。
3. 代码实现:FreeMarker渲染Word XML
3.1 项目依赖与基础配置
我用的是Maven项目,引入FreeMarker依赖即可,不需要额外引入POI,除非后面要做图片替换等二进制操作。
xml复制<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.32</version>
</dependency>
FreeMarker版本建议2.3.32以上,2.3.x系列稳了很多年,API没有太大变化。不要再引旧版本了,新版本对JDK的兼容性更好,HTML/XML自动转义的配置也更完善。
配置FreeMarker时,有几个细节会影响Word XML场景的体验:
java复制Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setDefaultEncoding("UTF-8");
cfg.setNumberFormat("0.######");
// 上面这行很关键,默认的数字格式可能带千分位逗号,渲染到XML里会导致Word解析异常
setNumberFormat("0.######")是我踩过坑之后加上的。FreeMarker默认的数字输出格式在不同版本里不太一样,有的版本会输出1,234这种带千分位的格式。放在XML的w:t里,文本显示倒是没问题,但如果生成的是表格列宽、行高等属性,带逗号的数字会直接让Word报错。
3.2 渲染主流程:从zip到zip
整个渲染流程可以概括成四条腿:读zip、预处理XML、模板渲染、写回zip。下面是一个可以跑通的最简实现:
java复制public byte[] exportWord(Map<String, Object> data, InputStream templateStream) throws Exception {
// 1. 读取模板docx所有条目
Map<String, ByteArrayOutputStream> entries = new HashMap<>();
try (ZipInputStream zis = new ZipInputStream(templateStream)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
zis.transferTo(baos);
entries.put(entry.getName(), baos);
}
} catch (IOException e) {
throw new RuntimeException("读取docx模板失败", e);
}
// 2. 从条目里取出正文XML
ByteArrayOutputStream documentBaos = entries.get("word/document.xml");
String documentXml = documentBaos.toString(StandardCharsets.UTF_8);
// 3. 合并被拆分的占位符
documentXml = mergeRuns(documentXml);
// 4. FreeMarker渲染正文
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32);
cfg.setDefaultEncoding("UTF-8");
cfg.setNumberFormat("0.######");
Template template = new Template("documentXml", documentXml, cfg);
StringWriter out = new StringWriter();
template.process(data, out);
String renderedXml = out.toString();
// 5. 替换正文并写回新的docx
ByteArrayOutputStream newDoc = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(newDoc)) {
for (Map.Entry<String, ByteArrayOutputStream> e : entries.entrySet()) {
ZipEntry zipEntry = new ZipEntry(e.getKey());
zos.putNextEntry(zipEntry);
if ("word/document.xml".equals(e.getKey())) {
zos.write(renderedXml.getBytes(StandardCharsets.UTF_8));
} else {
e.getValue().writeTo(zos);
}
zos.closeEntry();
}
}
return newDoc.toByteArray();
}
这段代码有两个值得注意的点。
第一,new Template("documentXml", documentXml, cfg)把XML字符串直接当作模板源,没有走模板文件路径,这是个很实用的技巧。因为document.xml是从zip里动态取出来的,如果用FreeMarker的setDirectoryForTemplateLoading加载模板文件,反而要多一步把XML落盘的操作,完全没必要。
第二,写回zip时,两个putNextEntry和closeEntry之间的顺序不能乱。如果某个条目是目录,比如word/media/这种,要单独处理。好在大多数docx模板里没有空的目录条目,实际生产代码建议加个判断:if (entry.isDirectory())就只创建目录不写数据。
3.3 动态表格:用<#list>生成多行
导出复杂Word最常用的需求就是动态表格。比如一个合同明细表,行数不固定,每行有商品名、规格、数量、单价、金额。
直接在Word模板里,我们只做一个两行的表格,第一行表头保持不变,第二行做成数据行。找到第二行对应的w:tr,在它的外层套上<#list>,行内要动态取值的地方用${item.xxx}:
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:tc><w:p><w:r><w:t>单价(元)</w:t></w:r></w:p></w:tc>
</w:tr>
<#list itemList 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.quantity}</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>${item.price}</w:t></w:r></w:p></w:tc>
</w:tr>
</#list>
</w:tbl>
很多人第一次写这个,容易把<#list>放在w:tr内部,或者放在w:tbl外面,结果不是多了一行空行,就是表格直接分离。记住:<#list>要包住整个w:tr,且放在w:tbl内部。这样循环几次,就会生成几行w:tr,Word对w:tbl的子元素是w:tr这事并不在意只数多还是少。
如果表格某一列要根据数据动态合并单元格,比如多个订单属于同一个客户,需要纵向合并w:tc,那就要结合条件判断了。纵向合并的XML规则是:起始单元格用<w:vMerge w:val="restart"/>,后续被合并的单元格用<w:vMerge/>。用FreeMarker表示就是:
xml复制<w:tc>
<w:tcPr>
<#if item.isFirstInGroup>
<w:vMerge w:val="restart"/>
<#else>
<w:vMerge/>
</#if>
</w:tcPr>
<w:p><w:r><w:t>${item.customerName}</w:t></w:r></w:p>
</w:tc>
不过在动手写这种逻辑之前,建议先评估一下复杂度。如果合并逻辑非常复杂,比如要做多级合并、跨行跨列混合合并,用XML模板硬写的成本可能会超过POI。这种情况下,我的建议是:表格主体用XML模板,合并操作交给后续的POI后处理。两个工具结合用,各干各擅长的。
3.4 条件显示:<#if>控制段落或整行
合同里经常有这样的需求:某些条款只在特定付款方式下显示。用FreeMarker做条件显示非常直观。
控制一个段落显示与否:
xml复制<#if paymentType == "advance">
<w:p>
<w:r><w:t>预付款条款:客户须在合同签订后3日内支付30%预付款。</w:t></w:r>
</w:p>
</#if>
控制表格整行显示与否,把<#if>包在w:tr外面即可,逻辑和<#list>一致。
这里有一个容易踩的细节:如果<#if>判断失败,段落或整行直接没有了,表格缺行、文档少段,这在Word里看着可能很奇怪。如果只是想让内容为空但保留表格结构,就不要用<#if>控制整行,而是用${xxx!''}控制单元格内容。
3.5 数据里的换行怎么处理
这是打包后Word报错的常见元凶之一。Java字符串里的\n到了Word XML里,并不会自动变成换行,直接写在w:t里会变成一个不可见字符,Word打开时可能直接忽略,也可能报错。
正确的做法是把换行符替换为<w:br/>,而且要放在w:r内部。所以模板里不能把这个动态值直接写成${item.address},因为你没法在模板里预知哪里要换行。
我的做法是在封装数据时,提前把含有换行的字段转成XML片段。比如写一个工具方法:
java复制public static String formatMultiLine(String text) {
if (text == null) {
return "";
}
String escaped = escapeXml(text);
return escaped.replace("\n", "<w:br/>");
}
这里注意顺序:一定要先做XML转义,再替换换行。如果先替换换行,再转义,<w:br/>的尖括号会被转义成<w:br/>,Word里显示的就是一段文本而不是换行。
然后在FreeMarker模板里,对这个字段用${multiline(address)}这种自定义函数,或者在数据模型里预先处理成一个专门的addressHtml字段:
java复制data.put("addressHtml", formatMultiLine(order.getAddress()));
模板里写:
xml复制<w:p>
<w:r><w:t>${addressHtml}</w:t></w:r>
</w:p>
用了<w:br/>之后,Word打开就能看到正常换行,而且换行后的文本和行首缩进也能正确显示。
4. 常见问题与排查技巧实录
4.1 XML转义:数据中的&、<、>让Word打不开
这个坑几乎人人都会踩。业务数据里经常出现AT&T、价格<100元这种字符串,直接拼到XML里,生成的docx文件用Word打不开,解压后看XML才发现是转义问题。
XML里只有5个字符必须转义:&转成&,<转成<,>转成>,双引号转成",单引号转成'。但并不是所有场景都需要全部转义,比如单引号在文本节点里不转也能用。
问题在于,FreeMarker默认是不做XML转义的。它把输出当作普通文本,数据里有什么就输出什么。所以有两种解法:
解法一:在数据层统一做XML转义,所有塞进模板的字符串都先用工具类转一遍。这种方案可控性最强,但容易漏,只要有一个字段忘了转义,线上就炸。
解法二:FreeMarker开启自动转义。在Configuration里设置:
java复制cfg.setAutoEscapingPolicy(Configuration.ENABLE_IF_DEFAULT);
cfg.setOutputFormat(XMLOutputFormat.INSTANCE);
这里有个细节:XMLOutputFormat是FreeMarker针对XML输出的一个格式处理器,开启后,模板里所有${xxx}都自动做XML转义。但如果你有些字段本来就是XML片段(比如上一节的<w:br/>换行内容),就会被二次转义,这时候需要用${xxx?no_esc}关闭单个变量的转义。
我个人的经验是:用解法一,显式转义。因为在一个中大型项目里,模板数量多、维护的人也多,依赖“默认转义开启”这件事很容易在某次修改中被打破。写个escapeXml工具方法,所有字段塞进data map时统一处理,规则明确,排查也方便。等团队熟悉了,再考虑要不要开全局自动转义。
4.2 打包后Word提示文件损坏,如何快速定位
这是最常见的报错:生成的docx用Word打不开,提示“文件已损坏”。原因九成九是XML格式坏了。排查思路很固定:
第一步,把生成的docx后缀改成zip,解压出来。
第二步,用文本编辑器打开word/document.xml,找一个XML格式化工具或在线校验工具检查一下。多数情况下,错误集中在几类:
- 某个
w:t标签没闭合,多了个<或>。 - 数据里含有非法控制字符,比如
\u0000、\u0001这类不可见字符,XML规范不允许直接出现,必须过滤。 - 标签嵌套顺序错了,比如
<w:tc>外面忘了包<w:p>。 - 属性值里带了引号没转义。
排查经验:先看document.xml的最后20行。因为XML解析是按顺序的,报错往往在末尾或某处语法断点附近。不用从头一行行看,浪费时间。
关于非法控制字符,这里分享一个我之前写过的过滤方法,可以在数据封装时统一处理:
java复制public static String stripInvalidXmlChars(String input) {
if (input == null) {
return null;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
if (c == 0x9 || c == 0xA || c == 0xD
|| (c >= 0x20 && c <= 0xD7FF)
|| (c >= 0xE000 && c <= 0xFFFD)) {
sb.append(c);
}
}
return sb.toString();
}
实测下来,导出的数据里如果带有从Excel复制过来的特殊字符,或者某些老系统的乱码字符,没有这一层过滤,Word打开的报错率非常高。加了这个方法后,基本没再遇到过控制字符导致的损坏。
4.3 图片替换:模板里固定位置换图
Word XML模板方案处理图片,常用的做法不是在XML里动态生成图片标签,而是在模板里放一张占位图片,最后用新的图片字节替换掉模板里的图片文件。
具体步骤是:
第一,在Word模板里,需要放图片的位置插入一张图片,大小、位置都调整好。这张图就是占位符,程序只需要换它的字节数据,不需要动XML结构。
第二,解压模板docx,打开word/_rels/document.xml.rels,找到这张占位图片对应的关系文件。rels文件里是类似这样的内容:
xml复制<Relationship Id="rId5"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
Target="media/image1.png"/>
这段XML表示rId5这个关系指向了word/media/image1.png。同时,正文里图片所在的位置会有一个<a:blip r:embed="rId5"/>标签,通过rId5关联到这张图片。
第三,代码里做的是:把模板zip里的word/media/image1.png这个条目的内容,替换成新的图片字节,其他条目一概不动。新图片的尺寸可能和模板图不一致,这会导致Word里图片显示变形。解决办法有两个:一个是在业务上约定所有图片裁成同一尺寸;另一个是用Java处理图片缩放后再替换,比如用ImageIO读取后按目标宽高缩放。
图片替换这部分,模板引擎干不了,必须结合Java的IO操作。我用的是最简单的方案,直接替换zip里的二进制条目,实测效果很稳定。如果你的图片是动态生成的条形码、二维码,同样可以先生成图片文件,再走这一步。
4.4 关于PDF导出与打印适配的一点补充
有些项目导出Word只是为了展示,最后还要转PDF打印。Word XML模板方案生成的docx,在Word里打开再另存为PDF,格式一般没问题。但如果你的系统里有在线预览、自动转PDF的流程,建议在模板阶段就把纸张大小、页边距、页眉页脚设置清楚,别指望代码去改。因为这类设置主要写在w:sectPr里,手工改XML容易出错。
顺带提一句,出于打印的考虑,字体尽量用宋体、黑体、微软雅黑这类常见字体,避免花哨字体在别的机器上替换导致版式错位。这是Word文档老生常谈的问题,但在模板导出场景里尤其明显——代码生成1000份合同,如果字体被替换,整个版式就乱了。
5. Velocity方案快速补充与迁移建议
5.1 Velocity写入Word XML的示例
Velocity版本的流程完全一致,区别只在模板语法和渲染API。先看模板,同样的动态表格用Velocity写是这样:
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>
#foreach($item in $itemList)
<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.quantity}</w:t></w:r></w:p></w:tc>
</w:tr>
#end
</w:tbl>
$!{item.name}这种写法是Velocity里表示“为空时输出空字符串”,对应FreeMarker的${item.name!''}。没有这个感叹号,字段为空时模板会直接报错或输出$item.name原样,非常烦人。
渲染API也不同。Velocity用VelocityContext:
java复制VelocityContext context = new VelocityContext();
context.put("itemList", itemList);
context.put("contractNo", contractNo);
StringWriter out = new StringWriter();
Velocity.evaluate(context, out, "documentXml", documentXml);
String renderedXml = out.toString();
注意Velocity.evaluate的签名,前面几个参数有固定顺序:context、writer、logTag、模板字符串。logTag随便写个名字就行,主要用来区分日志来源。
5.2 Velocity转FreeMarker的要点
如果你现在项目里还是Velocity,想迁到FreeMarker,其实很简单。两者在Word XML场景里的架构是一样的,主要改三个地方。
第一,语法。#foreach($item in $list)换成<#list list as item>,#if($x)换成<#if x>,变量名去掉$前缀。FreeMarker里访问Map的key直接写${map.key},访问JavaBean的属性也写${bean.property},和Velocity的$!{bean.property}几乎一一对应。
第二,空值处理。Velocity的$!{x}在FreeMarker里换成${x!''}。你可以在Configuration里设置默认值处理策略,但最保险的做法还是每个可能为空的变量都带上!''。
第三,工具类方法。如果Velocity项目里用了$dateUtil.format($date)这种工具类,迁移时需要把工具方法注册成FreeMarker的TemplateMethodModel,或者在数据层提前把日期格式化成一个String字段。
从实操角度看,如果一个模板文件有一两百行,迁移大概需要半天到一天。迁移完之后,FreeMarker对模板语法的校验更严格,错误信息也更友好,整体维护体验会好不少。但没有特殊理由,也不建议专门为了“换引擎”去动老项目——能把现有逻辑跑稳,比什么都重要。
6. 数据准备与模板维护的工程化建议
6.1 数据模型设计:一切为了模板简单
用模板引擎做Word导出,最忌讳的就是把复杂逻辑写进模板。FreeMarker虽然可以写判断、循环、甚至自定义函数,但模板里逻辑太多,可读性和可维护性都会断崖式下降。
我的原则是:模板里只做展示,所有计算、格式化、默认值处理都在Java代码层完成。比如金额格式化,不要在模板里做,而是在封装data map的时候,就放一个已经是字符串的总金额字段;日期显示也提前格式化成yyyy年MM月dd日这种标题想要的格式;费率显示成3.50%这种,同样在代码层完成。
这样做的好处很实际:将来模板交给业务人员维护,他们看到的占位符永远是“数据已经准备好,拿来即用”的状态,不会被一堆<#if>和格式化函数绕晕。就算中间有复杂逻辑,也是Java代码里跑,出了问题可以单测。
6.2 模板版本管理与自动化测试
模板文件和代码一样,也会有改版。我在项目里会把模板docx存到resource目录或者配置中心,版本跟着代码走。每次改模板,都保留一个旧版本,至少保留最近两三版。这样线上出了问题,可以快速回滚到旧模板重新出文件,不用等业务重新给一份。
自动化测试这一块,很多人做导出功能容易忽略。我建议写若干个典型的测试数据,覆盖:
- 所有必填字段都有值
- 所有字段为空的极端情况
- 超长文本、带换行文本、带特殊字符文本
- 表格只有一行、几百行的情况
- 条件显示的分支全覆盖
测试的断言不一定要复杂到逐字比对,最基础的是用解压后的XML做schema校验,确认生成的docx能正常解压、XML格式没问题。更进一步可以断言XML里包含某些关键文本,比如合同编号:123456出现在渲染结果里。这个级别的自动化测试,能帮你挡住90%因模板改动引入的回归问题。
6.3 性能实测与优化方向
有人担心XML模板方案性能不行,毕竟每份文档都要解压、DOM解析、XPath遍历、模板渲染、重新打包。我的实测数据是:一份10页左右、带图表和明细表格的合同,整个导出流程在100~200毫秒之间。如果批量生成1000份,加个线程池,压到几十秒内完全没问题。
主要的性能瓶颈反而是DOM解析XML这一块,因为mergeRuns要对整个document.xml建DOM树。如果单份文档都到了秒级,可以先看看是不是模板里放了体积特别大的样式定义,或者是不是XPath表达式写得不够高效。另一个优化点是:如果同一个模板要生成几百份文档,可以把模板的Template对象缓存起来,不要每次都new Template。FreeMarker的模板解析本身也是开销,缓存能省下不少时间。
这套方案做到最后,你会发现自己已经从“写代码控制Word”变成了“搭好流水线填数据”。业务人员改模板,你新增或调整字段,两边各干各的,协作效率比POI硬编码时代高太多了。
7. 实测感受与最后的几点提醒
整个方案跑下来,我最深的体会是:模板引擎导出Word XML这条路,上限很高,但门槛比POI更隐蔽。它的坑不在于API记不记得住,而在于对docx结构有没有敬畏心。你一旦理解了w:p、w:r、w:t这三层结构,理解了zip里每个文件各自的职责,后面遇到问题基本都能顺着XML去排查,不需要查百度查半天。
关于工具选型,新项目用FreeMarker基本是共识,除非你团队里有人对Velocity熟到闭眼写模板。引擎只是手段,真正花时间的永远是模板设计、占位符规范和异常数据处理。
最后再分享一个小技巧:模板做完后,先用Word打开一次,另存为新docx,然后再拿这个新docx去做程序模板。原因是Word另存时会整理一遍XML结构,占位符被拆分的概率会低很多。这个动作很蠢但对减少问题真的很有效,我每次做模板都会保留这步。
工具链层面,建议准备一个快速解压/压缩docx的小工具脚本,方便随时检查生成的XML。排查问题时,能直接看到XML内容,比在Word里反复打开关闭效率高一个数量级。
