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>

关键标签:

  • &lt;w:p&gt; — 段落
  • &lt;w:pStyle w:val="Heading1"/&gt; — 一级标题标记
  • &lt;w:r&gt; — 文本运行(run),一段里可以有多个 run
  • &lt;w:t&gt; — 实际文本内容
  • &lt;w:tbl&gt; / &lt;w:tr&gt; / &lt;w:tc&gt; — 表格 / 行 / 单元格

理解了这层结构,用库解析时就知道数据从哪来,遇到特殊情况也能自己写 XML 解析。

2. Workers 环境下的库选择

Workers 运行时不是 Node.js,没有 fspath,不支持 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 app

extractRawText 把所有文本拼接起来,段落之间用换行分隔,格式信息全部丢掉。适合只关心内容不关心排版的场景,比如提取文本丢给 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 默认把图片转成 &lt;img&gt;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 解析的差异

维度DOCXPDF
文件结构ZIP + XML,结构化二进制流,页面描述
文本提取准确可能有编码和字体问题
结构保留标题、列表、表格都能保留依赖生成方式,可能丢失
Workers 库mammothpdfjs-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 手动解析 &lt;w:vMerge&gt;&lt;w:gridSpan&gt; 标签。

加密文档? 加密的 .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 上下文窗口