继续潜水

导航

 
FreeMarker导出Word时XML特殊字符转义问题解决方案

问题背景

在使用FreeMarker模板引擎导出Word文档(.doc格式,实际是XML格式)时,如果数据中包含XML特殊字符(如 <、>、&、"、'),会导致生成的Word文档无法正常打开或显示异常。 例如,检测数据中有这样的值:
value1=不透水性(< 0.075)
其中的 < 符号会被Word解析为XML标签的开始,导致文档结构错误。

问题原因

Word的.doc格式本质上是XML文档,而 <、>、& 等字符在XML中有特殊含义:
字符XML含义必须转义为
< 标签开始 &lt;
> 标签结束 &gt;
& 实体引用开始 &amp;
" 属性值引号 &quot;
' 属性值引号 &apos;
如果这些字符出现在内容中而没有转义,会破坏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("&", "&amp;");  // 必须第一个,否则其他转义结果中的&会被误转义
    XML_ESCAPE_MAP.put("<", "&lt;");
    XML_ESCAPE_MAP.put(">", "&gt;");
    XML_ESCAPE_MAP.put("\"", "&quot;");
    XML_ESCAPE_MAP.put("'", "&apos;");
}
重要说明:必须使用LinkedHashMap保证遍历顺序,& 符号必须第一个被转义。如果顺序错误,比如先转义 < 为 &lt;,再转义 & 时,&lt; 中的 & 也会被转义,变成 &amp;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.*;

/**
 * 处理特殊字段,把 "<" 转义成 "&lt;" 等
 * @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=不透水性(&lt; 0.075)
value2=密度(&gt;ss)
value3=-!@#¥~%……&amp;*()《》&lt;&gt;?、/&apos;&quot;【】[]{}
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);
}

总结

  1. 方案一(全局配置):适用于纯文本简单场景,一行配置解决
  2. 方案二(手动转义):适用于复杂场景,支持List<Map>嵌套、图片XML、富文本等
    • 必须使用LinkedHashMap保证转义顺序
    • & 符号必须第一个转义
    • 可通过前缀列表跳过不需要转义的XML片段
  3. 方案三(CDATA区块):简单直接,模板中包裹即可
    • 无需Java代码处理
    • 内容原样输出,无需转义
    • 需逐个字段添加CDATA,维护成本较高
推荐选择:
  • 新项目:优先使用方案三(CDATA)
  • 已有项目:推荐方案二,灵活性高,不需要修改模板
  • 纯文本简单场景:可以使用方案一

参考链接:
posted on 2026-08-03 13:23  继续潜水  阅读(0)  评论(0)    收藏  举报