
说实话我入行这些年接过的导出需求没一百也有八十但导出富文本到Word这个需求每次遇到都觉得没那么简单。前端编辑器里看着挺好的排版一到Word里就乱成一片要么图片没了要么表格挤成一坨要么直接打不开文件。后来我把方案沉淀成一套相对稳定的做法核心思路就是用Java FreeMarker模板引擎把富文本编辑器产出的HTML内容转成Word能认的XML结构再通过模板渲染出doc格式文档。今天这篇文章就围绕这个方案把技术选型、模板设计、富文本转换、图片处理这些环节一一拆开聊顺便把踩过的坑也一并交代清楚。这个方案目前我用到过的场景挺多的企业内部工单系统把工单详情导出成带排版的Word送给客户确认、后台管理系统的商品详情备份、运营人员把编辑好的富文本活动方案固化成Word存档。凡是网页上能看的格式要原样搬进Word里的需求这套思路基本都能覆盖。适合的人群是已经在用Java做Web开发、项目里接入了富文本编辑器、想找一种稳定方案把富文本内容导出成Word文档的开发者。不夸张地说学会这套方案以后遇到类似需求你心里就有底了。1. 为什么选了FreeMarker这条路1.1 先说结论这是够用、可控、好维护的组合导出Word文档市面上其实有好几条路可以走。我最早用的是Apache POI直接代码里逐行构建表格、段落、样式好处是灵活坏处是写起来太痛苦——富文本内容里光是段落就有几十种排列组合图片位置、表格嵌套、列表层级都要用代码控制写完自己都不想看第二遍。后来也试过用HTML直接改后缀名骗Word打开这个方法简单是真简单但每次打开都提示格式和扩展名不匹配用户体验很差根本不敢用在正式业务里。FreeMarker方案的本质是把Word当模板来渲染。你在Word里把整个文档结构画好该固定的地方固定该填充内容的地方留好占位符然后另存为XML格式再把XML内容嵌入到FreeMarker模板文件.ftl里。Java侧只需要构造好数据模型由FreeMarker把动态内容填充进去最终输出成Word能识别的XML文档。这样带来的好处很明显页面排版在Word里就能预览不用在代码里一点点调坐标和像素业务人员改个模板甚至不需要经过开发直接改Word再另存为XML就行代码量也大幅减少维护成本很低。1.2 技术原理Word文档到底是怎么存储的你要理解这个方案为什么成立得先搞清楚Word的底层存储机制。一个.docx文件其实就是一个zip压缩包里面是一堆XML文件和资源文件Word打开文件时再把这些XML解析成你看到的页面。.doc格式稍微特殊一点它是二进制格式但Word 2003之后其实也支持一种Word 2003 XML Document格式本质上就是一份XML文本Word能直接打开后缀名用.doc就能关联到Word程序。这就带来一个关键思路我们完全可以用程序生成一份结构合法、内容完整的Word XML文档只要标签、命名空间这些写对了Word就能正常打开。而FreeMarker模板引擎恰好擅长处理这种文本模板 动态数据的场景。它在渲染过程中把模板里的占位符替换成真实数据其他内容原样保留对于Word XML来说就是静态骨架不变、动态内容填入。这里要注意一个容易忽略的点Word XML里对特殊字符有严格要求比如内容里出现 符号必须写成 出现 符号必须写成 。你在模板里直接写死的内容无所谓但从富文本编辑器拿过来的内容里面满是HTML标签如果直接塞进模板轻则Word报错重则整份文件废掉。所以后面我会专门讲如何做富文本的清洗和转换。2. 项目准备与环境搭建2.1 依赖清单与版本选择开始之前先规划好依赖。我用的是Maven项目需要在pom.xml里引入FreeMarker。版本选择有个建议尽量用新版本原因很简单——老版本的FreeMarker在语法解析和命名空间处理上多多少少有点边界问题新版本不仅修复了bug还增加了一些便利的语法特性比如 ?html 内置指令在模板里处理HTML转义就很方便。dependency groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.32/version /dependency除了FreeMarker还需要一个HTML解析工具。富文本编辑器比如wangEditor、UEditor、Tinymce输出的内容是一段HTML字符串里面有、 、 、各种标签但Word XML要求的是 w:p、w:r、w:tbl 这类结构必须做一次HTML语义到Word语义的翻译。我自己用的是Jsoup它不仅能解析HTML还能用类似jQuery的选择器语法提取和操作节点在转换环节非常顺手。dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.16.2/version /dependency项目结构上我习惯把模板放在 resources/templates 目录下跟代码分开管理。这样换模板不用重新编译部署的时候单独更新模板文件就行。2.2 富文本内容从哪里来富文本的内容来源通常是前端富文本编辑器提交过来的HTML片段。比如你在wangEditor里写了一篇带标题、图片、表格和超链接的文章编辑器提交的内容可能是这样h2项目实施方案/h2 p本次项目strong核心目标/strong是完成系统升级详情如下/p img srcdata:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg alt架构图 / table border1 trtd模块/tdtd负责人/td/tr trtd订单系统/tdtd张三/td/tr /table p有任何问题请联系a hrefmailto:testexample.com项目组/a/p这里要注意图片部分富文本编辑器为了省事通常会把图片转成base64编码的数据URI直接嵌入在HTML里。这种内容存数据库很方便但导出Word时必须先把base64解出来还原成图片文件否则Word里面会显示成一片空白或者裂图。具体处理方式我放到后面详细讲这里先提个醒。从数据库或后端接口拿到这段HTML字符串后建议先做一次后端清洗。富文本编辑器产出的内容有一些脏数据比如空段落、无效的样式class、内联的style属性里带着奇怪的字体名这些如果不处理转换出来的Word文档排版会很难看。我一般在导出前调用一个工具方法把空这种段落过滤掉把连续多个空格替换成 避免Word把它们合并。3. 核心实现富文本怎么安全地塞进Word模板3.1 模板设计技巧在word里画好龙骨再导成xml最稳的模板制作流程是先画好Word文档再转换成XML最后改造成FreeMarker模板。你不需要直接在.ftl文件里手写那密密麻麻的XML标签那样既不直观又容易出错。具体操作是这样的打开Word新建一份空白文档把标题、落款、签名位这些固定内容先排版好。需要动态插入富文本的位置先放一个占位符比如在那一行写{{richContent}}方便后面定位。文件另存为格式选择Word 2003 XML文档。Word会生成一个.xml文件里面是完整的文档结构。用文本编辑器打开这个XML文件你会看到一长串的 w:document、w:body、w:p 标签。找到刚才放的{{richContent}}所在位置把它所在的完整段落结构删掉替换成FreeMarker模板指令${richContent}。把这个文件改成 .ftl 后缀放到项目的模板目录下。这里有个细节值得注意Word生成的XML文件头部会有 这个声明保留着没问题。但里面偶尔会夹带一些Word特有的配置节点比如 w:docPr、w:rPr 里的字体定义这些不用去动保留着反而能让导出效果更接近原Word排版。一个实用的技巧是在Word里把页面设置纸张大小、页边距、页眉页脚都编排好再导出XML。这样后续每个用模板生成的Word文档页面设置都是一致的不用在Java代码里去处理页面尺寸问题。3.2 富文本转换的三个关键点拿到富文本HTML字符串后不能直接塞进模板里得先做一层翻译。我封装了一个 RichTextToWordXmlConverter 工具类核心工作就是把HTML节点逐层转换成Word XML节点。这里说三个最关键的处理点。第一段落标签的映射。HTML里的标签对应Word XML里的 w:p。问题在于富文本编辑器产出的段落内容内部还嵌套着、、这些行内标签这些需要映射成Word的 w:rrun节点。我的做法是用Jsoup遍历HTML节点的children碰到文本节点直接生成 w:t 包起来碰到行内标签则根据标签类型生成带样式属性的 w:rPrrun properties把加粗、斜体、颜色、字号这些信息填进去。第二超链接的处理。HTML里的 Word XML里对应的结构比较复杂需要用到 w:hyperlink 节点并且要在文档的rels文件里注册关系。我这边简化的处理方案是把超链接转成一个普通段落文字用蓝色加下划线样式模拟链接效果这样虽然不带有真实的跳转功能但视觉效果基本一致。如果业务上必须保留可点击跳转那就需要额外操作[Content_Types].xml和word/_rels/document.xml.rels把URL关系注册进去工作量会大不少。第三特殊字符的转义。HTML里经常出现 不间断空格、与符号、中文引号等。这些字符在HTML上下文里合法但在Word XML的 w:t 节点里有些字符必须用XML实体表示。我在工具类里写了一个cleanText方法把 转成 、 转成 、 转成 同时把连续空格转成 。这一步看似简单却是最容易踩坑的地方——漏掉一个 就可能导致整个Word文档打不开。3.3 图片处理base64字符串怎么回到word里图片是我当初踩坑最多的地方。富文本编辑器上传的图片分几种情况一种是已经上传到服务器HTML里的img标签src是完整的URL另一种是编辑器直接把图片转成base64嵌在HTML里。第一种比较好处理模板里直接用URL就能显示但要求目标机器能访问到那个URL否则Word打开时图片加载不出来。第二种就必须先解码把base64字符串还原成图片文件再想办法让Word引用它。我的做法是在转换器里遇到 标签时先判断src的类型。如果是base64数据URI就截取逗号后面的部分用Base64解码得到字节数组写入到项目配置好的临时目录生成一个随机文件名比如uuid.png。然后把图片相对路径记录下来传给模板渲染模块。最终在Word模板里图片对应的结构是这样的w:pict v:shape id图片唯一ID type#_x0000_t75 stylewidth:100pt;height:80pt v:imagedata src图片相对路径 o:title/ /v:shape /w:pict注意这里的 src 属性我测试过用相对路径相对于doc文件的路径是最稳的。如果你生成Word文档后把doc文件和图片文件夹保持同一个相对层级Word就能正常找到图片。有些方案喜欢把图片base64直接嵌到XML里用 w:binData 节点存二进制但我实测下来图片一多文件体积会爆炸而且Word打开速度明显变慢所以不推荐。图片宽高的处理也要额外注意。富文本编辑器里的img标签通常带width和height属性单位是像素。Word XML里v:shape的width和height单位是pt磅所以要做一次换算1像素约等于0.75磅。我在工具类里是这样算的widthPt widthPx * 0.75。如果img标签没有宽高属性我会默认给一个100pt × 75pt的尺寸避免图片显示过大或过小。3.4 用FreeMarker合并输出的完整代码数据模型和模板准备好后最后一步是用FreeMarker把数据合并生成最终的Word文档。我给大家看一段可以直接用的代码这段代码我用在好几个项目里基本稳定。import freemarker.template.Configuration; import freemarker.template.Template; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; public class WordExportService { public void exportWord(MapString, Object dataModel, String templateName, String outputPath) { try { // 创建FreeMarker配置指定模板目录 Configuration config new Configuration(Configuration.VERSION_2_3_32); config.setDefaultEncoding(UTF-8); config.setClassForTemplateLoading(WordExportService.class, /templates); // 加载模板 Template template config.getTemplate(templateName); // 输出到文件 try (Writer writer new BufferedWriter( new OutputStreamWriter(new FileOutputStream(outputPath), StandardCharsets.UTF_8))) { template.process(dataModel, writer); } } catch (Exception e) { throw new RuntimeException(生成Word文档失败, e); } } }调用侧的数据模型组装关键是把前面转换好的富文本XML片段塞进去public void buildDataAndExport(String richTextHtml) { // 1. 富文本转换为Word XML RichTextToWordXmlConverter converter new RichTextToWordXmlConverter(); String wordXml converter.convert(richTextHtml); // 2. 组装数据模型 MapString, Object dataModel new HashMap(); dataModel.put(title, 2024年Q1项目总结报告); dataModel.put(author, 张三); dataModel.put(date, 2024-04-01); dataModel.put(richContent, wordXml); // 3. 导出 exportWord(dataModel, report.ftl, /tmp/report.doc); }有一点必须反复强调FreeMarker模板渲染时如果数据模型里的某个key不存在默认会直接报错。所以富文本转换后的XML要确保dataModel里的key和模板里的占位符完全一致。同时注意富文本内容里如果包含FreeMarker的保留字符比如 ${、#有可能被FreeMarker误解析这种情况我建议把富文本XML字符串塞进数据模型前先做一层转换把 $ 替换成 ${把 替换成 。虽然富文本转出来的XML里一般不会有这些问题但防患于未然总没错。4. 常见问题与排查技巧4.1 模板报错rtf or template file parsing errorFreeMarker加载模板的时候最常见的报错是 freemarker.core.ParseException: Encountered ... 这种。我在刚接触这个方案时就被它折磨过。原因通常是模板文件里含有FreeMarker不认识的标签语法比如Word导出的XML里可能有 w:br/ 这种自闭合标签FreeMarker解析正常但如果XML里出现了 # 或者 ${ 这种组合字符就会被FreeMarker当成指令来解析结果它又不认识这个指令直接报错。排查方法先用文本编辑器打开.ftl文件搜索所有 # 和 ${ 出现的位置看看是不是模板的动态数据占位符。如果是误报就需要手动改掉。特别是富文本内容里可能包含CSS样式代码里面很可能出现 { 和 }这些地方如果被FreeMarker当成插值指令处理也会报错。我的规避方案是能用数据模型传递的内容绝不写死在模板里模板里只保留静态的Word XML标签和真实的FreeMarker指令。4.2 导出后表格歪了、样式丢了富文本里的表格转成Word后经常出现列宽不对、边框消失这些问题。根本原因在于富文本编辑器里的表格CSS样式比如border1、stylewidth: 100%和Word XML的表格定义机制完全两码事。富文本编辑器里的表格往往依赖内联style属性控制边框、背景色、列宽而Word XML里需要明确指定 w:tblPr、w:tblBorders、w:tblW 这些节点。我给的建议是在转换器里对表格做专门处理。遇到标签时先解析它的style属性把border、width、background-color这些关键样式提取出来映射成对应的Word表格属性。单元格的处理也同理的colspan、rowspan属性要转换成Word XML里的 w:gridSpan 和 w:vMerge 节点。这块逻辑相对繁琐属于干一次就不想再干第二次的活但做完之后表格导出的稳定性确实提升很多。还有一个细节Word默认的表格样式可能没有边框导出后看着像没表格线。解决方式很简单在模板的表格属性里显式定义边框样式w:tblBorders w:top w:valsingle w:sz4 w:color000000/ w:left w:valsingle w:sz4 w:color000000/ w:bottom w:valsingle w:sz4 w:color000000/ w:right w:valsingle w:sz4 w:color000000/ w:insideH w:valsingle w:sz4 w:color000000/ w:insideV w:valsingle w:sz4 w:color000000/ /w:tblBorders4.3 图片加载不出来图片不显示是导出方案里被问得最多的一个问题。我总结下来原因有三个。第一个模板里图片的 src 路径写成了绝对路径而且这个路径在打开Word的机器上不存在。比如你在服务器上生成文档图片路径写的是 /tmp/xxx.png这个路径只在服务器有意义用户下载到本地打开就找不到了。解决办法是把图片和doc文件打包在一起或者用相对路径。第二个原因图片的base64没有正确解码。我见过有些同学直接用字符串截取的方式处理data URI结果把前缀data:image/png;base64,也一起写到了图片文件里这样生成的图片文件是损坏的。正确做法一定是先 split(,) 取第二部分再做Base64解码。第三个原因Word XML里图节点写法不对。如果你直接把 标签塞进了Word XMLWord是识别不了的。图片必须用 w:pict 包裹 v:shape 再指向 v:imagedata这三个节点缺一不可。这块我在前面已经给出标准写法Copy过去直接用就行。4.4 导出的word打开提示文件损坏或乱码这类问题十有八九是编码或特殊字符引起的。文件损坏先检查生成的XML是不是格式合法有一个最土的排查方法把生成的.doc文件后缀改成.xml用浏览器打开如果浏览器提示XML解析错误就说明格式有硬伤浏览器还会定位到具体行和列排查效率很高。常见原因有两个一是富文本内容里带上了 符号没有转义二是FreeMarker渲染时把XML声明 中的 ? 跟FreeMarker指令搞混了。第二个问题处理方式是在模板里改用 [%...%] 语法或者把XML声明去掉Word其实不强制要求XML声明。乱码问题则简单得多基本都是编码不一致导致的。确保三处编码统一模板文件存的是UTF-8、FreeMarker配置里 setDefaultEncoding(UTF-8)、输出Writer的编码也是UTF-8。三处统一后中文乱码基本不会出现。4.5 文件名中文乱码生成Word文档后通过HTTP下载到用户本地文件名如果带中文经常会出现乱码。这个其实跟FreeMarker没关系是HTTP响应头的问题。需要在下载接口里对文件名做URL编码处理。我惯用的写法是String fileName 项目实施方案.doc; String encodedFileName URLEncoder.encode(fileName, UTF-8).replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment; filename*UTF-8 encodedFileName);这里用 filename*UTF-8 这种RFC 5987规定的格式兼容主流浏览器。老一点的写法直接拼 filename fileName中文铁定乱码已经被我淘汰了。5. 进一步优化与踩坑总结踩过几次坑之后我把这套流程里能优化的地方也梳理了一下。首先是模板文件的管理建议按业务模块拆分每个模块一个.ftl文件不要放在一个文件里。富文本导出需求往往伴随着复杂的页面编排一个模板文件几百行XML一旦改动一处排查问题就能让人头大。拆开之后每个模板职责单一出问题也好定位。其次是性能。如果你的导出接口会频繁被调用每次解析HTML转XML会有一定的CPU开销。我的做法是加了一层缓存同一个富文本内容如果已经转换过短时间内直接复用转换结果用Redis或者本地内存缓存都行。当然这个优化需要结合业务实际——如果富文本内容经常变化缓存时间设短一点或者干脆不缓存。再有一个建议是关于日志的。导出功能不像普通的CRUD接口出问题之后排查成本很高。我建议在转换器里每个关键步骤都打上日志解析HTML用了多久、图片处理了几张、转换结果XML片段长度多少。这样万一客户说导出的Word有问题你能第一时间判断是前置数据的问题还是转换环节的问题不用瞎猜。最后再分享一个模板调试的土办法。如果你拿到了一个不正常的Word文档第一步不是去看日志而是把生成的.doc文件解压或者改名成.xml用浏览器打开看哪个节点报错。浏览器对XML的容错性很差但它会明明白白告诉你错在第几行这是我用过的效率最高的排查手段。记得排查完把问题修复后重新生成一份干净的文档避免旧文件干扰判断。这套Java FreeMarker导出富文本到Word的方案我在多个项目里反复用过稳定性确实经得起考验。方案的关键不在于用了多么高深的技术而在于对Word文档格式的深入理解和转换细节的严格把控。如果你正准备做类似的需求按照我上面写的模板制作流程和转换逻辑一步步走再结合自己的业务场景调整大概率能少走不少弯路。