
做后端的人基本都碰到过这种需求用户在前端编辑器里写了一篇 Markdown 文档提交到后端之后我们需要把它导出一份 .docx 给客户、运营或者领导看。项目标题里“springboot项目将markdown格式字符串转docs docx4j commonmark、commonmark-ext-gfm-tables”这一串关键词其实已经把技术方案说完了——Spring Boot 负责接口与生命周期commonmark 负责把 Markdown 字符串解析成结构化节点commonmark-ext-gfm-tables 补上 GFM 表格支持docx4j 负责把这些节点落到 .docx 的 XML 结构里。这篇文章就是把我在实际项目里跑通的一条完整链路拆开讲清楚包括依赖选型、核心代码、表格转换这个最难啃的骨头以及各种我踩过的坑。适合刚接触 docx4j 的 Spring Boot 开发者也适合那些已经用 freemarker 生成 Word 模板、但遇到动态内容就头疼的人。看完之后你至少能自己写一个mdToDocx(String markdown)的接口前端传 Markdown 字符串后端吐出一个能直接打开的 Word 文件。1. 项目整体设计与思路拆解1.1 需求场景与核心矛盾先说场景。现在的业务系统尤其是一些内容管理、知识库、在线文档类项目编辑器基本都支持 Markdown 语法。用户辛辛苦苦写了一篇带标题、表格、代码块的文档结果领导说“给我一份 Word 版”。这时候如果前端拿 Markdown 去生成 Word受浏览器渲染限制太多后端直接拿原文拼字符串又肯定不支持格式。最稳妥的办法就是后端拿到 Markdown 字符串做一次“转译”输出真正的 docx 文件。核心矛盾在于Markdown 是一种极简的纯文本语法docx 是一种极其啰嗦的 XMLZIP 复合文档。把一个纯文本结构转换成 OOXML 结构中间需要先“理解”内容的语义再“翻译”成 Word 的段落、文本、表格、样式。这个翻译过程如果自己写正则基本会写出一个充满 bug 的玩具直接拼 XML 字符串又没法保证 docx 能被 Office 正常打开。所以必须依赖两个成熟库一个负责解析 Markdown一个负责写 Word。1.2 为什么选 commonmark docx4j而不是其他方案我知道很多人第一反应是“直接调 pandoc”或者“用 LibreOffice 命令行转换”再或者“在 Spring Boot 里用 poi-tl 这种基于 Word 模板的工具”。这些方案我都评估过各有各的问题pandoc / LibreOffice 命令行转换质量确实好但属于外部进程依赖。服务器上得装额外软件还要处理进程并发、超时、临时文件清理在容器化部署环境里尤其痛苦。Docker 镜像里为了一个导出功能塞进一个 LibreOffice体积直接膨胀 1 个 G维护成本太高。aspose-words这类商业库功能强大但要 License而且对项目体积和规范要求都比较敏感。poi-tl / freemarker Word 模板适合固定模板、固定占位符的场景。但我们的内容是用户自由书写的 Markdown标题层级不定、表格列数不定、代码块数量不定模板方案完全无法覆盖这种“动态文档”。纯 poi 写 docxApache POI 的 XWPF 也能写 Word但 POI 对 OOXML 的支持偏“粗”很多排版细节要自己构造底层 XML表格、列表等结构处理起来非常繁琐代码量比 docx4j 大不少。所以最后定了commonmark 负责解析 docx4j 负责生成。commonmark 是 CommonMark 规范的标准 Java 实现语义精确扩展机制健全docx4j 是处理 OOXML 最底层的 Java 库能精确控制段落、表格、字体、边框虽然 API 啰嗦但可控性最强。这套组合不依赖外部命令纯 JVM 运行Spring Boot 里加几个依赖就能直接用。1.3 转换链路总体设计整个转换流程可以拆成四步解析commonmark 接收 Markdown 字符串生成一棵节点树AST。节点类型包括Heading、Paragraph、TableBlock、CodeBlock、ListBlock、BlockQuote等。遍历从根节点出发按顺序遍历每个节点。转译对每个节点类型生成对应的 docx4j 对象。Heading生成带样式的段落PTableBlock生成表格TblCodeBlock生成等宽字体段落Text生成文本节点R和Text。输出把所有对象挂到WordprocessingMLPackage的主文档部分保存为字节流或输出流交给 Spring Boot 接口返回给前端。设计上要注意一点整个转换过程必须保持顺序。Markdown 的节点顺序就是文档的阅读顺序遍历时不能倒序、不能打乱生成的 docx 元素要按顺序添加到文档内容列表里。2. 核心依赖与环境准备2.1 关键依赖版本与踩坑记录这一步看起来简单但坑最多。直接贴我最后稳定运行的一组依赖dependency groupIdorg.commonmark/groupId artifactIdcommonmark/artifactId version0.21.0/version /dependency dependency groupIdorg.commonmark/groupId artifactIdcommonmark-ext-gfm-tables/artifactId version0.21.0/version /dependency dependency groupIdorg.docx4j/groupId artifactIddocx4j-JAXB-ReferenceImpl/artifactId version11.4.9/version /dependency这里有几个版本相关的细节commonmark 和 commonmark-ext-gfm-tables 的版本必须一致否则运行时会因为扩展接口签名不匹配报NoSuchMethodError。0.21.0 是一个比较稳定的版本后面的 0.22.0 我也试过主要差异不大。docx4j 11.x 需要 Java 8 以上Spring Boot 2.x 完全兼容。但如果你用的是 JDK 11 及以上要注意 JAXB 已经从 JDK 里移除了需要额外加依赖否则启动就会报类找不到。我当时在 JDK 11 环境里的解决方式是加上dependency groupIdjavax.xml.bind/groupId artifactIdjaxb-api/artifactId version2.3.1/version /dependency dependency groupIdorg.glassfish.jaxb/groupId artifactIdjaxb-runtime/artifactId version2.3.5/version /dependency dependency groupIdjavax.activation/groupId artifactIdactivation/artifactId version1.1.1/version /dependency不要同时引入 docx4j 多个 JAXB 实现模块。docx4j 有docx4j-JAXB-Internal、docx4j-JAXB-ReferenceImpl、docx4j-JAXB-MOXy三个实现变体引入多个会让 JAXBContext 初始化的时候随机挑一个严重的会在不同环境出现不一致的行为。我就吃过一次亏本地 Windows 上好好的部署到 Linux 的容器里就报 XML 解析异常最后排查出来是依赖树里混了两个实现删掉多余那个就好了。2.2 Spring Boot 工程结构目录结构按常规三层来src/main/java/com/example/md2docx/ ├── controller/ │ └── MdExportController.java ├── service/ │ ├── MarkdownToDocxService.java │ └── impl/ │ └── MarkdownToDocxServiceImpl.java ├── converter/ │ ├── MarkdownToDocxConverter.java │ └── element/ │ └── DocxElementFactory.java └── common/ └── Result.javaconverter包放核心转换逻辑service包负责接口封装controller包只处理 HTTP 请求。这样布局的好处是如果以后要扩展其他格式转换比如 Markdown 转 PDF只需要替换converter层不影响对外接口。3. 核心实现从 Markdown 字符串到 docx3.1 commonmark 解析构建节点树第一步是解析 Markdown 字符串。核心代码很简单Parser parser Parser.builder() .extensions(Arrays.asList(TablesExtension.create())) .build(); Node document parser.parse(markdownString);TablesExtension来自commonmark-ext-gfm-tables注册之后commonmark 才能识别 GFM 表格语法也就是用|和---画出来的那种表格。解析完之后document是一棵完整的节点树。我需要写一个遍历逻辑按顺序处理每个块级节点。我选择了手动遍历因为这样最容易控制顺序也最容易在表格内部跳过不必要的递归。private void render(Node parent) { for (Node child : parent.getChildren()) { if (child instanceof Heading) { renderHeading((Heading) child); } else if (child instanceof Paragraph) { renderParagraph((Paragraph) child); } else if (child instanceof CodeBlock) { renderCodeBlock((CodeBlock) child); } else if (child instanceof ListBlock) { renderList((ListBlock) child); } else if (child instanceof BlockQuote) { renderBlockQuote((BlockQuote) child); } else if (child instanceof TableBlock) { renderTable((TableBlock) child); } else if (child instanceof ThematicBreak) { renderThematicBreak(); } else if (child instanceof Text) { // 顶层纯文本一般不会出现但防御性处理 } else { render(child); // 其他节点继续向下递归 } } }注意这里没有使用 commonmark 的AbstractVisitor虽然官方推荐 visitor 模式但表格节点TableBlock的内部子节点结构比较特殊visitor 模式默认会递归遍历表格里的每个单元格如果visit(Paragraph)和visit(Text)里也生成文档元素会导致表格内部的文本被额外输出一份到文档正文里。手动遍历是更可控的做法。3.2 创建 docx4j 文档骨架与页面设置解析之前先创建一份空的 Word 文档骨架WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.createPackage();这一步生成的包结构实际上就是符合 OOXML 规范的 docx 压缩包。之后我要设置页面大小和页边距。A4 纸的宽度是 11906 twips1 厘米约 567 twips高度是 16838 twips。如果页面设置不写Word 打开默认是 Letter 纸张中文排版会很别扭。SectPr sectPr new SectPr(); PgSz pgSz new PgSz(); pgSz.setW(BigInteger.valueOf(11906)); pgSz.setH(BigInteger.valueOf(16838)); sectPr.setPgSz(pgSz); PgMar pgMar new PgMar(); pgMar.setTop(BigInteger.valueOf(1440)); // 上下页边距 2.54cm pgMar.setBottom(BigInteger.valueOf(1440)); pgMar.setLeft(BigInteger.valueOf(1440)); // 左右页边距 2.54cm pgMar.setRight(BigInteger.valueOf(1440)); sectPr.setPgMar(pgMar); wordMLPackage.getDocument().getBody().setSectPr(sectPr);Twips 是 docx 里的度量单位1 磅 20 twips1 厘米约等于 567 twips。这里 1440 twips 正好是 2.54 厘米也就是标准 1 英寸页边距。页眉页脚暂时不做后面如果要加也是在sectPr里引用HeaderReference。3.3 块级元素转换标题、段落、列表、代码块、引用这是整个转换器的主体。先看标题标题最核心的处理是字体大小和加粗private void renderHeading(Heading heading) { int level Math.min(heading.getLevel(), 6); P p new P(); // 段落间距 Spacing spacing new Spacing(); spacing.setBefore(BigInteger.valueOf(240)); // 段前 12pt spacing.setAfter(BigInteger.valueOf(120)); // 段后 6pt p.setSpacing(spacing); R r new R(); RPr rpr new RPr(); // 标题加粗 BooleanDefaultTrue bold new BooleanDefaultTrue(); bold.setVal(true); rpr.setB(bold); // 标题字号H1 为 22ptH2 为 18pt逐级递减 HpsMeasure sz new HpsMeasure(); int sizeHalfPoints 44 - (level - 1) * 4; // 22pt 44 half-points sz.setVal(String.valueOf(sizeHalfPoints)); rpr.setSz(sz); rpr.setSzCs(sz); r.setRPr(rpr); // 递归处理标题内的文本和行内样式 for (Node child : heading.getChildren()) { renderInline(child, r); } p.getContent().add(r); wordMLPackage.getMainDocumentPart().addObject(p); }标题字号这里有个容易混淆的点docx4j 的sz值不是“磅”而是“半磅”。22 磅号要写成 44。我第一次直接把 22 填进去生成的 Word 打开后标题小得可怜。普通段落是最常见的节点。Markdown 里一个段落内可能有换行、加粗、斜体、行内代码所以renderParagraph要把整个段落作为一个P内部塞入多个R遇到换行标签时就插入一个Brprivate void renderParagraph(Paragraph paragraph) { P p new P(); // 段前段后间距可稍微调小 Spacing spacing new Spacing(); spacing.setAfter(BigInteger.valueOf(120)); p.setSpacing(spacing); R r new R(); for (Node child : paragraph.getChildren()) { if (child instanceof SoftLineBreak) { p.getContent().add(r); r new R(); Br br new Br(); r.getContent().add(br); } else if (child instanceof Text) { Text text (Text) child; // 这里使用 wml.Text不要和 node.Text 混淆 org.docx4j.wml.Text docxText new org.docx4j.wml.Text(text.getLiteral()); docxText.setSpace(preserve); r.getContent().add(docxText); } else { renderInline(child, r); } } p.getContent().add(r); wordMLPackage.getMainDocumentPart().addObject(p); }setSpace(preserve)这个点非常重要。docx 的 XML 规范里w:t元素默认会折叠空白字符如果 Markdown 里特意写了多个空格Word 打开后就看不到了。加上xml:spacepreserve才能保留原文的空格。代码块用等宽字体加背景色呈现private void renderCodeBlock(CodeBlock codeBlock) { P p new P(); R r new R(); RPr rpr new RPr(); // 使用 Consolas 字体 RFonts rf new RFonts(); rf.setAscii(Consolas); rf.setHAnsi(Consolas); rpr.setRFonts(rf); // 加一点浅灰背景也可以不做看需求 Shd shd new Shd(); shd.setFill(F2F2F2); rpr.setShd(shd); r.setRPr(rpr); org.docx4j.wml.Text docxText new org.docx4j.wml.Text(codeBlock.getLiteral()); docxText.setSpace(preserve); r.getContent().add(docxText); p.getContent().add(r); wordMLPackage.getMainDocumentPart().addObject(p); }列表的转换是块级元素里比较麻烦的。docx 的列表机制需要numbering.xml配合这涉及定义abstractNum和num结构复杂。在实际项目里如果对列表样式要求不高可以用“伪列表”方案每项前面加一个•或者1.字符然后设置首行缩进和悬挂缩进private void renderList(ListBlock listBlock) { // 判断是有序还是无序列表 boolean ordered listBlock instanceof OrderedList; int index 1; for (Node itemNode : listBlock.getChildren()) { if (itemNode instanceof ListItem) { P p new P(); // 设置左缩进 PPr ppr new PPr(); Ind ind new Ind(); ind.setLeft(BigInteger.valueOf(720)); // 左缩进 0.5 英寸 ppr.setInd(ind); p.setPPr(ppr); R r new R(); org.docx4j.wml.Text symbolText new org.docx4j.wml.Text( ordered ? (index) . : • ); symbolText.setSpace(preserve); r.getContent().add(symbolText); p.getContent().add(r); // ListItem 内部可能有 Paragraph 或嵌套列表 for (Node child : itemNode.getChildren()) { if (child instanceof Paragraph) { renderParagraph((Paragraph) child); } else if (child instanceof ListBlock) { renderList((ListBlock) child); } } index; } } }这种方案简单直接层级嵌套也能通过递归缩进实现唯一的缺点是用 Word 打开后列表不会显示为“自动编号”而是纯文本符号。如果业务要求严格的 Word 原生列表需要去操作NumberingDefinitionsPart代码量至少翻一倍。引用块的处理我用缩进加斜体来模拟private void renderBlockQuote(BlockQuote blockQuote) { for (Node child : blockQuote.getChildren()) { if (child instanceof Paragraph) { P p new P(); PPr ppr new PPr(); Ind ind new Ind(); ind.setLeft(BigInteger.valueOf(1080)); // 左缩进 0.75 英寸 ind.setRight(BigInteger.valueOf(360)); ppr.setInd(ind); p.setPPr(ppr); for (Node inline : child.getChildren()) { R r new R(); RPr rpr new RPr(); BooleanDefaultTrue italic new BooleanDefaultTrue(); italic.setVal(true); rpr.setI(italic); r.setRPr(rpr); // ... 文本处理这里省略 p.getContent().add(r); } wordMLPackage.getMainDocumentPart().addObject(p); } } }3.4 GFM 表格转换最关键的难点表格是整个转换里最核心、也最容易翻车的部分。commonmark 解析 GFM 表格后AST 结构是TableBlock ├── TableHead │ └── TableRow │ ├── TableCell │ └── TableCell ├── TableBody │ ├── TableRow │ │ ├── TableCell │ │ └── TableCell │ └── TableRow │ └── ...docx 生成表格需要四个层级Tbl表格本体设置整体宽度和边框。TblGrid列网格声明每列的宽度。Tr行对象。Tc单元格对象每个单元格内部至少包含一个Paragraph。转换逻辑如下private void renderTable(TableBlock tableBlock) { Tbl tbl new Tbl(); // 先遍历一遍所有行统计列数 ListTableRow allRows new ArrayList(); int columnCount 0; // TableHead 和 TableBody 里都有 TableRow StringBuilder sb new StringBuilder(); for (Node child : tableBlock.getChildren()) { traverseRows(child, allRows); } // 从第 0 行表头计算列数 if (!allRows.isEmpty()) { TableRow firstRow allRows.get(0); columnCount firstRow.getChildren().size(); } // 设置表格属性宽度 100%指定边框 TblPr tblPr new TblPr(); TblWidth tblWidth new TblWidth(); tblWidth.setType(pct); tblWidth.setW(5000); // 5000 100% tblPr.setTblW(tblWidth); // 边框设置 TblBorders tblBorders new TblBorders(); Border border new Border(); border.setVal(STBorder.SINGLE); border.setSz(4); border.setColor(auto); // 分别设置上、下、左、右、内部横线、内部竖线 tblBorders.setTop(border); tblBorders.setBottom(border); tblBorders.setLeft(border); tblBorders.setRight(border); tblBorders.setInsideH(border); tblBorders.setInsideV(border); tblPr.setTblBorders(tblBorders); tbl.setTblPr(tblPr); // 列网格均分宽度 TblGrid tblGrid new TblGrid(); int usableWidth 9600; // A4 减去页边距后的宽度twips int colWidth usableWidth / columnCount; for (int i 0; i columnCount; i) { TblGridCol gridCol new TblGridCol(); gridCol.setW(String.valueOf(colWidth)); tblGrid.getGridCol().add(gridCol); } tbl.setTblGrid(tblGrid); // 遍历行和单元格 for (TableRow row : allRows) { Tr tr new Tr(); int cellIndex 0; for (Node cellNode : row.getChildren()) { if (cellNode instanceof TableCell) { TableCell cell (TableCell) cellNode; Tc tc new Tc(); // 单元格宽度与列网格对应 TcPr tcPr new TcPr(); TblWidth tcWidth new TblWidth(); tcWidth.setType(dxa); tcWidth.setW(String.valueOf(colWidth)); tcPr.setTblW(tcWidth); // 表头加灰底和加粗 if (row.getParent() instanceof TableHead) { Shd shd new Shd(); shd.setFill(D9E2F3); tcPr.setShd(shd); } tc.setTcPr(tcPr); // 解析单元格内部内容 for (Node inner : cell.getChildren()) { if (inner instanceof Paragraph) { P p new P(); R r new R(); if (row.getParent() instanceof TableHead) { RPr rpr new RPr(); BooleanDefaultTrue bold new BooleanDefaultTrue(); bold.setVal(true); rpr.setB(bold); r.setRPr(rpr); } // 处理文本节点 for (Node textNode : inner.getChildren()) { if (textNode instanceof Text) { org.docx4j.wml.Text docxText new org.docx4j.wml.Text(((Text) textNode).getLiteral()); docxText.setSpace(preserve); r.getContent().add(docxText); } } p.getContent().add(r); tc.getContent().add(p); } } tr.getContent().add(tc); } } tbl.getContent().add(tr); } wordMLPackage.getMainDocumentPart().addObject(tbl); }这里花了很大篇幅因为表格转换有四个具体坑第一个坑TblGrid的列数必须和实际Tc数量一致。如果 GFM 表格里某一行少写了一列docx 文件在 Word 里打开时会提示“表格已损坏”。我建议在遍历时做一次列数校正不足的补空Tc多的合并或丢弃。第二个坑单元格内不能直接放Text。必须是Tc - Paragraph - R - Text这个结构。很多新手直接把文本塞进Tc生成的文件能打开但 Word 不显示内容。第三个坑表头识别。commonmark 里TableHead是首行的父节点判断row.getParent() instanceof TableHead就能识别表头。表头通常要加粗和加底纹。第四个坑列宽。GFM 表格语法本身不指定列宽我按均分处理。但这个逻辑在列数很多的时候会出问题——如果一列的字特别多Word 的自动调整会把表格撑开。更好的做法是设置tblLayout为autofit让 Word 根据内容自动调整或者在读列宽时启用 commonmark 的列对齐信息做加权。实际上TableCell节点上有getAlignment()可以用它做参考但权重还是要自己定项目里我为了省事直接用均分加 autofit 兜底。3.5 行内元素处理加粗、斜体、行内代码与链接块级元素搞定后行内元素是细节。Markdown 里常见的有**加粗**、*斜体*、行内代码、[链接](url)。这些都是Paragraph或Heading的子树在遍历它们的子节点时处理。我已经在renderParagraph里写了基础逻辑现在补全renderInlineprivate void renderInline(Node node, R r) { if (node instanceof Text) { org.docx4j.wml.Text docxText new org.docx4j.wml.Text(((Text) node).getLiteral()); docxText.setSpace(preserve); r.getContent().add(docxText); } else if (node instanceof Emphasis) { // 斜体可以设置当前 run 的 i 属性 RPr rpr r.getRPr(); if (rpr null) { rpr new RPr(); } BooleanDefaultTrue italic new BooleanDefaultTrue(); italic.setVal(true); rpr.setI(italic); r.setRPr(rpr); // 递归处理内部节点 for (Node child : node.getChildren()) { renderInline(child, r); } } else if (node instanceof StrongEmphasis) { RPr rpr r.getRPr(); if (rpr null) { rpr new RPr(); } BooleanDefaultTrue bold new BooleanDefaultTrue(); bold.setVal(true); rpr.setB(bold); r.setRPr(rpr); for (Node child : node.getChildren()) { renderInline(child, r); } } else if (node instanceof Code) { // 行内代码等宽字体 浅灰底纹 RPr rpr r.getRPr(); if (rpr null) { rpr new RPr(); } RFonts rf new RFonts(); rf.setAscii(Consolas); rf.setHAnsi(Consolas); rpr.setRFonts(rf); Shd shd new Shd(); shd.setFill(F2F2F2); rpr.setShd(shd); r.setRPr(rpr); org.docx4j.wml.Text docxText new org.docx4j.wml.Text(((Code) node).getLiteral()); docxText.setSpace(preserve); r.getContent().add(docxText); } // 链接的完整实现需要创建 hyperlink 关系这里先做简化 }关于链接docx 里的“真链接”需要往WordprocessingMLPackage的 relationships 部分注册一个 external hyperlink relationship然后设置r:id。核心代码大致是// 这只是伪代码级的示意 Relationship rel wordMLPackage.getMainDocumentPart() .getRelationshipsPart() .addExternalRelationship(url, http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink); org.docx4j.wml.Hyperlink hyperlink new org.docx4j.wml.Hyperlink(); hyperlink.setId(rel.getId()); // hyperlink 内部再加 run 和 text因为这一步要额外处理 relationship很多项目为了赶进度直接用蓝色下划线文本代替链接这也是可以接受的。我在项目里做了真链接后续有空会单独写一篇这里先不展开。4. Spring Boot 对外接口与完整导出流程4.1 封装转换 Service 与接口实现转换器写完之后需要在 Service 层包一层门面。对外的方法有两个一个返回byte[]适合直接写 HTTP 响应一个接收OutputStream适合大文件流式输出。Service public class MarkdownToDocxServiceImpl implements MarkdownToDocxService { private final MarkdownToDocxConverter converter new MarkdownToDocxConverter(); Override public byte[] mdToDocx(String markdown) { try { return converter.convertToBytes(markdown); } catch (Exception e) { throw new BusinessException(Markdown 转 Word 失败 e.getMessage()); } } Override public void mdToDocx(String markdown, OutputStream os) throws IOException { converter.convertToStream(markdown, os); } }MarkdownToDocxConverter.convertToBytes的核心逻辑就是解析、遍历、保存public byte[] convertToBytes(String markdown) throws Exception { WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.createPackage(); // 页面设置... // 解析并渲染... ByteArrayOutputStream baos new ByteArrayOutputStream(); wordMLPackage.save(baos); return baos.toByteArray(); }Controller 层写一个接口接收 Markdown 字符串返回 .docx 文件下载PostMapping(/export-docx) public void exportDocx(RequestBody ExportDocxRequest request, HttpServletResponse response) throws IOException { byte[] docxBytes markdownToDocxService.mdToDocx(request.getMarkdown()); // 设置响应头 response.setContentType(application/vnd.openxmlformats-officedocument.wordprocessingml.document); response.setHeader(Content-Disposition, attachment; filename URLEncoder.encode(request.getFileName(), UTF-8) .docx); response.getOutputStream().write(docxBytes); response.getOutputStream().flush(); }Content-Disposition里的文件名必须做 URL 编码否则中文文件名在浏览器里可能变成乱码。Content-Type也有讲究.docx对应的 MIME 类型是application/vnd.openxmlformats-officedocument.wordprocessingml.document如果写成了application/octet-stream下载功能也能用但浏览器对“在线预览”的支持会变差。4.2 中文字体与页面细节docx 的默认字体在西文环境下通常是 Calibri中文环境下会变成“等线”这本身没问题。但如果我们转换的 Markdown 里既有中文又有代码最好在文档级别统一设置字体避免 Word 打开时因为找不到字体而自动替换。我习惯在创建完包之后设置默认字体为“微软雅黑”RPr defaultRpr new RPr(); RFonts rf new RFonts(); rf.setAscii(Calibri); rf.setEastAsia(微软雅黑); rf.setHAnsi(Calibri); defaultRpr.setRFonts(rf);setEastAsia这个属性必须设置它是 OOXML 专门为中文及东亚字符准备的字体字段。如果只设置ascii中文字符不会生效生成的文件在 Windows 上会显示默认的宋体。字体这块踩坑概率极高因为本地测试时 Linux 环境不一定有微软雅黑Word 打开预览效果和服务器渲染出来的字体名没有直接关系直到最终用户打开才发现乱换字体。段落间距也需要全局控制。docx 默认段落间距为 0如果 Markdown 文档段落之间有空行转换后 Word 里两段会挤在一起。我在每个段落生成时都设置了Spacing段后 6pt120 twips到 12pt240 twips之间具体数值可按公司规范调。注意 Spring Boot 接口里的文件名默认值也要做空值判断。5. 常见问题与排查技巧实录5.1 环境类问题JAXB 与 JDK 模块化最常遇到的是启动时报ClassNotFoundException: javax.xml.bind.JAXBContext。这个问题的原因就是前面提到的 JDK 11 移除了 JAXB API。排查思路很简单先看 JDK 版本再看依赖树里有没有 JAXB API 和实现。我推荐用一条命令排查mvn dependency:tree -Dincludesjavax.xml.bind:jaxb-api,org.glassfish.jaxb:jaxb-runtime如果jaxb-api是provided或optional状态运行期肯定会缺。Spring Boot 2.x 的spring-boot-starter-web不会主动带 JAXB必须自己加。5.2 生成文件打不开或提示“内容有问题”文件打不开第一件事不是怀疑代码而是先解压看看 XMLunzip -p export.docx word/document.xml | head -100docx 本质是 zip 包Word 打不开通常是内部 XML 不合规。常见的有两种情况一是某个节点的命名空间对不上二是w:t里塞了转义不对的字符比如把直接写进去了。docx4j 的Text对象会自动处理 XML 转义但如果你手动拼过 XML 字符串就很有可能漏掉、、。我还遇到过一种情况Markdown 原文里有\n换行符没有转换成Br直接用纯文本塞进去Word 里表现为没换行。这种不太会导致文件打不开但会让格式诡异。建议在开发阶段准备一份覆盖所有语法的测试 Markdown每次改完转换器都跑一遍然后打开实际 Word 文件检查。我测试用的 Markdown 模板大概 80 行包含各级标题、加粗斜体、表格、代码块、列表、引用块、换行和链接。这个东西很有价值建议保留。5.3 表格无边框、列宽被压缩表格生成后没有边框九成是因为没设置TblBorders。docx 表格默认是没有边框的除非引用了一个带边框的表格样式。我上面的代码里直接给TblPr设了边框这是一种最稳妥的方案。列宽被压缩则要看TblGrid和单元格TcPr的设置是否一致。一个容易忽略的点是如果表格中某个Tc的TblWidth没设置Word 会按内容自动分配导致原本的列宽失效。建议每个Tc的TcPr都明确设置宽度。还有一种情况是表格后面紧跟着的段落被“吸进”了表格导致表格多了一行。这种问题通常是因为遍历顺序里没把表格后的Paragraph正确处理。检查方法就是解压 document.xml看表格的/w:tbl后面是不是直接跟着新的段落了。5.4 空格被吃掉和换行丢失setSpace(preserve)的问题在 3.3 里说过了这里再强调一次Markdown 里的纯文本节点只要可能包含连续空格或者开头结尾空格都要设置xml:spacepreserve。这个属性不影响正常文字的显示只有多余空格时才有区别但一旦遇到“两个空格”这种 Markdown 换行语法不设置就会出现格式错位。换行丢失的问题主要是SoftLineBreak没处理。GFM 里普通换行不表示段结束只表示软换行。如果没有转换SoftLineBreak为BrWord 会把整段文字糊在一起。硬换行HardLineBreak同理。5.5 常见问题速查表问题现象可能原因解决方案启动报ClassNotFoundException: JAXBContextJDK 11 缺少 JAXB 依赖添加jaxb-api、jaxb-runtime、activation生成 docx 打不开XML 结构不规范Tc层级错误解压 XML 检查确保Tc - Paragraph - R - Text表格无边框TblBorders未设置在TblPr里设置上下左右及内部边框中文显示为方块或乱码RFonts未设置eastAsia设置rf.setEastAsia(微软雅黑)Markdown 连续空格消失Text未设置spacepreservedocxText.setSpace(preserve)段落换行丢失未处理SoftLineBreak转换为Br对象表格列数不一致某行缺列或多列遍历时统一补齐Tc内存占用过高超大 Markdown 字符串同时保存在内存考虑流式解析或加体积限制个人实际跑这个方案时最深的体会是不要一开始就追求支持所有 Markdown 特性先把标题、段落、列表、表格、行内样式这五类稳定输出再逐步加引用块、代码块、图片和链接。docx 的 XML 结构非常啰嗦每一步转换都可能引入一个看不见的坑开发阶段准备一份覆盖各种语法的测试 Markdown每次改完跑一遍对比 Word 输出比单元测试还能发现问题。最后再分享一个细节文本节点一定要记得setSpace(preserve)Word 默认会吃掉多余空格这个问题我当时排查了整整一下午希望你不用再踩一遍。