ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot实现Markdown转Word:commonmark与docx4j完整实践

Spring Boot实现Markdown转Word:commonmark与docx4j完整实践 前阵子接了一个需求前端页面用Markdown编辑器让用户填写内容保存到数据库里的是Markdown源字符串但后台导出时必须生成正式的Word文档.docx。技术栈是Spring Boot服务端本来就有文档处理的能力只是这次要新加一个“Markdown字符串转Word”的转换模块。调研了一圈最后敲定的方案是commonmark commonmark-ext-gfm-tables负责解析Markdowndocx4j负责产出Word文档。这套组合在Spring Boot项目里跑得很稳不仅把常见的标题、段落、列表、表格都正确处理了而且整个过程不需要额外安装任何外部程序纯Java搞定。这篇就把整个实现思路和关键代码完整拆开聊适合正在做Java后端文档转换、或者想在Spring Boot项目里内置Markdown导出Word功能的朋友参考。1. 需求梳理与技术选型1.1 这个需求到底在解决什么表面上这是个“格式转换”需求但实际上有三个隐藏点容易被忽略。第一输入是字符串不是文件。很多团队做Markdown转Word时会借助外部命令行工具直接把.md文件喂进去或者用在线接口转换。但我们的场景是数据库里存的文本用户是通过表单提交的数据根本没落地成文件。如果临时把字符串写成临时文件再调用外部工具既有安全问题临时文件清理、路径穿越也会增加部署复杂度。所以最好的方案就是纯Java库把字符串直接灌进内存里完成全流程。第二Markdown不只是“标题加段落”。真实业务里有表格、代码块、引用、列表、加粗、行内代码、超链接。尤其是表格用户在前端Markdown编辑器里写得挺欢导出Word后如果表格全部丢失或者样式稀烂这个功能就废了。所以“能不能支持GFMGitHub Flavored Markdown表格”直接决定方案能不能用。第三docx不是简单文本文件。docx本质是个ZIP包内部是各种XML文档。你不可能自己拼字符串拼出一个合法的docx。必须有专业库来构建文档结构树docx4j就是干这个的。1.2 为什么偏偏是commonmark配docx4j当时其实摆了好几个选项我把它们列出来对比一下你就明白为什么最后落在这个组合上。第一个是Pandoc号称“文档转换瑞士军刀”效果确实好但它是独立的二进制程序Java侧要使用Runtime.exec去调用。服务器部署的时候除了JDK还得装Pandoc而且跨平台路径、版本管理、并发调用资源回收全是坑。我们团队不想引入这种外部依赖。第二个是flexmark-java它是基于CommonMark规范的Java实现功能相当丰富表格、锚点、YAML头、脚注都有扩展。但我实测下来它的API风格偏重Table扩展的衔接需要自己写的代码量不少而且它对docx输出本来也没有内置支持该走的桥还是要走。相比之下commonmark-java更加标准扩展机制干净社区活跃度也更高。第三个是Apache POI的XWPF它确实能操作docx而且很多老项目都在用。但POI对复杂文档结构表格嵌套、分节符、页眉页脚的操作非常繁琐你完全是在和XML节点搏斗。docx4j类似JAXB绑定的docx对象模型属于“语义化”操作设置字体、表格边框、单元格底色的代码直观得多。结论很清楚commonmark-java负责把字符串解析成标准ASTdocx4j负责把AST翻译成Word对象模型。扩展方面commonmark-ext-gfm-tables这个官方扩展专门负责解析GFM表格正好补齐了最关键的缺口。2. 依赖引入与基础架构2.1 Maven坐标与版本兼容先看Maven依赖。Spring Boot版本决定docx4j的选型这里有个容易踩的版本断层问题docx4j从11.x开始用了Jakarta命名空间jakarta.xml.bind这正好兼容Spring Boot 3.x如果还在用Spring Boot 2.x那只能锁docx4j 8.3.x的javax版本。我项目用的是Spring Boot 3.x所以依赖如下dependency groupIdorg.commonmark/groupId artifactIdcommonmark/artifactId version0.22.0/version /dependency dependency groupIdorg.commonmark/groupId artifactIdcommonmark-ext-gfm-tables/artifactId version0.22.0/version /dependency dependency groupIdorg.docx4j/groupId artifactIddocx4j-JAXB-ReferenceImpl/artifactId version11.4.9/version /dependency这里我特意指定了commonmark版本0.22.0因为0.21.0之后表格扩展对表头表体的处理和Paragraph结构有过调整升级后API更统一。如果你在旧项目里用spring-boot-starter-parent统一管理版本注意确认没有冲突。docx4j-JAXB-ReferenceImpl里包含了org.docx4j:docx4j-core等传递依赖不需要额外引其他子模块。2.2 转换器的整体设计项目里我建了一个独立的转换服务不跟Controller的业务逻辑混在一起。结构上分成三层MarkdownToDocxConverter对外只暴露一个方法传入Markdown字符串返回byte[]或WordprocessingMLPackageDocxMarkdownVisitor核心渲染器继承commonmark的AbstractVisitor把所有AST节点映射为docx4j对象DocxStyleFactory专门管理样式比如标题字号、表格边框、字体设置集中在一个类里方便后续调整。这样分层的目的是有两个一是Markdown解析和docx生成可以分别做单元测试二是如果以后想支持从File或URL读取Markdown源只需要在入口层加一个适配方法Visitor完全不用动。一个简单的使用入口大概长这样Service public class MarkdownToDocxConverter { private final Parser parser; private final DocxMarkdownVisitor visitor; public MarkdownToDocxConverter() { this.parser Parser.builder() .extensions(List.of(TablesExtension.create())) .build(); this.visitor new DocxMarkdownVisitor(); } public byte[] convertToBytes(String markdown) throws Docx4JException { Node document parser.parse(markdown); WordprocessingMLPackage wordMLPackage WordprocessingMLPackage.createPackage(); visitor.render(document, wordMLPackage); ByteArrayOutputStream baos new ByteArrayOutputStream(); Docx4J.save(wordMLPackage, baos); return baos.toByteArray(); } }你可能注意到我把Parser的构建拆到了构造函数里。这是因为commonmark的Parser构建成本并不低每次请求都new一个会产生不必要的浪费直接在Spring Bean初始化时建好线程安全也没问题。3. 从Markdown字符串到docx核心实现3.1 第一步把字符串解析成ASTcommonmark的核心设计是“解析与渲染分离”。解析阶段做的事情很简单读入字符串产出语法树AST。这个AST极其严谨所有节点都是Node的子类比如Document、Paragraph、Heading、BulletList、FencedCodeBlock、TableBlock等等。解析代码就一行Parser parser Parser.builder() .extensions(List.of(TablesExtension.create())) .build(); Node root parser.parse(markdownString);那为什么解析阶段要单独做成AST而不是直接生成Word因为AST是标准化的中间表示。不管你的Markdown来源是前端表单、第三方API还是本地文件解析出的AST结构完全一致。后续要接docx4j、PDF、HTML渲染器都可以复用同一个AST。另外提醒一下parser.parse()不会校验语法正确性它对Markdown字符串非常宽容。实际上哪怕是空字符串它也会生成一个空的Document节点。所以业务层要对“空内容”做单独判断。3.2 第二步用Visitor把AST节点映射成docx4j对象AST有了接下来就是核心遍历AST把每个节点翻译成对应的docx4j对象。docx4j的对象模型里一个Word文档可以简单理解成这样WordprocessingMLPackage整个Word文件MainDocumentPart正文部分P段落R文本片段可以设置字体、加粗、颜色Table表格Tr表格行Tc表格单元格。commonmark的AbstractVisitor提供了很好的遍历机制。我继承了它重写各节点的visit方法在visit(Paragraph)里创建docx4j的P对象在visit(Heading)里创建带样式的P对象在visit(FencedCodeBlock)里创建等宽字体段落。一个关键细节docx4j的内容追加方式和想象中不太一样。你不能直接往MainDocumentPart里push而是要拿wordMLPackage.getMainDocumentPart().addObject(...)。但同时段落内的文本片段R是挂在P下的Override public void visit(Paragraph paragraph) { P p new P(); handleChildren(paragraph, p); mainDocumentPart.addObject(p); preserveSpacingAfterPrevious(p); } private void handleChildren(Node parent, P p) { for (Node child : parent.getChildren()) { if (child instanceof Text) { R r new R(); r.setContent(List.of(new TextNode(((Text) child).getLiteral()))); p.getContent().add(r); } else if (child instanceof Emphasis) { // 加粗/斜体处理 } else if (child instanceof SoftLineBreak || child instanceof HardLineBreak) { // 换行符处理docx4j里是Br对象 Br br new Br(); p.getContent().add(br); } } }这里有个容易出错的地方commonmark的Text节点里保存的是纯文本字符串直接setLiteral就行。但docx4j的Text是org.docx4j.wml.Text别和commonmark的Text搞混了两个包的类名完全一样但类型不同一开始我写代码时IDE自动导入了commonmark的Text编译报错查了半天。标题的渲染更直白把HeadingLevel映射成Word的Heading样式其实就是设置段落样式ID或者直接设置字号加粗Override public void visit(Heading heading) { P p new P(); String styleVal switch (heading.getLevel()) { case 1 - Heading1; case 2 - Heading2; case 3 - Heading3; default - Heading4; }; p.setPPr(createPPrWithStyle(styleVal)); handleChildren(heading, p); mainDocumentPart.addObject(p); }关于换行这里我想多说一句。Markdown里两个空格加一个换行是硬换行HardLineBreak单个换行是软换行SoftLineBreak。如果你把软换行也直接翻译成Word里的换行就不会了。正确做法是软换行时在同一个段落里加一个Br但不要新建P对象。否则原本一个Markdown段落会因为源码里的换行被切成好几个Word段落排版会碎。4. GFM表格转换标题里点名的那块硬骨头4.1 表格在commonmark中的AST长什么样表格为什么值得单独开一章因为表格是Markdown中结构最复杂的块级元素。普通段落只是“文本流”表格是“二维结构”行和列的交错关系必须精确映射到docx4j的Table模型上。先说AST结构。使用TablesExtension之后Markdown里的表格会被解析成以下节点树TableBlock ├── TableHead │ └── TableRow │ ├── TableRowCell (文本) │ └── TableRowCell (文本) └── TableBody ├── TableRow │ ├── TableRowCell │ └── TableRowCell └── TableRow └── TableRowCell注意两点第一TableHead和TableBody下直接子节点就是TableRow不是TableRow的数组。遍历时要小心层级。第二TableRowCell继承了Paragraph类的结构它内部含有多个Text节点。你可以把TableRowCell理解成一个“嵌在表格里的段落集合”。取文本的简便方法是cell.getContent()转成字符串但如果你要处理加粗、代码块这类行内元素就得走handleChildren(cell, p)把子节点逐个映射进段落。private String plainTextOf(Node node) { StringBuilder sb new StringBuilder(); for (Node child : node.getChildren()) { if (child instanceof Text) { sb.append(((Text) child).getLiteral()); } else { sb.append(plainTextOf(child)); } } return sb.toString(); }这个方法能用但注意它会把加粗、链接、行内代码的标签全部丢掉。要保留样式必须走结构化映射这一点做不做全看业务定义。我的项目要求表格里可以有加粗文字和链接所以我在表格的单元格里也复用了段落级渲染器。4.2 表格转换的完整实现现在写完整的表格转换器。先看代码再解释Override public void visit(TableBlock tableBlock) { Table table new Table(); TblPr tblPr new TblPr(); TblBorders borders new TblBorders(); // 给表格加边框否则docx4j默认输出的表格在Word里看没有网格线 borders.setTop(createSingleBorder(4)); borders.setBottom(createSingleBorder(4)); borders.setLeft(createSingleBorder(4)); borders.setRight(createSingleBorder(4)); borders.setInsideH(createSingleBorder(4)); borders.setInsideV(createSingleBorder(4)); tblPr.setTblBorders(borders); table.setTblPr(tblPr); for (Node child : tableBlock.getChildren()) { if (child instanceof TableHead) { handleTableHead(table, (TableHead) child); } else if (child instanceof TableBody) { handleTableBody(table, (TableBody) child); } } mainDocumentPart.addObject(table); // 表格后加一个空段落否则表格后面直接跟文本会贴在表格上 mainDocumentPart.addObject(new P()); } private void handleTableHead(Table table, TableHead head) { for (Node rowNode : head.getChildren()) { if (rowNode instanceof TableRow trNode) { Tr tr new Tr(); handleRowCells(tr, trNode, true); table.getContent().add(tr); } } } private void handleTableBody(Table table, TableBody body) { for (Node rowNode : body.getChildren()) { if (rowNode instanceof TableRow trNode) { Tr tr new Tr(); handleRowCells(tr, trNode, false); table.getContent().add(tr); } } } private void handleRowCells(Tr tr, TableRow rowNode, boolean isHeader) { for (Node cellNode : rowNode.getChildren()) { if (cellNode instanceof TableRowCell rowCell) { Tc tc new Tc(); // 表头单元格设置底色和加粗 if (isHeader) { setTcShading(tc, D9E2F3); } for (Node inner : rowCell.getChildren()) { if (inner instanceof Paragraph paragraph) { P p new P(); handleChildren(paragraph, p); tc.getContent().add(p); } } tr.getContent().add(tc); } } }handleRowCells里有一个非常关键的处理逻辑表格单元格的AST结构是TableRowCell它的内部节点是普通Paragraph。你不能直接把TableRowCell当成Paragraph来渲染必须先拿到其内部的Paragraph再对Paragraph的子节点执行文本映射。如果漏了这层提取表格转换后内容会直接丢失。createSingleBorder的实现也很直白就是构造一个带单线样式的边框对象private TblBorders.Border createSingleBorder(int sz) { TblBorders.Border border new TblBorders.Border(); border.setVal(STBorder.SINGLE); border.setSz(BigInteger.valueOf(sz)); border.setColor(000000); return border; }这段代码有个细节值得说STBorder.SINGLE来自org.docx4j.wml.STBorder枚举对老手来说顺理成章但对第一次接触docx4j的人很可能直接写字符串或者忘了设置setVal结果表格边框全部不显示。4.3 让转换出来的表格看起来像样表格能把数据放进去只是及格要做到“像Word里手写的表格”还得补三个样式功能。第一是表头底色。用setTcShading给表头单元格加一个浅色背景视觉上直接和正文区分开private void setTcShading(Tc tc, String fill) { TcPr tcPr new TcPr(); Shd shd new Shd(); shd.setFill(fill); shd.setVal(STShd.CLEAR); tcPr.setShd(shd); tc.setTcPr(tcPr); }第二是单元格边距。Word表格如果没设置边距文字会紧贴单元格边缘观感很差。给Table的TblPr加上TblCellMarTblCellMar cellMar new TblCellMar(); cellMar.setTop(createMargins(60)); cellMar.setBottom(createMargins(60)); cellMar.setLeft(createMargins(100)); cellMar.setRight(createMargins(100)); tblPr.setTblCellMar(cellMar);TblCellMar各项的单位是缇twips60缇约等于1.06毫米100缇约等于1.76毫米。这个值对应Word里相对紧凑的样式如果希望更宽松可以调到120到140。第三是列宽控制。docx4j里需要给TblGrid设置GridCol并且每个单元格的TcW要匹配Word才会按预设宽度渲染TblGrid tblGrid new TblGrid(); // 假设每列等宽总宽度约15cm用缇表示大约是8500 int columnCount getColumnCount(tableBlock); int columnWidth 8500 / Math.max(1, columnCount); for (int i 0; i columnCount; i) { GridCol gridCol new GridCol(); gridCol.setW(BigInteger.valueOf(columnWidth)); tblGrid.getGridCol().add(gridCol); } table.setTblGrid(tblGrid);列宽的换算记住一个基准1厘米约等于567缇。所以15cm宽的表格大约对应8500缇。如果你的页面布局是A4纸带左右页边距正文宽度一般不到16cm8500缇是个安全的默认值。5. 实操过程中踩过的坑与排查记录5.1 版本断层问题这是所有坑里最隐蔽的一个。docx4j 11.x版本的Maven坐标虽然能用但它依赖JAXB Reference Implementation如果你的项目里同时存在老的javax.xml.bind相关依赖比如一些老的SDK运行时会抛出各种ClassNotFoundException。我项目花了一下午排查这个最后发现是一个老旧的excel导出工具引了javax.xml.bind。解决方法是给那个老依赖加exclusions排除掉或者在docx4j依赖上去掉多余的传递依赖。如果你的项目从Spring Boot 2升级到3一定要检查老的javax绑定包是否干净。5.2 中文与字体问题文档生成后打开一看中文全变成方块或者默认字体Calibri这是docx4j最常被吐槽的地方。原因不是docx4j不支持中文而是它默认没有为你设置中文字体eastAsia字体。Word处理东亚字符时看的是rFonts元素的eastAsia属性如果不设置Word按西文字体Calibri解析自然乱套。给文本片段设置中文字体的代码private void setEastAsiaFont(R r, String fontName) { RPr rpr r.getRPr(); if (rpr null) { rpr new RPr(); r.setRPr(rpr); } CTRFonts fonts rpr.getRFonts(); if (fonts null) { fonts new CTRFonts(); rpr.setRFonts(fonts); } fonts.setEastAsia(fontName); fonts.setAscii(fontName); fonts.setHAnsi(fontName); fonts.setCs(fontName); }我的做法是全局统一设置在MarkdownToDocxConverter里创建Package后直接切一个PhysicalFont把默认字体改成“宋体”或者“微软雅黑”。如果不需要那么细的颗粒度可以直接覆盖RFonts属性。docx4j有个简单的字体映射方式FontMapper fontMapper new IdentityPlusMapper(); wordMLPackage.setFontMapper(fontMapper);但注意IdentityPlusMapper只是建立物理字体到文档字体的映射关系如果你本机没有安装对应字体Word还是可能显示异常。最稳妥的办法就是在每个R上明确设置中文字体名称——Word用户电脑上一般都有宋体、黑体、微软雅黑。5.3 表格列宽、单元格内容不生效的问题表格转换完以后在Word里打开发现列宽完全没按设置的来单元格内容却垂直居中但水平左对齐乱糟糟的。这里有两个隐藏规则第一docx4j的TcW必须在表头行所有单元格上都设置如果只有第一行设置了列宽下面行没有Word会自适应布局把列宽拉扯得很难看。所以我在handleRowCells里给每个Tc都设置了与列序号对应的TcW。第二想让表格内容水平居中必须设置Jc枚举。否则Word默认是左对齐。表头加粗加居中是标准表格的基本样式PPr pPr new PPr(); Jc jc new Jc(); jc.setVal(JcEnum.CENTER); pPr.setJc(jc); p.setPPr(pPr);5.4 文件输出与下载时的小细节最后一个不是Markdown转换本身的问题但碰到概率极高。Spring Boot后端把byte[]返回给前端前端用Blob触发下载结果用WPS或Word打开后提示“文件已损坏是否修复”。我排查下来原因有两个一是docx文件本身需要正确保存。直接用Docx4J.save(wordMLPackage, baos)是标准做法但你如果用FileOutputStream以文本模式打开再输出文件就毁了。必须用对流方式别手动转字符串。二是HTTP响应头没设置正确。Spring Boot的ResponseEntitybyte[]要配Content-Disposition并且不要带charset否则有些浏览器端工具会以文本方式解读return ResponseEntity.ok() .contentType(MediaType.parseMediaType(application/vnd.openxmlformats-officedocument.wordprocessingml.document)) .header(HttpHeaders.CONTENT_DISPOSITION, attachment; filenameexport.docx) .body(bytes);注意application/vnd.openxmlformats-officedocument.wordprocessingml.document这个MIME类型千万别图省事写成application/octet-stream后者虽然也能触发下载但有些客户端不太认。5.5 性能与并发问题还有一个容易被忽略的点docx4j创建WordprocessingMLPackage的开销比想象中大特别是在并发高的场景下。我一开始直接在Controller方法里每次转换都新建Package压测时发现CPU飙得很高。解决办法有两个方向。一是用ThreadLocal复用Parser实例因为commonmark的Parser是线程安全的而docx4j的WordprocessingMLPackage不是。二是把转换过程异步化比如Spring的Async配合CompletableFuture把文档处理放到独立线程池里不要让HTTP请求线程一直阻塞在这。我实际项目中是直接单独给这个转换接口开了一个ExecutorService核心线程数设为CPU核心数队列容量控制一下。实测下来200并发请求下的响应时间比同步处理快了将近一倍。6. 扩展与后续优化方向这个转换器做完基础版之后我又往里面加了两个比较实用的功能也分享出来。一个是代码块的等宽字体和浅灰底色。Markdown里的围栏代码块在Word里如果按普通段落渲染完全看不出代码的样子。我给FencedCodeBlock节点单独生成一个段落并设置等宽字体如Consolas再给这个段落的PPr加上底纹Override public void visit(FencedCodeBlock codeBlock) { P p new P(); p.setPPr(createCodeBlockPPr()); R r new R(); setEastAsiaFont(r, Consolas); r.setContent(List.of(new TextNode(codeBlock.getLiteral()))); p.getContent().add(r); mainDocumentPart.addObject(p); }第二个是图片处理。Markdown里的图片如果只是![alt](url)这种网络图片链接直接转出来在Word里会显示成空白。docx4j要往文档里加图需要先把图片读成二进制流用BinaryPart关联到Package然后再创建Drawing对象。这块代码量不小但配合Spring的ResourceLoader可以很容易地实现本地图片路径的支持。如果你想在这个方向继续做深还可以考虑把转换器抽成独立模块提供REST接口给其他服务调用。我已经是这么做的了服务内只暴露一个POST接口入参是字符串出参是base64编码的docx文件内容这样前端拿到后直接用atob转成Blob就能下载整个链路很干净。最后再分享一个调样式的小技巧docx4j生成的Word文档在Word/WPS中打开时的观感和你在浏览器里看Markdown渲染的结果差别很大。Markdown页面是“紧凑流式”风格而Word文档讲究“段落呼吸感”。所以在给段落设置间距时别把Web端的样式参数直接照搬。让每个段落保留了默认的行距和段间距后再用Word自己的文档网格去排版效果反而更自然。这个分寸是在多次修订中试出来的直接抄默认值反而是最稳的起步点。
返回列表