FreeMarker导出Word时XML特殊字符转义问题解决方案
问题背景
在使用FreeMarker模板引擎导出Word文档(.doc格式,实际是XML格式)时,如果数据中包含XML特殊字符(如 <、>、&、"、'),会导致生成的Word文档无法正常打开或显示异常。 例如,检测数据中有这样的值:
value1=不透水性(< 0.075)
其中的 < 符号会被Word解析为XML标签的开始,导致文档结构错误。
问题原因
Word的.doc格式本质上是XML文档,而 <、>、& 等字符在XML中有特殊含义:
| 字符 | XML含义 | 必须转义为 |
| < |
标签开始 |
< |
| > |
标签结束 |
> |
| & |
实体引用开始 |
& |
| " |
属性值引号 |
" |
| ' |
属性值引号 |
' |
如果这些字符出现在内容中而没有转义,会破坏XML结构。
解决方案
方案一:全局配置XMLOutputFormat(适用于简单场景)
FreeMarker提供了XMLOutputFormat,可以自动对输出内容进行XML转义。
import freemarker.core.XMLOutputFormat;
import freemarker.template.Configuration;
static {
configuration = new Configuration(Configuration.VERSION_2_3_30);
configuration.setClassForTemplateLoading(WordUtil.class, "/exportTemplates");
configuration.setDefaultEncoding("UTF-8");
// 开启XML输出格式,自动转义特殊字符
configuration.setOutputFormat(XMLOutputFormat.INSTANCE);
}
优点:
缺点:
- 对于动态拼接的HTML/XML片段会误转义
- 不适合包含图片XML、富文本等复杂内容
方案二:手动转义(适用于复杂场景)
当数据中包含动态插入的XML片段(如图片、富文本)时,全局转义会破坏这些内容。此时需要精确控制哪些内容需要转义。
1. 定义转义映射表(使用LinkedHashMap保证顺序)
import java.util.LinkedHashMap;
import java.util.Map;
/**
* XML转义字符映射表(使用LinkedHashMap保证顺序,&必须第一个转义)
*/
private static final Map<String, String> XML_ESCAPE_MAP = new LinkedHashMap<>();
static {
XML_ESCAPE_MAP.put("&", "&"); // 必须第一个,否则其他转义结果中的&会被误转义
XML_ESCAPE_MAP.put("<", "<");
XML_ESCAPE_MAP.put(">", ">");
XML_ESCAPE_MAP.put("\"", """);
XML_ESCAPE_MAP.put("'", "'");
}
重要说明:必须使用LinkedHashMap保证遍历顺序,& 符号必须第一个被转义。如果顺序错误,比如先转义 < 为 <,再转义 & 时,< 中的 & 也会被转义,变成 &lt;,导致页面显示错误。
2. 定义不需要转义的XML前缀列表
private static final String IMG_XML_PREFIX = "<w:r><w:rPr>...</w:rPr><w:pict><w:binData>";
/**
* 不需要转义的XML前缀列表
*/
private static final List<String> XML_PREFIX_LIST = Arrays.asList(
IMG_XML_PREFIX, // 图片XML片段
"<w:trHeight" // 表格行高XML
);
3. 转义处理方法
import lombok.extern.slf4j.Slf4j;
import java.util.*;
/**
* 处理特殊字段,把 "<" 转义成 "<" 等
* @param key 字段名
* @param value 字段值
* @param replaceMap 替换数据map
*/
public static void specialTranslationProcessing(String key, Object value, Map<String, Object> replaceMap) {
// 不需要转义的XML前缀列表
List<String> xml_prefix_list = Arrays.asList(IMG_XML_PREFIX, "<w:trHeight");
if (value instanceof String) {
String strVal = (String) value;
// 如果是以 "_rich" 结尾的富文本字段,或包含不需要转义的XML前缀,跳过
if (key.endsWith("_rich") || xml_prefix_list.stream().anyMatch(strVal::contains)) {
replaceMap.put(key, strVal);
} else {
// 其他字符串需要转义特殊字符
String escaped = strVal;
boolean hasEscape = false;
for (Map.Entry<String, String> entry : XML_ESCAPE_MAP.entrySet()) {
if (escaped.contains(entry.getKey())) {
escaped = escaped.replace(entry.getKey(), entry.getValue());
hasEscape = true;
}
}
if (hasEscape) {
log.info("String转义字符: {} -> {}", strVal, escaped);
}
replaceMap.put(key, escaped);
}
} else if (value instanceof List) {
// 处理List类型,遍历内部的Map
List<?> list = (List<?>) value;
List<Map<String, Object>> processedList = new ArrayList<>();
for (Object item : list) {
if (item instanceof Map) {
Map<String, Object> itemMap = (Map<String, Object>) item;
Map<String, Object> processedMap = new HashMap<>();
for (Map.Entry<String, Object> entry : itemMap.entrySet()) {
String itemKey = entry.getKey();
Object itemValue = entry.getValue();
if (itemValue instanceof String) {
// 检查是否包含不需要转义的XML前缀
if (xml_prefix_list.stream().anyMatch(e -> itemValue.toString().contains(e))) {
log.info("List内不转义字符: {}", itemValue.toString());
processedMap.put(itemKey, itemValue);
} else {
// 对Map内部的字符串值进行转义处理
String strVal = (String) itemValue;
String escaped = strVal;
boolean hasEscape = false;
for (Map.Entry<String, String> escapeEntry : XML_ESCAPE_MAP.entrySet()) {
if (escaped.contains(escapeEntry.getKey())) {
escaped = escaped.replace(escapeEntry.getKey(), escapeEntry.getValue());
hasEscape = true;
}
}
if (hasEscape) {
log.info("List内转义字符: {} -> {}", strVal, escaped);
}
processedMap.put(itemKey, escaped);
}
} else {
processedMap.put(itemKey, itemValue);
}
}
processedList.add(processedMap);
} else {
processedList.add((Map<String, Object>) item);
}
}
replaceMap.put(key, processedList);
} else {
// 其他类型直接放入
replaceMap.put(key, value);
}
}
4. 调用示例
Map<String, Object> replaceMap = new HashMap<>();
replaceMap.put("taskNo", "20230128");
replaceMap.put("detectionList", detectionList);
// 对replaceMap中的每个字段进行处理
for (Map.Entry<String, Object> entry : replaceMap.entrySet()) {
specialTranslationProcessing(entry.getKey(), entry.getValue(), replaceMap);
}
// 使用处理后的replaceMap导出Word
Template template = configuration.getTemplate(templateFileName);
template.process(replaceMap, response.getWriter());
方案三:使用CDATA区块(推荐)
在XML模板中,可以使用CDATA区块包裹内容,CDATA中的内容会被当作纯文本处理,不会被解析为XML标签。
模板写法:
<!-- 不使用CDATA,特殊字符会导致XML解析错误 -->
<w:t>${item.value1!""}</w:t>
<!-- 使用CDATA,内容原样输出,无需转义 -->
<w:t><![CDATA[${item.value1!""}]]></w:t>
实际示例:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document>
<w:body>
<w:tbl>
<#list detectionList as item>
<w:tr>
<w:tc>
<w:p>
<w:r>
<w:t><![CDATA[${item.value1!"/"}]]></w:t>
</w:r>
</w:p>
</w:tc>
<w:tc>
<w:p>
<w:r>
<w:t><![CDATA[${item.value2!"/"}]]></w:t>
</w:r>
</w:p>
</w:tc>
</w:tr>
</#list>
</w:tbl>
</w:body>
</w:document>
优点:
- 最简单,无需Java代码处理
- 内容原样输出,无需转义
- 支持所有特殊字符,包括 < > & " ' 等
缺点:
- CDATA不能嵌套,内容中不能包含 ]]> 字符串
- 需要修改模板文件,逐个字段添加CDATA包裹
- 需要指定某些变量去单独增加CDATA,维护成本较高
注意事项:
- CDATA内容中不能包含 ]]> ,否则会提前结束CDATA区块
- 如果数据中可能包含 ]]> ,需要使用方案二手动转义
方案对比
| 对比项 | 方案一 | 方案二 | 方案三(CDATA) |
| 实现复杂度 |
简单,一行配置 |
需编写处理方法 |
简单,模板修改 |
| 适用场景 |
纯文本内容 |
包含图片、富文本等复杂内容 |
所有场景 |
| 灵活性 |
低,全局生效 |
高,可精确控制 |
中,按字段控制 |
| 维护成本 |
低 |
中 |
中,需逐字段修改 |
| 代码侵入 |
无 |
需要Java处理 |
无 |
| 模板修改 |
不需要 |
不需要 |
需要 |
实际案例
假设导出检测报告,数据如下:
List<Map<String, Object>> detectionList = new ArrayList<>();
Map<String, Object> item = new HashMap<>();
item.put("value1", "不透水性(< 0.075)"); // 包含 < 符号
item.put("value2", "密度(>ss)"); // 包含 > 符号
item.put("value3", "-!@#¥~%……&*()《》<>?、/'\"【】[]{}"); // 包含多种特殊字符
item.put("rowHeight", "<w:trHeight w:val=\"323\" w:h-rule=\"exact\"/>"); // XML片段,不转义
detectionList.add(item);
方案二处理后:
// 转义前
value1=不透水性(< 0.075)
value2=密度(>ss)
value3=-!@#¥~%……&*()《》<>?、/'"【】[]{}
rowHeight=<w:trHeight w:val="323" w:h-rule="exact"/> (不转义)
// 转义后
value1=不透水性(< 0.075)
value2=密度(>ss)
value3=-!@#¥~%……&*()《》<>?、/'"【】[]{}
rowHeight=<w:trHeight w:val="323" w:h-rule="exact"/> (保持不变)
方案三(CDATA)模板:
<w:t><![CDATA[${item.value1!"/"}]]></w:t>
<w:t><![CDATA[${item.value2!"/"}]]></w:t>
<w:t><![CDATA[${item.value3!"/"}]]></w:t>
<!-- rowHeight是XML片段,不使用CDATA -->
<w:t>${item.rowHeight!""}</w:t>
在Word中打开后,两种方案都会正确显示为:
value1=不透水性(< 0.075)
value2=密度(>ss)
value3=-!@#¥~%……&*()《》<>?、/'"【】[]{}
特殊字段处理
对于包含图片XML或富文本的字段,可以通过特定规则跳过转义:
// 定义不需要转义的XML前缀列表
List<String> xml_prefix_list = Arrays.asList(
"<w:r><w:rPr>...<w:pict>", // 图片XML片段
"<w:trHeight" // 表格行高XML
);
// 检查是否需要跳过转义
if (key.endsWith("_rich") || xml_prefix_list.stream().anyMatch(strVal::contains)) {
// 不转义,直接使用
replaceMap.put(key, strVal);
}
总结
- 方案一(全局配置):适用于纯文本简单场景,一行配置解决
- 方案二(手动转义):适用于复杂场景,支持List<Map>嵌套、图片XML、富文本等
- 必须使用LinkedHashMap保证转义顺序
- & 符号必须第一个转义
- 可通过前缀列表跳过不需要转义的XML片段
- 方案三(CDATA区块):简单直接,模板中包裹即可
- 无需Java代码处理
- 内容原样输出,无需转义
- 需逐个字段添加CDATA,维护成本较高
推荐选择:
- 新项目:优先使用方案三(CDATA)
- 已有项目:推荐方案二,灵活性高,不需要修改模板
- 纯文本简单场景:可以使用方案一
参考链接: