做导出富文本这种需求场景其实很固定用户在前端用富文本编辑器UEditor、wangEditor、CKEditor这类编辑了一段内容存进数据库的是带HTML标签的字符串然后哪天业务方说“帮我把这篇内容导成Word发给客户/存档/打印”。如果你直接拿HTML改后缀名成.doc打开只会看到一堆标签和乱码如果用POI硬啃HTML解析样式那部分又特别痛苦。我最后用的是Java FreeMarker Word 2003 XML模板这条路线把富文本这块单独做成一个HTML到Word XML的转换器跟模板引擎配合起来用。这套方案维护成本低、格式可控性好跑了好几个项目都很稳。这篇文章就从方案选型、模板构造、富文本转换、完整代码到坑位一次性讲透。1. 为什么最终选了FreeMarker而不是POI或iText先说结论做富文本导出Word这个需求真正的工作量不在“导出”这两个字上而在“怎么把HTML语义完整翻译成Word能够识别的格式”。FreeMarker在这里扮演的是“模板数据填充”的角色真正解决翻译问题的是Word XML这套中间格式。技术选型的时候我对比过三条路各有各的适用场景。第一条路Apache POI直接操作XWPFDocument。POI对docx的原生支持虽然已经很成熟但它的操作模型是面向段落、表格、图片这些Word底层对象。要把一段HTML转成Word等于得自己写一个HTML解析器把标签逐级翻译成XWPFParagraph、XWPFRun、XWPFTable过程中还要处理嵌套列表、行内样式、图片插入位置这些细节。写出来代码量很大而且样式控制很别扭比如HTML里的span stylecolor:#ff0000你得遍历所有run去setColor维护性很差。POI适合做从零生成复杂Word报表的场景不适合做HTML到Word的转换。第二条路iText / flying-saucer 转PDF再转Word。这种间接方案把PDF当中间产物HTML渲染成PDF效果很好CSS支持也强但从PDF转Word基本都会丢东西排版错位、图片丢失是常态而且Word是允许编辑的业务方经常拿回去改几个字PDF转换出来的Word编辑体验很差。除非需求只是“能看就行”否则这条路走不通。第三条路就是FreeMarker Word XML模板。Word本身支持一种很古老的纯XML格式Word 2003 XML也叫WordML本质是一个带w:xx命名空间的XML文件直接指定后缀名为.doc就能用Word打开。FreeMarker是个文本模板引擎它的核心能力就是拿数据模型去渲染文本模板而WordML正是文本模板。这样一来模板里可以写静态的Word样式字体、页边距、标题样式要动态填充的地方用${}占位符富文本部分就用一个专门写好的转换器把HTML转成一段WordML片段塞进占位符里。整个流程清晰代码量少样式控制反而更灵活。不过这里要强调一个容易混淆的点我们说的“Word文档模板”跟.docx毫无关系。你如果想用模板变量填充docx那需要Apache POI的XWPFParagraph遍历去替换文本又绕回去了。FreeMarker的Word方案必须活在WordML的世界里。提示之所以选Word 2003 XML而不是直接构建OOXML即docx的底层结构是因为docx本质是个ZIP压缩包里面包含多个XML文件document.xml、styles.xml、media等FreeMarker没法直接渲染压缩包内部的文件。而WordML是单文件XML适合文本模板引擎直接处理。2. Word 2003 XML模板构造思路与关键命名空间我先把结论摆出来用FreeMarker做Word导出核心就是把你的Word文档变成一个XML文本模板然后拿数据往里填。很多人第一次接触WordML会有点懵因为它跟平时看到的HTML完全不一样标签全是w:p、w:r、w:t这种。其实拆开看就三类东西w:p段落Paragraph相当于HTML里的p或者一个文本块w:r文本片段Run一个段落可以包含多个Run每个Run可以有自己的字体、大小、颜色w:t真正的文本内容Text在w:r里面如果你要给某段文字设置字体、大小模板大概长这样w:p w:pPr w:jc w:valcenter/ /w:pPr w:r w:rPr w:rFonts w:ascii宋体 w:eastAsia宋体 w:hAnsi宋体/ w:sz w:val28/ w:b/ /w:rPr w:t标题内容/w:t /w:r /w:p这里w:sz w:val28/表示字号为14ptWord里字号单位是半磅28就是14磅对应汉字四号w:jc w:valcenter/表示居中w:b/表示加粗。制作模板最推荐的方式直接用Word编辑。具体操作是先用Word把静态内容的样式调好在需要动态填充的位置敲上占位符比如${title}、${content}然后另存为Word 2003 XML 文档再用文本编辑器打开这个XML文件把多余的命名空间和XML声明整理干净。这样做的最大好处是你不用手写XML排版Word会帮你生成所有正确的样式标签。整理模板时需要注意几点文件头部的?xml version1.0 encodingUTF-8 standaloneyes?保留xmlns:w这些命名空间声明要保留完整模板里的${title}不要被Word自动包进w:t以外的标签里如果被拆成多个w:t段FreeMarker会替换失败。这个问题我在第四部分细说。除了静态部分模板里还需要动态片段。富文本导出的模板核心就是一段类似这样的内容${wordContent}这个wordContent就是富文本HTML经过转换后生成的WordML片段这是整篇文章最核心的节点。3. 富文本HTML到Word XML的转换Jsoup解析与节点映射富文本在数据库里存的是HTTP的HTML95%以上是UEditor、wangEditor这类前端编辑器生产的。里面什么标签都有p、span、strong、img、table、ul/li还有内联style属性。如果你天真地把这些HTML字符串直接拼进Word XML模板Word打开只会报错因为HTML和WordML是两个完全不同的XML方言。所以核心要做一层转换器把HTML DOM树翻译成Word DOM树。我用的是Jsoup做HTML解析原因是它API简单、选择器强大、容错性极好遇到不标准的HTML不会直接挂掉。Jsoup把HTML解析成Document树之后我写一个递归方法遍历所有节点按规则映射成对应的WordML字符串。3.1 行内标签的映射规则先定一套基础映射表下边是我在代码里真实使用的规则HTML标签/属性WordML对应说明pw:p段落保留段落间距和首行缩进brw:br/手动换行strong/bw:b/加粗em/iw:i/斜体uw:u w:valsingle/下划线font color#ff0000w:color w:valFF0000/文字颜色注意去掉#stylefont-size: 18px;w:sz w:val36/字号px要换算成半磅stylefont-family: 微软雅黑;w:rFonts w:ascii微软雅黑 w:eastAsia微软雅黑/字体styletext-align: center;w:jc w:valcenter/段落对齐a href...w:hyperlink超链接img src...w:drawingWord内嵌图片见3.3节tablew:tbl表格转换规则单独处理这套映射表看着简单但真正实现时有个关键点样式属性是作用在标签上的但生成WordML时w:rPr必须放到对应的w:r里面。比如span stylecolor:#ff0000红色/span不能只生成一个带颜色的w:t标签得生成一个包含w:rPr和w:t的完整w:r节点。我写转换器时的核心数据结构是两层段落级属性对齐、缩进、行距和行内级属性字体、字号、颜色、加粗斜体下划线。递归遍历HTML树时每进入一个节点就把当前节点的样式叠加到状态对象里遇到文本节点时把状态对象里累计的所有样式属性生成一个完整的w:r。3.2 段落与嵌套块级元素富文本编辑器生成的内容经常有嵌套结构比如ulli项目一/lili项目二/li/ul。WordML里没有直接的列表标签要把每一个li转成一个段落w:p并在段落属性里加上编号或项目符号w:p w:pPr w:numPr w:ilvl w:val0/ w:numId w:val1/ /w:numPr /w:pPr w:r w:t项目一/w:t /w:r /w:p这里有个前提w:numId w:val1/对应的编号格式要在模板的styles.xml区域提前定义好否则导出的Word里看不到编号只会看到缩进过的普通段落。这也是一个容易掉进去的坑。嵌套引用—像blockquote—同理通常转成带左边距和缩进的段落用w:ind w:left720/表示左缩进0.5英寸。还有一个非常常见的坑HTML里的空段落往往被转换成一堆连续的空w:pWord打开后会产生大量空白。我的处理方式是在转换器里加一个去重逻辑如果连续多个段落都是空内容只保留一个并且把空段落的高度设为极小用w:spacing w:before0 w:after0/配合字号设为1避免一个回车占一行。3.3 图片处理三种来源与尺寸换算图片这块最容易出问题也是业务方最能直接感知到差距的地方。富文本图片有三种来源外链URL比如http://server/images/xxx.png导出时要用Java原生java.net或者HttpClient下载到字节数组Base64编码前端编辑器粘贴截图时通常会生成data:image/png;base64,xxxxx直接解码就行相对路径如果数据库存的是相对路径需要拼上文件服务器的完整地址再下载图片处理成WordML时需要把图片转成base64字符串内嵌到XML里。w:drawing标签的写法如下w:p w:r w:drawing wp:inline distT0 distB0 distL0 distR0 wp:extent cx1924560 cy1080360/ wp:docPr id1 name图片/ a:graphic a:graphicData urihttp://schemas.openxmlformats.org/drawingml/2006/picture pic:pic pic:blipFill a:blip r:embedrId1/ a:stretcha:fillRect//a:stretch /pic:blipFill pic:spPr a:xfrma:off x0 y0/a:ext cx1924560 cy1080360//a:xfrm a:prstGeom prstrecta:avLst//a:prstGeom /pic:spPr /pic:pic /a:graphicData /a:graphic /wp:inline /w:drawing /w:r /w:p这里有个核心技术点wp:extent cx... cy.../的取值单位是EMUEnglish Metric Unit1像素约等于9525EMU所以一个宽200px的图片应该写作cx200*95251905000。图片的实际字节数据不直接放在这里而是放在WordML文档末尾的二进制区w:binData或OLE对象区通过r:embed属性关联。不过在实际项目中我强烈建议你把这些图片内嵌逻辑封装成一个工具方法输入图片字节流、宽高、目标显示宽度输出完整的w:drawingXML字符串。显示宽度优先取原图宽高如果图片超宽比如移动端上传的图片有750px就按等比缩放到页面可用宽度。如果图片加载失败或者URL失效我建议兜底输出一个占位文本比如“[图片加载失败]”而不是直接抛异常把整个导出中断。3.4 表格转换富文本里如果有表格转换规则也不复杂就是HTML标签到WordML标签的一一对应HTMLWordMLtablew:tbltrw:trtd/thw:tccolspanw:gridSpan w:val2/rowspanw:vMerge w:valrestart/或w:vMerge/表格最麻烦的是边框和宽度。HTML里table border1还算好办对应的WordML要写一整套w:tblBorders麻烦的是很多富文本编辑器的表格根本没有border属性只有内联style比如styleborder-collapse: collapse; border: 1px solid #ccc;。我的转换器会解析td上的style把边框色和粗细映射到w:tcBorders。这个需求因为多而杂很难说能100%还原所有样式我的原则是优先保结构样式做到80%还原就够了。4. 完整导出流程模板设计、数据填充与工具类封装讲完关键转换逻辑我把完整导出流程从头到尾串一遍包含依赖、模板语法、工具类和调用方式。4.1 依赖清单我用的依赖主要是这三个dependency groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.32/version /dependency dependency groupIdorg.jsoup/groupId artifactIdjsoup/artifactId version1.17.2/version /dependency dependency groupIdcommons-io/groupId artifactIdcommons-io/artifactId version2.15.1/version /dependencyFreeMarker版本建议2.3.30以上2.3.32对JDK8到JDK21都能跑。Jsoup选稳定版就行。commons-io是处理图片下载和IO流用的你也可以用Java NIO替代。4.2 模板文件设计模板文件放在src/main/resources/templates/word/rich_content.ftl这里我用FreeMarker的#include语法把富文本内容单独一个变量引用?xml version1.0 encodingUTF-8 standaloneyes? w:document xmlns:whttp://schemas.openxmlformats.org/wordprocessingml/2006/main xmlns:rhttp://schemas.openxmlformats.org/officeDocument/2006/relationships xmlns:wphttp://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing xmlns:ahttp://schemas.openxmlformats.org/drawingml/2006/main xmlns:pichttp://schemas.openxmlformats.org/drawingml/2006/picture w:body w:p w:pPr w:jc w:valcenter/ /w:pPr w:r w:rPr w:rFonts w:ascii宋体 w:eastAsia宋体 w:hAnsi宋体/ w:sz w:val36/ w:b/ /w:rPr w:t${title}/w:t /w:r /w:p w:p w:r w:rPr w:rFonts w:ascii宋体 w:eastAsia宋体 w:hAnsi宋体/ w:sz w:val18/ /w:rPr w:t${createTime}/w:t /w:r /w:p ${wordContent} /w:body /w:document这里的${wordContent}就是我们转换好的WordML片段FreeMarker会把它原样嵌入。注意w:t标签里的${title}如果包含、、等XML特殊字符Word打开会直接报错。FreeMarker有一个?html内置函数可以转义XML字符但用在w:t里要小心因为它会把转成amp;Word打开时会正确还原。我是这样处理的在填充数据模型前先用freemarker.template.utility.XmlEscapeUtil或者手写一个escape方法保证文本内容的安全性。4.3 完整工具类源码下面是我在实际项目里使用的导出工具类核心代码做了部分精简但主流程完整可跑。import freemarker.template.Configuration; import freemarker.template.Template; import freemarker.template.TemplateExceptionHandler; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.HashMap; import java.util.Map; public class WordExportUtil { private static final Configuration freemarkerConfig new Configuration(Configuration.VERSION_2_3_32); static { freemarkerConfig.setClassForTemplateLoading(WordExportUtil.class, /templates/word); freemarkerConfig.setDefaultEncoding(UTF-8); freemarkerConfig.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); } /** * 导出Word文档 * * param title 标题 * param createTime 创建时间 * param richTextHtml 富文本HTML * param outputPath 导出文件路径 */ public static void exportRichTextToWord(String title, String createTime, String richTextHtml, String outputPath) throws Exception { // 1. 富文本HTML转WordML String wordContent HtmlToWordXmlConverter.convert(richTextHtml); // 2. 构建数据模型 MapString, Object dataModel new HashMap(); dataModel.put(title, escapeXml(title)); dataModel.put(createTime, escapeXml(createTime)); dataModel.put(wordContent, wordContent); // 3. 渲染模板 Template template freemarkerConfig.getTemplate(rich_content.ftl); try (Writer out new BufferedWriter(new OutputStreamWriter( new FileOutputStream(outputPath), StandardCharsets.UTF_8))) { template.process(dataModel, out); } } /** XML特殊字符转义 */ private static String escapeXml(String s) { if (s null) return ; return s.replace(, amp;) .replace(, lt;) .replace(, gt;) .replace(\, quot;) .replace(, apos;); } }4.4 转换器核心代码重点看HtmlToWordXmlConverter这个类里面的递归遍历逻辑是整个导出方案的心脏。public class HtmlToWordXmlConverter { public static String convert(String html) { if (html null || html.isEmpty()) return ; Document doc Jsoup.parse(html); StringBuilder sb new StringBuilder(); for (Element body : doc.getElementsByTag(body)) { for (Node child : body.childNodes()) { convertNode(child, sb, new StyleState()); } } return sb.toString(); } private static void convertNode(Node node, StringBuilder sb, StyleState style) { if (node instanceof TextNode) { String text ((TextNode) node).text(); if (text null || text.trim().isEmpty()) return; // 用当前累计的style生成一个w:r sb.append(style.buildRun(text)); return; } if (node instanceof Element) { Element elem (Element) node; StyleState childStyle style.clone(); applyElementStyle(elem, childStyle); switch (elem.tagName().toLowerCase()) { case p: sb.append(w:p); if (childStyle.hasParagraphStyle()) { sb.append(childStyle.buildParagraphProperties()); } for (Node child : elem.childNodes()) { convertNode(child, sb, childStyle); } sb.append(/w:p); break; case br: sb.append(w:rw:br//w:r); break; case img: String imgXml convertImage(elem); sb.append(w:p).append(imgXml).append(/w:p); break; case strong: case b: childStyle.bold true; for (Node child : elem.childNodes()) convertNode(child, sb, childStyle); break; case em: case i: childStyle.italic true; for (Node child : elem.childNodes()) convertNode(child, sb, childStyle); break; case u: childStyle.underline true; for (Node child : elem.childNodes()) convertNode(child, sb, childStyle); break; case span: case font: // 样式已通过applyElementStyle应用到childStyle for (Node child : elem.childNodes()) convertNode(child, sb, childStyle); break; case a: { String href elem.attr(href); sb.append(w:hyperlink r:id\rIdLink\); for (Node child : elem.childNodes()) convertNode(child, sb, childStyle); sb.append(/w:hyperlink); break; } case ul: case ol: // 列表转段落编号具体numId根据上下文 childStyle.listItem true; for (Element li : elem.getElementsByTag(li)) { sb.append(w:p); sb.append(w:pPrw:numPrw:ilvl w:val\0\/) .append(w:numId w:val\).append(ul.equals(elem.tagName()) ? 1 : 2) .append(\//w:numPr/w:pPr); for (Node child : li.childNodes()) convertNode(child, sb, childStyle); sb.append(/w:p); } break; case table: convertTable(elem, sb, childStyle); break; default: // 其他标签默认按行内内容处理 for (Node child : elem.childNodes()) convertNode(child, sb, childStyle); } } } }上面代码里的StyleState是一个保存当前累计样式的类包含字体、字号、颜色、对齐、加粗、斜体、下划线等字段buildRun(text)就是根据这些字段拼出一个完整的w:r字符串buildParagraphProperties则生成w:pPr段落属性。4.5 图片处理实现图片转换的代码是重点中的重点我单独列出来讲。private static String convertImage(Element imgElem) { String src imgElem.attr(src); byte[] imageBytes null; String base64Data null; String suffix png; try { if (src.startsWith(data:image)) { // base64格式 String[] parts src.split(,); if (parts.length 2) { base64Data parts[1]; imageBytes Base64.getDecoder().decode(base64Data); String mimeType parts[0].replace(data:, ).replace(;base64, ); suffix mimeType.contains(jpeg) ? jpg : mimeType.contains(gif) ? gif : png; } } else if (src.startsWith(http)) { // 下载网络图片 imageBytes downloadImage(src); suffix src.contains(.) ? src.substring(src.lastIndexOf(.) 1) : png; } else { // 相对路径拼接文件服务器地址后下载 imageBytes downloadImage(fileBaseUrl src); } if (imageBytes null || imageBytes.length 0) { return w:pw:rw:t[图片加载失败]/w:t/w:r/w:p; } int width 0; int height 0; try { if (imgElem.hasAttr(width)) width Integer.parseInt(imgElem.attr(width)); if (imgElem.hasAttr(height)) height Integer.parseInt(imgElem.attr(height)); } catch (NumberFormatException ignored) {} if (width 0) width 300; // 默认宽度 if (height 0) height 200; // 默认高度 // 宽度超限等比缩放 int maxWidth 500; if (width maxWidth) { height height * maxWidth / width; width maxWidth; } long cx width * 9525L; long cy height * 9525L; return buildDrawingXml(base64Data, suffix, cx, cy); } catch (Exception e) { return w:pw:rw:t[图片加载失败]/w:t/w:r/w:p; } }buildDrawingXml生成的就是3.3节里那段复杂的w:drawingXML同时会把base64图片数据追加到文档的w:binData区或者直接用一个内部的retationship文件关联。注意w:document根节点下方的w:body里如果存在二进制图片数据通常要放在w:body的最末尾用w:o:OleObject或一个w:binData节点承载然后用r:embedrId1关联。具体位置要跟生成的w:drawing配合好否则Word会提示“图片无法显示”。这个细节我调试的时候卡了半天你如果遇到图片不显示优先检查这段关系是否正确。4.6 调用示例调用过程极其简单业务代码只需要一行WordExportUtil.exportRichTextToWord( 第三季度经营分析报告, 2025-10-12 14:30:00, pstrong一、总体情况/strong/pp本季度营收span style\color: #ff0000;\1.2亿元/span同比增长15%。/p, /data/export/经营分析报告.doc );生成的经营分析报告.doc用Microsoft Word打开效果就是标题居中加粗正文宋体四号红色数字正常展示跟富文本编辑器里看到的基本一致。5. 我在实际项目里踩过的坑样式丢失、图片变形与模板替换失败这部分是全文最有价值的实操经验全是踩坑后的复盘。每个坑都浪费过不少时间分享出来帮大家提前绕开。5.1 Word里编辑模板后${title}被拆成了多个w:t片段这是个非常阴的坑。你在Word里直接打${title}看起来只是一个字符串但Word保存成XML时会自动把这段文本拆分到多个w:t节点里。比如你输入了${title}XML里可能是这样的w:rw:t${tit/w:t/w:r w:rw:tle}/w:t/w:r或者更乱$、{、title、}分布在不同的run里。FreeMarker再聪明也认不出这种被拆碎的占位符渲染结果就是Word里显示了一段怪异的${title}残留或者直接模板替换失败。解决方式有两种。第一种在Word里输入占位符时用一下格式统一的快捷键选中占位符CtrlD打开字体设置把“隐藏”取消勾选然后CtrlShiftF8进入列选择模式重新选择一下有时候能保持为一个run。但这种做法运气成分高。我推荐第二种用文本编辑器直接改XML模板。在Word里另存为XML后用Notepad或VS Code打开找到${title}相关的w:r节点把这个run里的所有碎片手动合并成一个w:t${title}/w:t。这个过程虽然手动但一次配置永久复用。5.2 富文本包含、等字符导致XML解析失败这是第二个高频坑。富文本内容里如果有A B这种文本MySQL里存的就是A B转换器处理时如果不转义拼到WordML里就变成w:tA B/w:tWord打开会直接报“XML错误”。你可能觉得Jsoup会帮你处理但实际上Jsoup解析的是HTML转成文本节点后已经变成了amp;但你在拼WordML的时候如果直接text()取出来它又是普通的了。所以我在escapeXml里手动处理一遍→amp;→lt;→gt;。注意处理顺序必须先转不然会出现二次转义导致页面显示amp;。5.3 图片导出后变形或者尺寸和原图不一致图片尺寸问题基本都出在两个地方。第一前端富文本编辑器里的width属性有的存的是stylewidth: 200px;而不是width200属性我的转换器最初只取attr(width)结果一堆图片变形。后来改成先解析style属性里的width和height再回退到attr。第二width和height只给了一个的情况下另一个要按原图比例算不能随便填默认值。这块我在convertImage里做了兜底逻辑如果只有宽度就根据下载到的图片字节流解析出真实宽高比来补全高度。这个用ImageIO.read(new ByteArrayInputStream(imageBytes))就能拿到原始图片信息不复杂但很有用。5.4 Word和WPS打开效果不同Word打开正常但WPS打开错乱、或者反过来这是最后一个值得说的坑。原因是Word 2003 XML这种老格式在两个办公软件里的兼容性本身就存在细微差别尤其体现在字体回退、页面边距和表格边框上。我最终的妥协方案是模板里字体全部设成宋体或微软雅黑段落间距用w:spacing明确指定而不是依赖默认值图片内联用w:drawing而不是老式的w:object虽然WPS对老格式的兼容性更好但Word对w:drawing的渲染更现代化而且在高分屏下更清晰。表格边框全部显式声明w:tblBorders不要依赖默认值。这样两边打开的效果已经非常接近了。5.5 大内容导出时内存吃紧富文本如果包含大量base64图片或者内容特别长一次性把整个XML字符串放在内存里再写文件可能会OOM。我的做法是改成流式处理FreeMarker本身支持Writer输出到文件流HtmlToWordXmlConverter.convert返回的WordML字符串不要用StringBuilder从头拼到尾而是直接传入同一个Writer边递归边写文件。我在项目里就是这么做的把convert方法的返回类型从String改成了void增加一个Writer参数性能提升明显GC压力也小很多。6. 这套方案还能怎么扩展如果你已经动手跑通了上面这套流程你会发现整个架构里最值得复用、也最灵活的部分就是HtmlToWordXmlConverter这个转换器。它本质上解决了“一种HTML方言到另一种XML方言的映射”这意味着如果将来需要导出PDF只需要把目标方言从WordML换成XSL-FO或者直接用现有HTML转PDF的工具转换器可以改为输出中间结构比如统一的语义化对象树再派生出不同的序列化器。如果需要导出Excel表格部分可以复用convertTable逻辑输出成Worksheet的XML格式。如果富文本源不只是编辑器比如来自Markdown渲染、来自爬虫抓取的文章正文只要HTML是标准DOM结构jsoup的解析能力都能兜住转换器基本不用改。我在最近一个项目里就是把HtmlToWordXmlConverter抽取成了独立模块根据入参的不同输出WordML片段或者HTML片段同一个接口供给导出Word和网页预览两个场景使用复用率很高。最后补充一个实际运维层面的小提示导出的.doc文件建议加个后缀校验。用户在测试环境上传一个.docx文件想测试导出功能如果你的接口不校验文件类型直接走转换逻辑极大概率会报错。还有导出的文件建议统一加上日期或流水号命名比如经营分析报告_20251012_01.doc避免同业务方多次导出后根本分不清哪个是最新版。这些看起来跟技术无关的小事恰恰是交付后用户体验差异最大的地方。