Word 文档解析
要点
- DOCX 文件本质上是一个 ZIP 压缩包,里面装的是 XML 文件——理解这层结构,后续解析逻辑就有了着落
- Workers 环境不支持 Node.js 原生模块,mammoth 是目前最稳的纯 JS 方案
extractRawText提取纯文本,convertToHtml保留标题、表格、列表等结构信息- DOCX 和 PDF 适用场景不同:用户编辑的文档优先 DOCX,最终分发的文件走 PDF
- 把 Word 文档转为 LLM 可用的结构化文本,是 AI 应用中一个常见需求
1. DOCX 文件的真实结构
DOCX 文件后缀改成 .zip 解压,能看到里面的内容:
document.docx(解压后)
├── [Content_Types].xml # 内容类型声明
├── _rels/.rels # 顶层关系文件
├── word/
│ ├── document.xml # 文档主体内容(正文在这里)
│ ├── styles.xml # 样式定义
│ ├── numbering.xml # 列表编号定义
│ ├── media/ # 图片等媒体文件
│ └── _rels/document.xml.rels
└── docProps/
├── app.xml # 应用属性
└── core.xml # 核心属性(作者、创建时间)正文在 word/document.xml 里:
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p>
<w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
<w:r><w:t>这是一级标题</w:t></w:r>
</w:p>
<w:p>
<w:r><w:t>这是一段普通文本。</w:t></w:r>
</w:p>
<w:tbl>
<w:tr>
<w:tc><w:p><w:r><w:t>列1</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>列2</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>
</w:body>
</w:document>关键标签:
<w:p>— 段落<w:pStyle w:val="Heading1"/>— 一级标题标记<w:r>— 文本运行(run),一段里可以有多个 run<w:t>— 实际文本内容<w:tbl>/<w:tr>/<w:tc>— 表格 / 行 / 单元格
理解了这层结构,用库解析时就知道数据从哪来,遇到特殊情况也能自己写 XML 解析。
2. Workers 环境下的库选择
Workers 运行时不是 Node.js,没有 fs、path,不支持 C++ 原生 addon。几个方案对比:
| 库 | Workers 可用 | 说明 |
|---|---|---|
mammoth | ✅ | 纯 JS,专注 DOCX → HTML/Markdown |
jszip + 手动解析 | ✅ | 灵活但需要自己写转换逻辑 |
libreoffice-convert | ❌ | 依赖 LibreOffice 进程 |
docx | ❌ | 生成 DOCX 的,不是解析 |
推荐 mammoth——纯 JS 实现,自己处理 ZIP 解压和 XML 解析,在 Workers 的 V8 isolate 里直接可用。
pnpm add mammoth如果需要完全控制解析过程,也可以用 jszip 解压 + DOMParser 解析 XML,但列表编号、嵌套表格这些都需要自己处理,工作量不小。
3. 提取纯文本内容
最简单的场景:只需要拿到文档里的文字。
import { Hono } from 'hono'
import mammoth from 'mammoth'
const app = new Hono()
app.post('/api/parse/text', async (c) => {
const formData = await c.req.formData()
const file = formData.get('file')
if (!file || !(file instanceof File)) {
return c.json({ error: '请上传一个文件' }, 400)
}
if (!file.name.endsWith('.docx')) {
return c.json({ error: '仅支持 .docx 格式' }, 400)
}
const arrayBuffer = await file.arrayBuffer()
const result = await mammoth.extractRawText({ arrayBuffer })
return c.json({
text: result.value,
warnings: result.messages.map((m) => m.message),
})
})
export default appextractRawText 把所有文本拼接起来,段落之间用换行分隔,格式信息全部丢掉。适合只关心内容不关心排版的场景,比如提取文本丢给 LLM 做摘要。
4. 提取结构化内容
保留标题层级、列表、表格,用 convertToHtml:
const result = await mammoth.convertToHtml({ arrayBuffer })
// result.value 包含 <h1>、<h2>、<ul>、<table> 等结构化 HTML自定义样式映射
企业用户的 Word 文档经常带自定义样式,不做映射会被当作普通段落:
const result = await mammoth.convertToHtml(
{ arrayBuffer },
{
styleMap: [
"p[style-name='Heading 1'] => h1:fresh",
"p[style-name='Heading 2'] => h2:fresh",
"p[style-name='重要提示'] => div.important-callout:fresh",
"p[style-name='代码块'] => pre > code:fresh",
"p[style-name='页眉'] => !", // 忽略
],
}
):fresh 表示每次新建元素不跟前面合并,! 表示忽略该样式内容。
HTML 转 Markdown
LLM 场景下 Markdown 比 HTML 更友好:
function htmlToMarkdown(html: string): string {
let md = html
md = md.replace(/<h1>(.*?)<\/h1>/g, '# $1\n\n')
md = md.replace(/<h2>(.*?)<\/h2>/g, '## $1\n\n')
md = md.replace(/<h3>(.*?)<\/h3>/g, '### $1\n\n')
md = md.replace(/<p>(.*?)<\/p>/g, '$1\n\n')
md = md.replace(/<strong>(.*?)<\/strong>/g, '**$1**')
md = md.replace(/<em>(.*?)<\/em>/g, '*$1*')
md = md.replace(/<ul>([\s\S]*?)<\/ul>/g, (_, content) => {
const items = content
.match(/<li>(.*?)<\/li>/g)
?.map((li: string) => `- ${li.replace(/<\/?li>/g, '')}`)
.join('\n')
return (items ?? '') + '\n\n'
})
md = md.replace(/<[^>]+>/g, '')
md = md.replace(/\n{3,}/g, '\n\n')
return md.trim()
}复杂的嵌套表格和合并单元格场景,可以用 turndown 库或在 mammoth 的 style map 里定制。
5. 处理图片
DOCX 里的图片存在 word/media/ 目录下。mammoth 默认把图片转成 <img> 但 src 为空,需要实现 convertImage 回调:
const result = await mammoth.convertToHtml(
{ arrayBuffer },
{
convertImage: mammoth.images.imgElement((image) => {
return image.read('base64').then((base64: string) => {
// 实际项目中把图片上传到 R2,返回真实 URL
return { src: `data:${image.contentType};base64,${base64}` }
})
}),
}
)图片 base64 编码后体积增大约 33%。Workers 内存上限 128MB(付费版),大多数文档没问题,但带大量高清图片的文档要注意。
如果用 jszip 手动解析,提取图片更直接:
import JSZip from 'jszip'
async function extractImages(buffer: ArrayBuffer) {
const zip = await JSZip.loadAsync(buffer)
const images: Array<{ filename: string; data: Uint8Array }> = []
for (const [path, file] of Object.entries(zip.files)) {
if (path.startsWith('word/media/') && !file.dir) {
images.push({
filename: path.replace('word/media/', ''),
data: await file.async('uint8array'),
})
}
}
return images
}6. DOCX 与 PDF 解析的差异
| 维度 | DOCX | |
|---|---|---|
| 文件结构 | ZIP + XML,结构化 | 二进制流,页面描述 |
| 文本提取 | 准确 | 可能有编码和字体问题 |
| 结构保留 | 标题、列表、表格都能保留 | 依赖生成方式,可能丢失 |
| Workers 库 | mammoth | pdfjs-dist(体积大) |
| 适用场景 | 合同、报告、方案 | 发票、证书、扫描件 |
选择建议:文件来源可控时优先 DOCX,结构保留更好。两种格式都要支持时,先判断类型,走不同的解析管道,最后汇聚成统一结构:
async function parseDocument(file: File) {
const buffer = await file.arrayBuffer()
if (file.name.endsWith('.docx')) {
const result = await mammoth.convertToHtml({ arrayBuffer: buffer })
return { type: 'docx', html: result.value }
}
if (file.name.endsWith('.pdf')) {
return { type: 'pdf', html: await parsePdf(buffer) }
}
throw new Error('不支持的文件格式')
}7. AI 场景:Word 转 LLM 输入
LLM 不需要 HTML 标签,需要干净的、有结构的文本。按章节拆分:
interface ParsedDocument {
title: string
sections: Section[]
metadata: { wordCount: number; hasImages: boolean; hasTables: boolean }
}
interface Section {
level: number
heading: string
content: string
}
async function docxToLlmInput(arrayBuffer: ArrayBuffer): Promise<ParsedDocument> {
const [textResult, htmlResult] = await Promise.all([
mammoth.extractRawText({ arrayBuffer }),
mammoth.convertToHtml({ arrayBuffer }),
])
const sections = splitByHeadings(htmlResult.value)
const wordCount = textResult.value.split(/\s+/).filter(Boolean).length
return {
title: sections[0]?.heading ?? '无标题文档',
sections,
metadata: {
wordCount,
hasImages: htmlResult.value.includes('<img'),
hasTables: htmlResult.value.includes('<table'),
},
}
}
function splitByHeadings(html: string): Section[] {
const headingRegex = /<h([1-6])>(.*?)<\/h\1>/g
const headings: Array<{ level: number; text: string; index: number; endIndex: number }> = []
let match: RegExpExecArray | null
while ((match = headingRegex.exec(html)) !== null) {
headings.push({
level: parseInt(match[1]),
text: match[2],
index: match.index,
endIndex: match.index + match[0].length,
})
}
if (headings.length === 0) {
return [{ level: 0, heading: '', content: html.replace(/<[^>]+>/g, '').trim() }]
}
return headings.map((h, i) => {
const end = i + 1 < headings.length ? headings[i + 1].index : html.length
return {
level: h.level,
heading: h.text.replace(/<[^>]+>/g, ''),
content: html.slice(h.endIndex, end).replace(/<[^>]+>/g, '').trim(),
}
})
}文档超长时需要截断适配 LLM 上下文窗口:
function prepareForLlm(doc: ParsedDocument, maxChars: number = 100000): string {
if (doc.metadata.wordCount * 1.5 <= maxChars) {
return [
`# ${doc.title}`,
...doc.sections.map((s) => `${'#'.repeat(s.level)} ${s.heading}\n\n${s.content}`),
].join('\n')
}
const parts: string[] = [`# ${doc.title}`, '']
let current = 0
for (const section of doc.sections) {
const text = `${'#'.repeat(section.level)} ${section.heading}\n\n${section.content}`
if (current + text.length > maxChars * 0.9) {
parts.push('\n\n[文档超出限制,已截断]')
break
}
parts.push(text)
current += text.length
}
return parts.join('\n')
}8. 完整示例:从上传到返回
import { Hono } from 'hono'
import mammoth from 'mammoth'
const app = new Hono()
app.post('/api/documents/parse', async (c) => {
let formData: FormData
try {
formData = await c.req.formData()
} catch {
return c.json({ error: '请使用 multipart/form-data' }, 400)
}
const file = formData.get('file')
const format = (formData.get('format') ?? 'text') as string
if (!file || !(file instanceof File)) {
return c.json({ error: '缺少 file 字段' }, 400)
}
if (!file.name.endsWith('.docx')) {
return c.json({ error: '仅支持 .docx 格式' }, 400)
}
if (file.size > 50 * 1024 * 1024) {
return c.json({ error: '文件不能超过 50MB' }, 400)
}
const arrayBuffer = await file.arrayBuffer()
try {
const [textResult, htmlResult] = await Promise.all([
mammoth.extractRawText({ arrayBuffer }),
mammoth.convertToHtml({ arrayBuffer }),
])
const base = {
fileName: file.name,
wordCount: textResult.value.split(/\s+/).filter(Boolean).length,
warnings: htmlResult.messages.map((m) => m.message),
}
switch (format) {
case 'html':
return c.json({ ...base, html: htmlResult.value })
case 'markdown':
return c.json({ ...base, markdown: htmlToMarkdown(htmlResult.value) })
default:
return c.json({ ...base, text: textResult.value })
}
} catch (error) {
const message = error instanceof Error ? error.message : '解析失败'
return c.json({ error: `文档解析失败:${message}` }, 500)
}
})
export default app调用:
# 提取纯文本
curl -X POST https://your-worker.workers.dev/api/documents/parse \
-F "file=@合同.docx"
# 转为 Markdown
curl -X POST https://your-worker.workers.dev/api/documents/parse \
-F "file=@合同.docx" -F "format=markdown"9. 常见问题
旧版 .doc 能解析吗? 不能。mammoth 只支持 .docx。.doc 是二进制格式,Workers 下没有成熟的纯 JS 方案。建议提示用户转存为 .docx,或调用 CloudConvert 等外部 API。
mammoth 的 warnings 要处理吗? 建议至少记日志。warnings 包含无法处理的特性(VBA 宏、OLE 对象、艺术字),能帮你判断解析结果可能缺少什么。
大文件超时怎么办? Workers 付费版 CPU 上限 30s,mammoth 解析 10MB 的 DOCX 约 1-3 秒。文件特别大时用 R2 做中转:先上传 R2,用 Queue 异步处理,结果写回 R2 后通知客户端。
合并单元格? mammoth 会把合并单元格展开成多个独立单元格。精确还原需要用 jszip 手动解析 <w:vMerge> 和 <w:gridSpan> 标签。
加密文档? 加密的 .docx 里有 EncryptionInfo 文件,ZIP 内容也加密了。只能提示用户去密码后重新上传。
总结
回顾这篇的要点:
- DOCX 文件是 ZIP + XML,
word/document.xml存正文,word/media/存图片 - Workers 下用 mammoth 解析,纯 JS 不依赖原生模块
extractRawText提取纯文本,convertToHtml保留结构信息- style map 自定义样式映射,
convertImage处理图片 - DOCX 和 PDF 适用场景不同,能选 DOCX 就选 DOCX
- AI 场景下按标题拆章节,截断适配 LLM 上下文窗口