Markdown 导出 Word 文档技术方案
一、整体架构
Markdown 源文本
marked.js 渲染
HTML 字符串
DOM 解析器
docx 库构建
Word 文档
Mermaid 预处理
SVG → PNG
核心设计原则:统一 HTML 源架构
- Markdown 渲染后维护一份干净的 HTML 变量
- 预览展示和 Word 导出共用同一 HTML 源
- Mermaid SVG 转图片逻辑复用,确保一致性
二、导出流程详解
2.1 第一阶段:Markdown → HTML
javascript
// 使用 marked.js 将 Markdown 转为 HTML
import { marked } from 'marked'
const htmlContent = marked.parse(markdownText)
2.2 第二阶段:Mermaid 预处理
是
否
遍历所有 pre.mermaid
是否已渲染 SVG?
提取 SVG 内容
调用 mermaid.render
SVG → PNG 转换
存入 mermaidImages Map
关键函数:
collectMermaidImages()- 预收集所有 Mermaid 图表svgToPngForWord()- SVG 转 PNG(base64)
2.3 第三阶段:HTML → docx 元素
h1-h6
p
pre
ul/ol
img
blockquote
table
创建临时 DOM 容器
解析 HTML 字符串
递归遍历 DOM 节点
节点类型?
创建 Heading
创建 Paragraph
创建代码块
递归处理列表
创建 ImageRun
创建引用块
创建 Table
2.4 第四阶段:生成 Word 文件
javascript
const doc = new Document({
sections: [{
children: docxElements // 转换后的元素数组
}]
})
const buffer = await Packer.toBlob(doc)
await saveBlobToFile(buffer, filename)
三、遇到的问题及解决方案
问题 1:第三方库支持不足
| 项目 | 描述 |
|---|---|
| 现象 | 使用 html-to-docx 库导出时,代码块和图片丢失 |
| 原因 | 该库对复杂 HTML 结构(嵌套 table、inline style 包装)支持差 |
| 方案 | 弃用 html-to-docx,改用 docx 库自建解析器 |
| 经验 | 第三方库有局限性,核心功能需自主可控 |
问题 2:有序列表编号丢失
| 项目 | 描述 |
|---|---|
| 现象 | <ol> 导出后无编号,只有文字内容 |
| 原因 | 当 <li> 第一个子元素是 <p> 时(如 <li><p>内容</p></li>),textBuffer 为空,flushTextBuffer() 不输出 prefix |
| 方案 | 检测到 <p> 是首个子元素时,将 prefix 与 <p> 内容合并后再输出 |
问题代码结构:
html
<!-- 简单结构(正常)-->
<ol>
<li>项目一</li>
</ol>
<!-- 复杂结构(编号丢失)-->
<ol>
<li><p>项目一</p></li>
</ol>
修复逻辑:
javascript
if (childTag === 'p' && isFirst && !textBuffer.trim()) {
// 首个子元素是 <p>,合并 prefix 和 <p> 内容
textBuffer = child.textContent
flushTextBuffer() // 此时 prefix + textBuffer 一起输出
}
问题 3:嵌套列表内容丢失
| 项目 | 描述 |
|---|---|
| 现象 | 多级嵌套的 <ul>/<ol> 只输出第一层 |
| 原因 | 仅使用 li.textContent 获取内容,未递归遍历子元素 |
| 方案 | 实现通用递归解析架构,支持任意深度嵌套 |
错误做法:
javascript
// ❌ 只取文本,嵌套结构丢失
const text = li.textContent
正确做法:
javascript
// ✅ 递归处理所有子元素
function processElement(element, indentLevel) {
for (const child of element.children) {
if (child.tagName === 'UL' || child.tagName === 'OL') {
result.push(...processElement(child, indentLevel + 1))
}
// ... 其他元素处理
}
}
问题 4:内联元素错误换行
| 项目 | 描述 |
|---|---|
| 现象 | 这是**粗体**文字 被拆成三行 |
| 原因 | <strong>、<em> 等内联元素被误判为块级元素,触发换行 |
| 方案 | 精确区分内联/块级元素,内联元素追加到 textBuffer,块级元素才触发 flush |
元素分类:
javascript
// 块级元素(触发换行)
const blockTags = ['p', 'pre', 'div', 'ul', 'ol', 'li', 'blockquote', 'table', 'img']
// 内联元素(不换行,追加到 buffer)
const inlineTags = ['strong', 'em', 'code', 'a', 'span', 's', 'del', 'b', 'i', 'u']
问题 5:Mermaid CSS 残留污染
| 项目 | 描述 |
|---|---|
| 现象 | 导出的 Word 中出现 #mermaid-xxx{fill:#333;...} 乱码 |
| 原因 | Mermaid 渲染后 <pre> 中残留 CSS 样式代码,被当作普通代码块输出 |
| 方案 | 正则检测 #mermaid-xxx + {...} 模式,显式跳过不输出 |
检测逻辑:
javascript
const text = preElement.textContent
const isMermaidCss = /#mermaid-[^\s]+/.test(text) && /\{[^}]*\}/.test(text)
if (isMermaidCss) {
continue // 跳过,不输出
}
问题 6:Buffer 类型不兼容
| 项目 | 描述 |
|---|---|
| 现象 | saveBlobToFile 报错:blob.arrayBuffer is not a function |
| 原因 | html-to-docx 返回的是 Node.js Buffer,而非浏览器 Blob |
| 方案 | 在 fileSaver.js 中兼容处理,检测类型后统一转换 |
javascript
let arrayBuffer
if (blobOrBuffer instanceof Blob) {
arrayBuffer = await blobOrBuffer.arrayBuffer()
} else if (Buffer.isBuffer(blobOrBuffer)) {
arrayBuffer = blobOrBuffer.buffer
}
问题 7:HTML 预览列表无样式
| 项目 | 描述 |
|---|---|
| 现象 | 预览区的 <ol> 无编号,<ul> 无符号 |
| 原因 | Tailwind CSS Preflight 重置了 list-style-type: none |
| 方案 | 在 .prose 样式中手动覆盖 |
css
.prose :deep(ol) {
list-style-type: decimal;
}
.prose :deep(ul) {
list-style-type: disc;
}
四、架构演进历程
v1.0.2 完善
v1.0.1 重构
v1.0.0 初版
html-to-docx 库
简单 HTML 结构
问题多:图片丢失、代码块丢失
迁移到 docx 库
自建 DOM 解析器
解决图片和代码块问题
通用递归架构
支持任意嵌套
精确内联/块级区分
智能文件命名
五、核心经验总结
| 序号 | 经验 |
|---|---|
| 1 | 自建解析器更可控:第三方库有局限性,核心功能需自主实现 |
| 2 | 递归处理是必须的:HTML 结构可无限嵌套,必须支持任意深度 |
| 3 | 内联/块级必须精确区分:错误分类会导致格式混乱 |
| 4 | CSS 框架有副作用:Tailwind Preflight 会重置原生样式 |
| 5 | 统一 HTML 源架构:预览和导出共用同一数据源,确保一致性 |
| 6 | 预收集机制:Mermaid 图表先收集到 Map,解析时直接查表 |