1.转化为html
nodejs存在一些库能够解析docx文档:
| 库名称 | 主要功能 | 使用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| docx | 生成 DOCX | 动态创建 Word 文档(如报告、合同) | - 纯 JS,无依赖 - 支持表格、图片、样式 - API 直观 | - 不支持解析现有 DOCX - 仅适用于生成新文件 |
| mammoth.js | 解析 DOCX → HTML/Markdown | 提取 DOCX 内容并转换格式 | - 轻量级 - 保留基本样式(如标题、列表) | - 不支持修改 DOCX - 复杂格式可能丢失 |
| docxtemplater | 模板化 DOCX(变量替换) | 批量生成文档(如证书、发票) | - 支持循环、条件逻辑 - 保留原格式 | - 需要预定义模板 - 不适用于复杂文档生成 |
| officegen | 生成 DOCX/PPTX/XLSX | 创建 Office 文档(兼容旧版 Node) | - 支持多种格式 - 简单 API | - 维护较少 - 功能较基础 |
| pandoc(需 CLI) | DOCX ↔ HTML/Markdown/PDF 等 | 格式转换(如 DOCX 转 Markdown) | - 支持多种格式 - 高质量转换 | - 需安装外部工具 - 不适合动态生成 |
| unzipper + XML 解析 | 底层操作 DOCX | 需要直接修改 DOCX 内部结构 | - 完全控制文件内容 | - 开发复杂 - 需手动处理 XML |
相较之下mammoth比较合适使用在当前场景,该工具本身支持直接转为markdown
mammoth document.docx --output-format=markdown
但实践中效果并不是很好,文档会产生一些转义符、锚点处生成了a标签、无法转换表格等问题。
因此决定先通过mammoth,先将docx文档转化为html,再解析html转为markdown
mammoth转为html的这一步将docx的xml格式会剔除字体、字号等冗余信息,结构也变得简化,给转换markdown提供了基础。
2.转化为markdown
这一步没什么特别的,只是简单的根据html标签转化为markdown的语法
此处没有递归处理各节点,因为目前看到的
mammoth生成的html没有深层嵌套节点,有需要进一步优化可以加入递归转化
另外需要注意将html实体编码进行解码处理。
const dom = document.querySelector('#container');
const markdownText = htmlToMarkdown(dom);
const decodeMarkdownText = decodeHTMLEntities(markdownText);
function htmlToMarkdown(element) {
const tagMap = {
H1: "#",
H2: "##",
H3: "###",
H4: "####",
H5: "#####",
H6: "######",
};
let result = [];
function processNode(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName;
switch (tagName) {
case "H1":
case "H2":
case "H3":
case "H4":
case "H5":
case "H6":
result.push(`${tagMap[tagName]} ${node.textContent.trim()}\n`);
break;
case "P":
let pText = Array.from(node.childNodes).reduce((acc, child) => {
if (child.tagName === "BR") {
acc += " \n";
} else if (child.nodeType === Node.TEXT_NODE) {
acc += child.textContent.trim();
} else {
acc += child.textContent.trim();
}
return acc;
}, "");
result.push(pText);
break;
case "UL":
Array.from(node.children).forEach(child => {
result.push(`- ${child.textContent.trim()}`);
});
result.push("");
break;
case "OL":
Array.from(node.children).forEach((child, index) => {
result.push(`${index + 1}. ${child.textContent.trim()}`);
});
result.push("");
break;
case "A":
const href = node.getAttribute("href");
result.push(`[${node.textContent}](${href})`);
break;
case "STRONG":
result.push(`**${node.textContent}**`);
break;
case "EM":
result.push(`*${node.textContent}*`);
break;
case "TABLE":
const rows = Array.from(node.querySelectorAll("tr"));
if (rows.length > 0) {
const headers = Array.from(rows[0].querySelectorAll("th, td")).map(cell =>
cell.textContent.trim()
);
const separator = headers.map(() => "---");
result.push(`\n| ${headers.join(" | ")} |\n| ${separator.join(" | ")} |`);
for (let i = 1; i < rows.length; i++) {
const cells = Array.from(rows[i].querySelectorAll("td")).map(cell =>
cell.textContent.trim()
);
result.push(`| ${cells.join(" | ")} |`);
}
}
break;
default:
Array.from(node.childNodes).forEach(processNode);
}
} else if (node.nodeType === Node.TEXT_NODE) {
result.push(node.textContent.trim());
} else if (node.nodeType === Node.ELEMENT_NODE && node.hasChildNodes()) {
Array.from(node.childNodes).forEach(processNode);
}
}
processNode(element);
return result.join("\n").replace(/\n{3,}/g, "\n\n");
}
function decodeHTMLEntities(text) {
const textArea = document.createElement("textarea");
textArea.innerHTML = text;
return textArea.value;
}