Java后端用FreeMarker将富文本HTML导出为Word docx的完整实践
做业务系统的时候只要涉及合同、报告、简历、审批单这类场景十有八九会碰到一个问题前端富文本编辑器里填得整整齐齐的内容到了 Java 后端导出 Word格式全乱图片消失甚至文档直接打不开。我接手的这个项目也一样标题写得很明确——“java freemarker 导出富文本到Word文档”。说白了就是要把富文本编辑器项目里用的是 Vue PC 端的富文本组件产出的 HTML 内容通过 FreeMarker 模板引擎套到 Word 模板里最终生成一个格式正常、图片能显示、段落逻辑清晰的 docx 文件。这个需求看起来简单网上搜一圈也能找到一堆“三步搞定导出 Word”的爽文但那些文章大多没有说透最核心的问题富文本本质上是一段 HTML而 docx 要求的是 WordprocessingML两者不是一回事。直接拿 FreeMarker 把 HTML 塞进 Word XML是行不通的。这篇文章我会把从模板设计、FreeMarker 集成、富文本 HTML 转 Word XML到最终打包成 docx 的完整过程讲清楚同时把我在实际项目中踩过的坑和排查方法都放出来适合正在做类似功能的 Java 后端同学参考。1. 项目整体设计思路与方案选型1.1 富文本导出到 Word为什么难先说问题的根源。前端富文本编辑器输出的是一段 HTML 字符串比如p这是一段b加粗/b文字/p。这种标签在浏览器里渲染得很漂亮但 Word 可不管 HTML。docx 文件本质上是一个 zip 压缩包里面最关键的是word/document.xml这个文件里保存的是 Word 自己的 XML 方言也就是 WordprocessingML。比如同样一句“加粗文字”在 document.xml 里长这样w:p w:r w:rPr w:b/ /w:rPr w:t加粗文字/w:t /w:r /w:p注意到区别了吗HTML 用的是bWord 用的是w:b/。所以“导出富文本到 Word”这个需求真正的技术难点不是 FreeMarker 怎么用而是怎么把 HTML 标签结构翻译成 WordprocessingML 结构。翻译对了模板填充才有意义翻译不对就算是电影里顶级黑客来操作导出的文件也是坏的。1.2 三条技术路线对比我在动手之前把市面上的方案都过了一遍常见的大概有三条路。第一条路是直接用 FreeMarker 渲染一个 HTML 文件然后把扩展名改成.doc。这个方案最省事Word 确实能打开但打开时会走 HTML 渲染模式样式兼容性很差字体、行距、页边距经常对不上做出来的文件也不像一个正经的 Word 文档更像是网页截图。第二条路是 FreeMarker 渲染 HTML再用 LibreOffice 或 OpenOffice 在服务器上无头转换成 docx。这个方案效果不错但服务器上要额外装软件导出接口的耗时也明显变长而且还涉及到进程管理和并发问题部署和运维成本偏高。第三条路是用 FreeMarker 生成 Word 模板需要的 document.xml 内容富文本区域单独转换成 WordprocessingML 片段后再注入进去。这条路最贴合“导出标准 docx”的目标格式可控性最高也是我最终采用的方案。三条路线的对比我用一张表总结一下方案实现复杂度格式还原度部署依赖适用场景HTML 改后缀低差无只求能打开、对格式要求极低的内部工具HTML LibreOffice 转换中中需要安装 LibreOffice富文本格式复杂、可接受转换耗时FreeMarker WordprocessingML高高无合同、报告等对格式有严格要求的业务文档1.3 最终方案与项目结构我的最终方案是把 docx 模板作为一个固定资源文件放在工程里模板中普通的业务字段用 FreeMarker 的${}语法占位富文本区域则用一组特殊的注释标记圈出来。导出时先让 FreeMarker 把普通字段处理完再用自定义转换器把富文本 HTML 转成 WordprocessingML 片段最后用字符串替换把富文本片段填充到注释标记的位置。这里有一个很重要的设计考虑为什么不直接把${richContent}放在模板里让 FreeMarker 处理因为富文本转换出来的内容往往包含多个w:p段落、图片节点、表格节点。如果直接让 FreeMarker 把这个字符串插到某个段落内部轻则 XML 结构嵌套错误重则整个文档损坏。用注释标记隔离出独立的“富文本区域”就可以绕过 FreeMarker 对 XML 节点结构的限制这也是我在多次踩坑后总结出来的稳妥做法。项目结构大致如下src/main/java com.example.wordexport controller/WordExportController.java service/WordExportService.java service/impl/WordExportServiceImpl.java util/RichTextToWordXml.java util/DocxTemplateProcessor.java resources/template/contract-template.docx依赖只有两个核心库FreeMarker 负责模板渲染Jsoup 负责解析富文本 HTML 并协助转换成 Word XML。这两个库都很轻量不会给现有项目增加太多成本。2. 环境准备与Word模板制作2.1 制作docx模板的基础步骤有好几个第一次接触这个方案的同学问过我模板到底怎么做总不能每次都用代码生成整个 document.xml 吧。实际上不需要我们可以用 Word 直接画模板然后做一次“手术”。第一步用 Word 新建一个文档把需要动态填充的字段用普通文本写进去比如合同编号__contractNo__、签署日期__signDate__。这里建议先用下划线包裹的临时占位符而不是直接写${contractNo}。原因是 Word 在保存 docx 时会自动对文本做一些格式化调整一个连续的${contractNo}可能会被拆散到多个w:r节点里解压后你会发现文本被切得七零八落到时候替换非常痛苦。用__contractNo__这种样子不容易被 Word 拆散等解压后再统一替换成 FreeMarker 的${contractNo}问题就绕过去了。第二步保存成 docx 文件然后用压缩工具7-Zip、WinRAR 或者直接改后缀用 Zip 解压都可以打开这个 docx找到word/document.xml。第三步用文本编辑器打开 document.xml把之前写的__contractNo__替换成${contractNo}。替换的时候记得确认它是否完整地位于同一个w:t节点里如果被拆开了要手动整理一下。第四步修改完成后再把 document.xml 放回 docx 里。这里要注意不要直接在解压目录里用右键“压缩成zip”再改后缀这样容易出错。推荐的做法是用代码读取原 docx只替换word/document.xml这一个条目这也是后面导出逻辑要做的核心操作。2.2 模板里两个最容易踩的坑模板制作阶段有两个坑几乎是所有人都会踩的。第一个是特殊字符没有被转义。Word 在生成 document.xml 时按理说会把、这些特殊字符自动转义成amp;、lt;。但有时候你从别的地方复制文本进 Word或者模板经过二次编辑就可能出现裸的。这种裸到了 XML 解析阶段就是致命错误轻则解析失败重则文档直接打不开。我自己的习惯是模板做完后用一段脚本检查 document.xml 里是否存在后面不跟amp;、lt;、gt;、quot;、apos;的情况。这段检查的成本很低但能避免导出时大面积报错。第二个是 Word 的自动更正会往 document.xml 里塞一堆奇奇怪怪的东西比如拼写检查标记、修订记录、书签。在做模板时建议把“文件 - 选项 - 校对”里的自动更正选项关掉减少冗余节点。不然你解压出来看 document.xml会发现大量无关的w:proofErr/、w:bookmarkStart/标签翻半天都找不到自己写的占位符在哪。还有一个小技巧在 Word 里排版模板时固定用“正文”样式来写占位字段。因为 FreeMarker 替换后文本的长度是动态的如果占位符所在的段落使用了 Word 的“标题 1”“标题 2”这些样式内容一长目录和文档结构会变得很奇怪。正文样式最稳妥。2.3 富文本占位区域的埋点方式富文本区域的标记我建议用 XML 注释来完成。在 document.xml 中找到你希望富文本内容出现的位置插入这样两个注释!--RICH_CONTENT_START-- w:p/ !--RICH_CONTENT_END--这里手动加一个空的w:p/只是为了占位置导出时整个注释块连同空段落都会被替换掉。选 XML 注释而不是普通的占位文本是因为注释在 Word 里不会显示也不会被 Word 解析成实际的段落节点最干净。实际操作中你可以在 Word 文档里先用一行“富文本占位”之类的文字占住位置保存解压后把包含这段文字的整个段落区域替换成上面的注释标记。这样处理之后模板里就存在一个清晰、可定位的“富文本插槽”。注意注释标记不要和 FreeMarker 的语法冲突FreeMarker 只会解析#...和${...}普通 HTML 注释会原样输出所以可以放心使用。3. 富文本转换与核心代码实现3.1 FreeMarker集成与基础配置先把 FreeMarker 配置好。因为是动态生成 document.xml我推荐用StringTemplateLoader来加载模板字符串而不是把模板放到文件系统。整个流程是先从模板 docx 中读取word/document.xml的内容把这段 XML 字符串当作 FreeMarker 模板处理完后再打包回 docx。FreeMarker 的基础配置代码import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.cache.StringTemplateLoader; import java.io.StringWriter; public class DocxTemplateProcessor { private static final Configuration CFG createConfiguration(); private static Configuration createConfiguration() { Configuration cfg new Configuration(Configuration.VERSION_2_3_32); cfg.setDefaultEncoding(UTF-8); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); cfg.setLogTemplateExceptions(false); cfg.setWrapUncheckedExceptions(true); cfg.setFallbackOnNullLoopVariable(false); return cfg; } public static String process(String templateXml, MapString, Object dataModel) throws Exception { StringTemplateLoader loader new StringTemplateLoader(); loader.putTemplate(document.xml, templateXml); CFG.setTemplateLoader(loader); Template template CFG.getTemplate(document.xml); StringWriter writer new StringWriter(); template.process(dataModel, writer); return writer.toString(); } }这里有几个配置值得解释一下。RETHROW_HANDLER表示模板渲染出错时直接把异常抛出来方便在日志里定位问题不要吞掉异常。fallbackOnNullLoopVariable设置为 false目的是让模板中如果引用了不存在的变量直接报错而不是静默跳过。在导出文档这种场景里一个数据字段缺失可能导致整个模板乱掉宁可失败也不要导出一份内容缺失的文档。模板 XML 里使用占位符时一定要记住加?xml转义w:t合同编号${contractNo?xml}/w:t因为实际数据里可能包含、等字符如果不转义生成的 XML 就是非法的。FreeMarker 内置的?xml就是为了干这个事的把转成lt;把转成amp;保证 XML 结构不坏。3.2 富文本HTML转WordprocessingML这是整个方案的核心。我把富文本编辑器输出的 HTML 结构挨个分析了一遍发现业务场景里常用的标签其实没几个p、br、strong、b、em、i、u、span、a、ul、ol、li、img以及内联样式里的字体大小、颜色、对齐方式。转换的整体思路是用 Jsoup 解析 HTML然后按标签递归生成对应的 WordprocessingML 字符串。先说一下常见标签的映射关系HTML 元素WordprocessingML 片段说明pw:p段落br/w:br/段内换行strong/bw:b/放在 rPr 中加粗em/iw:i/斜体uw:u w:valsingle/下划线span stylecolor:#FF0000w:color w:valFF0000/字体颜色文本节点w:t记得转义 XML转换器的骨架代码import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; public class RichTextToWordXml { public static String convert(String html) { Document doc Jsoup.parseBodyFragment(html); StringBuilder sb new StringBuilder(); for (Element bodyChild : doc.body().children()) { appendElement(sb, bodyChild); } return sb.toString(); } private static void appendElement(StringBuilder sb, Element el) { String tag el.tagName().toLowerCase(); switch (tag) { case p: appendParagraph(sb, el); break; case br: sb.append(w:rw:br//w:r); break; case strong: case b: appendWrappedText(sb, el, w:b/); break; case em: case i: appendWrappedText(sb, el, w:i/); break; case u: appendWrappedText(sb, el, w:u w:val\single\/); break; case img: appendImage(sb, el); break; default: // 未知标签递归处理子节点 for (Element child : el.children()) { appendElement(sb, child); } appendTextNodes(sb, el); break; } } }段落处理比较关键需要把段落属性和文字属性分开。比如前端编辑器常常会输出styletext-align: center;我们要把它转成 Word 的居中对齐private static void appendParagraph(StringBuilder sb, Element el) { sb.append(w:p); String align el.attr(style); if (align ! null align.contains(text-align:center)) { sb.append(w:pPrw:jc w:val\center\//w:pPr); } for (Element child : el.children()) { appendElement(sb, child); } appendTextNodes(sb, el); sb.append(/w:p); }文字运行的处理更繁琐一些。因为 HTML 可能是嵌套的比如pstrong重点span stylecolor:#FF0000红色/span/strong/p需要递归地把所有修饰属性收集起来生成w:rPr再包住文本节点。这里有个原则每遇到一个需要修饰的标签就在当前文本外层套一个w:r属性放w:rPr文本放w:t。如果多个样式叠加就多层嵌套。这样做虽然会产生一些冗余的w:r但 Word 完全认而且代码简单不易出错。还有一个基础但必须做的操作文本内容要转义。富文本编辑器里的文本可能包含、、直接拼到 XML 里会破坏结构。我会写一个escapeXml方法把、、、、都替换成对应的实体。不要用String.replace一个个替换效率低且容易漏直接用 Jsoup 的Entities.escape或者手写一个基于遍历的转换方法都行。3.3 组装数据模型、渲染并打包导出富文本转换完成之后就该把整条链路串起来了。导出的核心步骤分四步读取模板 docx、提取 document.xml 并用 FreeMarker 渲染、转换富文本并替换注释块、重新打包 docx。模板读取和重新打包的代码import java.io.*; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; public class WordExportService { public void export(MapString, Object dataModel, String richHtml, OutputStream outputStream) throws Exception { byte[] templateBytes loadTemplate(template/contract-template.docx); // 用 ZipInputStream 读取模板中的 document.xml String templateXml readDocumentXml(templateBytes); // 第一步FreeMarker 渲染普通字段 String processedXml DocxTemplateProcessor.process(templateXml, dataModel); // 第二步富文本 HTML 转 WordprocessingML String richXml RichTextToWordXml.convert(richHtml); // 第三步替换富文本注释块 processedXml processedXml.replaceAll( !--RICH_CONTENT_START--[\\s\\S]*?!--RICH_CONTENT_END--, java.util.regex.Matcher.quoteReplacement(richXml) ); // 第四步打包成新的 docx 输出 writeDocumentXml(templateBytes, processedXml, outputStream); } }重新打包这一步最忌讳的是把整个 docx 解压成目录改完 document.xml 再重新压缩。因为 docx 里除了 document.xml还有[Content_Types].xml、各个_rels下的关系文件、word/media下的图片等用命令行 zip 重压很容易弄丢目录结构或者搞乱压缩元数据。正确的做法是逐条复制原 zip 条目只对word/document.xml做替换private void writeDocumentXml(byte[] templateBytes, String newDocumentXml, OutputStream out) throws IOException { try (ZipInputStream zis new ZipInputStream(new ByteArrayInputStream(templateBytes)); ZipOutputStream zos new ZipOutputStream(out)) { ZipEntry entry; while ((entry zis.getNextEntry()) ! null) { if (word/document.xml.equals(entry.getName())) { zos.putNextEntry(new ZipEntry(entry.getName())); zos.write(newDocumentXml.getBytes(StandardCharsets.UTF_8)); } else { zos.putNextEntry(new ZipEntry(entry.getName())); byte[] buffer new byte[8192]; int len; while ((len zis.read(buffer)) ! -1) { zos.write(buffer, 0, len); } } zos.closeEntry(); } } }写到这里需要特别提醒一下replaceAll的坑。替换的富文本 XML 字符串中很可能包含$和\而在 Java 的String.replaceAll中替换文本里的$会被当成分组引用直接替换会把新内容搞坏。所以上面代码里用了Matcher.quoteReplacement(richXml)把富文本 XML 转为字面量这一步是从网上很多二手教程里学不到、但实际必踩的坑。关于 Controller 层的导出其实很简单就是设置好响应头把文件流传给前端PostMapping(/exportWord) public void exportWord(RequestBody ExportRequest request, HttpServletResponse response) throws Exception { MapString, Object dataModel buildDataModel(request); response.setContentType(application/vnd.openxmlformats-officedocument.wordprocessingml.document); response.setHeader(Content-Disposition, attachment; filename URLEncoder.encode(合同导出.docx, UTF-8)); wordExportService.export(dataModel, request.getRichContent(), response.getOutputStream()); }前端用 axios 请求时记得设置responseType: blob否则拿回来的是一堆乱码这个属于前后端联调的基本操作但值得提一句。4. 常见问题与排查技巧实录4.1 文档损坏打不开导出后的 docx 一打开就报“文件已损坏是否尝试修复”这个问题的出现频率最高。碰到这种情况我一般把导出的 docx 当成 zip 解压打开word/document.xml用 XML 格式化工具检查一遍结构。90% 的情况是 XML 语法错误比如某个w:p没有闭合、文本里有裸的字符、属性值没有加引号。还有一种隐蔽问题document.xml 里的命名空间前缀必须在根节点声明比如xmlns:whttp://schemas.openxmlformats.org/wordprocessingml/2006/main。如果我们在拼接富文本 XML 片段时凭空造了一个没有前缀的标签或者自己声明了一个错误的前缀文档就会损坏。我的建议是在拼接富文本片段时只用w:前缀并且不要在片段内部重复声明命名空间依靠 document.xml 根节点上已有的声明就能生效。另外如果富文本替换后出现了空字符串也就是注释块被替换成空内容可能导致w:body里出现连续两个相邻的块级元素这在结构上没问题但 Word 有时会报错。所以替换前最好做一次判断如果富文本为空就生成一个空段落w:p/兜底。4.2 富文本样式全部丢失如果你导出的文档没坏但是富文本的加粗、字体颜色、居中对齐全部丢了那问题基本出在转换器的标签处理上。最常见的原因是富文本编辑器输出的不是strong这种语义标签而是span stylefont-weight: bold;这种内联样式。如果你只处理了strong那span样式自然就丢了。解决方法是两个方向一是调整富文本编辑器的配置让它输出语义标签而不是内联样式二是在转换器里增加对style属性的解析。我更倾向于后者因为线上已经在用的编辑器不可能轻易改配置。解析style时我常用一个简单的方法读取el.attr(style)然后判断里面是否包含font-weight: bold、font-style: italic、text-decoration: underline等关键词命中哪个就往w:rPr里加对应属性。字体大小转换这里容易算错因为 HTML 里经常用的是pxWord 用的是半磅值。比如前端设置字号为 14px对应 Word 里的字号计算方式是px * 72 / 96得到磅值再乘以 2 得到半磅值。14px 大约是 10.5pt半磅值就是 21。所以在转换器里看到font-size: 14px要生成的是w:sz w:val21/和w:szCs w:val21/。sz是西文字号szCs是东亚字符字号两个最好都写上不然中文内容可能还是默认大小。4.3 图片不显示或导出卡顿富文本里带图片是常见需求也是最容易出问题的环节。如果把img标签直接丢掉Word 里就空了一块如果处理不当图片可能显示不出来甚至文档打不开。要在 docx 里正确显示图片需要同时做四件事把图片字节写入word/media/目录、在word/_rels/document.xml.rels里注册图片关系、在[Content_Types].xml里声明图片扩展名、在 document.xml 里生成w:drawing节点引用关系 ID。这四个环节少一个都不行。图片来源如果是data:image/png;base64,xxxx这种 base64 格式需要先解码成字节数组再写入 docx。图片关系 ID 不能随便写要读取原模板里已有的document.xml.rels找出当前最大的rIdN然后递增生成新的 ID。这个细节很容易漏漏了就会出现“文档能打开但图片位置是一个红叉”的情况。如果图片数量多导出接口可能会很慢。我遇到过富文本里塞了十几张高清大图的情况每张图一两兆导出的 docx 体积大不说网络传输也慢。建议在后端做一次图片压缩限制最大宽度超过 800px 的按比例缩小这样文档体积能小很多Word 打开也更流畅。4.4 常见问题速查表整理了一张速查表方便大家遇到问题的时候直接对号入座现象可能原因解决方案导出的 docx 提示损坏XML 结构错误、标签未闭合、文本未转义解压 document.xml 检查语法文本节点做 XML 转义富文本所有样式丢失只处理了语义标签没解析内联 style增加 style 属性解析支持 font-weight、color 等中文显示为默认字号只设置w:sz没设置w:szCsszCs 一起设置图片红叉图片关系未注册或媒体文件未写入检查document.xml.rels和word/media/图片太大导出慢富文本里嵌套高清原图导出前压缩图片限制宽度文本被拆成多段无法替换Word 自动拆分w:r模板里先写临时占位符解压后再替换为${}replaceAll后富文本内容缺失替换文本包含$或\用Matcher.quoteReplacement处理替换文本FreeMarker 变量不存在报错数据模型和模板参数没对齐开启异常抛出并在日志中查看具体缺失变量5. 实操心得与后续扩展5.1 我踩过的坑和坚持的原则这个功能我前前后后改过三版第一版就是网上最常见的“FreeMarker 渲染 HTML 改后缀”上线当天就被业务方打回来了说文件名字是 doc但排版完全不是那么回事。第二版我老老实实做了 WordprocessingML 转换但最初把富文本放在w:t里直接插导致多个段落全挤在一行里换行全丢了。直到第三版改成“注释标记占位 段落级替换”才算真正稳定下来。我现在做这块功能有一个固定原则模板里绝不直接插入 HTML也绝不把富文本 HTML 塞进 FreeMarker 的变量里让模板引擎去处理。富文本必须要走独立的转换链路转成合法的 WordprocessingML 片段再注入到模板指定位置。这个原则让我避免了很多看起来莫名其妙的问题。另外一个坚持是导出功能一定要做自动化验证。我写了一个小的测试用例每次跑完导出后用 Java 自带的ZipFile打开生成的 docx确认[Content_Types].xml、word/document.xml都存在再用一个简单的 XML 解析器对 document.xml 做合法性校验。这一步能拦截掉大部分结构性错误比每次都手动打开 Word 检查高效得多。5.2 可以继续扩展的方向这个方案后续还有一些可以继续深挖的地方。一个是复杂表格的支持现在富文本里的table标签我还没有完全转换如果业务上经常有表格型富文本可以参照 HTML 表格到 Word 表格的映射规则把table/tr/td转换成w:tbl/w:tr/w:tc工作量不大但很繁琐。另一个是动态插入图片场景下的占位符处理比如模板里要求每个段落都附一张图片这种需求用当前的方案也能做但模板结构要设计得更细一些。还有批量导出场景。如果你的系统需要一次导出几十份不同数据的 Word 文档建议把“模板渲染”和“文件打包”解耦先并行处理所有文档的 XML 渲染再统一写文件能明显提升吞吐量。再配合消息队列做异步导出用户不需要一直等在页面上。最后顺手分享一个小技巧如果只是想在服务器上排查导出的 docx 是否正确可以把 docx 后缀改成 zip 解压后直接用浏览器打开 document.xml浏览器会以 XML 树的形式展示结构比文本编辑器更容易发现问题。这个办法我用了好几年一直觉得挺管用的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →