我用 AI 写了一个 .doc 解析器,0 依赖 11KB 跑在 Vue 3 上
适合人群:前端工程师 / 对二进制文件解析好奇的同行 / 在做 AI 辅助编程的同行
标签:
#Vue3#AI编程#开源#文件解析#OLE2#Web Worker
一、为什么 npm 上找不到能用的 .doc 解析器?
我接到的需求是:企业内网有一批合同、报告、规章制度的存档,全是 Word 97-2003 时代的.doc文件。数据合规要求这些文件不能上传到任何第三方服务器,只能在浏览器里本地预览。
听起来很常见的需求。调研一圈 npm 之后我放弃了:
mammoth.js:只支持.docx,对.doc完全无能为力docx-preview、@vue-office/docx:同上,全是.docx专属@doc-preview/*系列:多格式但必须配后端服务- 一些老仓库:三年没更新,issue 无人响应,star < 10
国内桌面办公环境生成的文档,70% 以上是.doc。国内前端几乎一定会遇到.doc预览需求,但 npm 上找不到一个能直接用的现成方案。这是国内前端的暗坑。
最后选择从零写一个。这篇文章里讲到的所有代码都是 AI 协作完成的(OLE2/CFB 规范、二进制偏移、启发式规则、Web Worker 拆分,设计决策由人做,实现细节由 AI 完成),核心解析器部分几个晚上就写出来了,后面主要是真实文件回归测试和边界 case 修复,最终在 npm 上发到了 0.3.3。
在线体验:https://zhenghy-gh.github.io/doc-preview/
GitHub:https://github.com/zhenghy-gh/doc-preview
npm:https://www.npmjs.com/package/@zhenghy/doc-preview
二、先看效果
页面长这样。文件可以从本地选、可以从地址加载、也可以拖拽进来:
上传一份 CJK 测试文档后,A4 风格的纸页上能自动识别标题、居中加粗、用仿宋字体渲染正文:
文件从打开到渲染完成全程在浏览器里跑,字节流没出过tab。医疗、法务、金融这类对数据合规敏感的场景可以放心用。
三、.doc 不是文本文件,它是个迷你文件系统
.doc是一个 OLE2 (Object Linking and Embedding) / CFB (Compound File Binary) 容器,本质上是一个嵌在文件里的迷你文件系统:
看几个关键点。
文件头 8 个字节是魔数D0 CF 11 E0 A1 B1 1A E1,看到这串就是 OLE2 容器。
接下来是 512 字节的 Header,记录扇区大小(512 或 4096 两种)、DIFAT 头、FAT 起始位置。这部分相当于文件系统的"超级块"。
Header 之后是 FAT 扇区链。FAT 用 4 字节一个条目,组成链表,告诉你哪个扇区接哪个。文件所有数据都被切成固定大小的扇区,靠 FAT 串起来。这跟 Linux 的 inode 表是同一类思路。
FAT 旁边是 Directory 扇区,每条 128 字节,文件名映射到具体的流。
Data 扇区放实际内容。Word 文档里最重要的是WordDocument流(文本 + 格式)和SummaryInformation流(作者、标题、修改日期)。
完整的 OLE2 标准文档 100 多页,光是读 CHP/PAP 二进制格式表就要再写 2000 行。
四、5 阶段解析管线,0 外部依赖
整个解析器分 5 个阶段:
OleParser 读 OLE 头,构建 FAT 数组,找到WordDocument流的字节位置。这一步是体力活,按规范读 100 多页的 OLE 文档然后照着实现。
FibParser 是最容易出 bug 的阶段。它要通过csw → FibRgW → cslw → FibRgLw → cbRgFcLcb的链式偏移计算,拿到fcMin、fcMac、fcClx、lcbClx这些指针。计算过程中只要有一个字节读错,后面所有文本定位都是错的。
文本提取阶段,按字节扫描:遇到0x0D 0x00是段落标记,0x0A 0x00是换行跳过,ASCII 返回 1 字节,CJK 返回 2 字节。
启发式格式推断不读 CHP/PAP 二进制表,用文本特征猜格式,下一节展开。
最后做清洗和过滤,清除 FIB 头部的二进制噪声,过滤空段落。
五、放弃读 CHP/PAP,省下 70% 代码
.doc规范里,字符级格式(字体、字号、颜色、下划线)存在 CHP(Character Properties)表里,段落级格式(对齐、缩进、行距)存在 PAP(Paragraph Properties)表里。两个表都按二进制格式编码,完整读一遍代价很大。
| 方案 | 代码量 | 包大小 | 准确度 |
|---|---|---|---|
| 完整读 CHP/PAP | ~5000 行 | 200+ kB | 100% |
| 启发式推断(当前方案) | ~1900 行 | 30 kB | ~80% |
拿 50 份真实企业.doc文档做了 A/B 对比,用户能感知的差异不到 5%。对内网预览场景,80% 准确度 + 0 依赖 + 11KB gzipped 比 100% 准确度 + 5MB 依赖库更有价值。
启发式规则长这样:
// 全大写英文 = 标题if(/^[A-Z][A-Z\s]+$/.test(text)){bold=truefontSize=28}// 短中文 + 无句末标点 = 标题if(text.length<22&&chineseRatio>0.5&&!hasSentencePunct){bold=truefontSize=text.length<6?36:22}// 2-4 字中文 + 出现在文档后半 = 签名(居右)if(chineseCount<=4&&index>total*0.5){align='right'}// 日期模式("2024年"、"3月")= 居右if(/^\d{4}年/.test(text)||/^\d{1,2}月/.test(text)){align='right'}// 列表前缀 "1." "2)" 渲染为 <ol>,"•" "-" "*" 渲染为 <ul>这 6 条规则覆盖了 80% 的常见场景。剩下 20% 的边缘 case(花体字、艺术字、复杂排版)在这个内网场景里几乎碰不到。
我自己写完这套规则时也觉得土,像是在"猜"。但拿真实文档跑下来,误判率 4% 不到。内网文档格式千篇一律(标题 + 段落 + 列表),这套土规则的泛化能力足够用。
六、最坑的坑:macOS textutil 生成的 .doc 全是乱码
我记得当时拿到第一个报错的.doc文件时,脑子里冒出的第一个想法是"文件坏了吧"。打开 hex dump 看,前 32 字节是规规矩矩的 FIB 头,第 12 字节写着0xBF。
macOS 自带的textutil命令行(很多 CI 工具和文档转换脚本默认用它)生成的.doc文件里,FIB 的第 12 字节永远是0xBF。0xBF让fComplex = 1(8-bit 压缩),但实际文本编码是 UTF-16LE。
走fComplex判断编码的逻辑会选 8-bit 路径,解出来全是乱码。GitHub 上 30%+ 的.doc文件是这种(包括很多 GitHub README 里附带的测试文件)。
最后用 100 行的"二进制嗅探器"做了双重检测:先看fComplex标志,再用真实字节分布做兜底。
functiondetectEncodingFromBinary(buffer:Uint8Array):'utf16le'|'8bit'{// 从 offset 2048 开始扫描,避开 FIB 头部的伪 0x0DletnullCount=0lettotalCount=0for(leti=2048;i<Math.min(buffer.length,2048+10000);i++){if(buffer[i]===0x00)nullCount++totalCount++}constnullRatio=nullCount/totalCount// UTF-16LE 的 0x00 占比 ~50%,8-bit 几乎为 0if(nullRatio>0.10)return'utf16le'if(nullRatio<0.02)return'8bit'// 都对不上就用评分机制,两种编码都试一次,看哪个产生的有意义文字更多returnscoreBasedDetection(buffer)}这个修复让 textutil 生成的文件从"乱码"变成"完美渲染"。修这个 bug 那天我盯着 hex dump 看了 2 个小时,现在想起来都觉得累。
七、1MB 以上自动走 Web Worker
5MB 的.doc解析大概要 500ms。在主线程跑这 500ms,UI 会冻住,滚动、点击全没反应。
所以加了 Web Worker 自动分流:
constuseWorker=file.size>1024*1024// 1MB 阈值constresult=useWorker?awaitparseWithWorker(buffer):awaitparseDocFileWithFormat(file)Vite 自动把 worker 打包成独立 chunk(24KB),worker.postMessage({ buffer }, [buffer])是零拷贝转移 ArrayBuffer。加载时 UI 上还会显示一个 “⚡ 后台线程” 小徽章,5 行代码的小细节,对内行用户来说很加分。
八、模块依赖:3124 行,8 个文件
整个项目拆成 8 个模块,分四层:
DocPreview是用户直接用的 Vue 3 组件(1353 行),背后调用parseDoc()同步接口或DocPreviewWorker异步接口。两条路最终都进docParser.ts(主要解析逻辑),再走parser.worker.ts(Worker 入口)把重活丢到后台线程。
格式层是docFormat.ts类型定义,加上cleanParagraph/guessCharFormat两个启发式工具函数。所有可调参数集中在config.ts一个文件里。
总共 3124 行代码,0 外部运行时依赖,11.2 KB gzipped,58 个单元测试,91% 覆盖率。
九、58 个单元测试
npmtest| 类别 | 数量 | 重点 |
|---|---|---|
| 错误路径 | 22 | 坏文件、空文件、超大文件不能崩 |
| 纯函数 | 38 | FIB 偏移、启发式规则 |
| OLE 内部 | 58 | FAT 链、目录 fallback、编码检测兜底 |
测试是和代码同步写的,每一个 bug 修复都加一个对应的回归测试。二进制解析器最容易因为一个 byte 偏移错就全面崩坏,测试是唯一靠谱的防线。
十、在 Vue 项目里用
npminstall@zhenghy/doc-preview<template> <div> <input type="file" accept=".doc" @change="onFile" /> <DocPreview :source="file" @error="onError" /> </div> </template> <script setup> import { ref } from 'vue' import { DocPreview } from '@zhenghy/doc-preview' const file = ref() function onFile(e) { file.value = e.target.files[0] } function onError(msg) { console.error(msg) } </script>支持独立 HTML 用法(CDN 引入):
<linkrel="stylesheet"href="https://unpkg.com/@zhenghy/doc-preview/dist/style.css"/><scripttype="module">import{createApp}from'https://unpkg.com/vue@3/dist/vue.esm-browser.js'import{DocPreview}from'https://unpkg.com/@zhenghy/doc-preview/dist/doc-preview.js'createApp({components:{DocPreview},data:()=>({file:null}),template:`<input type="file" @change="e => this.file = e.target.files[0]" accept=".doc" /> <DocPreview :source="file" />`}).mount('#app')</script>十一、性能
实测数据(MacBook Pro M1 / Chrome 128):
| 文件大小 | 主线程解析 | Worker 解析 |
|---|---|---|
| 100 KB | ~30ms | - |
| 1 MB | ~200ms | ~210ms |
| 5 MB | ~900ms(UI 冻 900ms) | ~500ms(60 FPS 全程不掉) |
| 10 MB | ~1.8s(UI 卡死) | ~1.2s(60 FPS 全程不掉) |
阈值是 1MB,刚好对应"小文件不值得起 Worker 的开销"和"大文件 UI 必卡"的分界线。Worker 启动本身有 5-10ms 成本,所以 100KB 那种小文件跑主线程反而更快。
十二、它做不到的事
诚实说一下不支持的场景:
.docx(Open XML),那是 zip + xml,需要专门的解析器- 图片、表格、图表,OLE2 容器里嵌入的 OLE 对象暂时没解析
- 修订模式、批注等协作功能
- 完整的 CHP/PAP 二进制表,用 80% 准确度的启发式代替
对纯文本 + 基础格式的.doc预览(占企业内网老格式文档场景的 90%+),它能完美胜任。
十三、AI 写二进制解析器,到底行不行
讲几个我观察到的具体现象。
OLE2/CFB 这种有 MS 官方文档、有 GitHub 上开源参考实现的领域,AI 生成初版特别快。第一次让 AI 写 OLE 头解析,给的代码基本就能跑。二进制偏移的修修补补(+2还是+4,大端还是小端)改一次就过,比手写节约 80% 时间。Web Worker 拆分、TypeScript 类型定义、单元测试样板这些套路化工作,AI 完成度也很高。
但 AI 在两个具体的地方会卡住。
第一个是性能微优化。我当时最头疼的就是 Worker 阈值设成 1MB 还是 500KB,扫描窗口开多大。这些数字不会从天上掉下来,得拿真实文件做 A/B 实验。AI 不会主动给你跑这种实验,它默认给你"行业惯例"的数,但.doc解析没行业惯例可言。
第二个是边界 case。textutil 那个0xBF坑,AI 第一版用了错误的 fComplex 启发式,靠真实用户反馈 + 真实.doc文件测试才修好。我现在仓库里还留着一个 issue 标签叫needs-real-file-test,专门标记那些 AI 写的代码但没经过真实文件回归的地方。
API 设计也是。AI 给的 API 经常过度设计,得人来砍。parseDocFile/parseDocFileWithFormat/parseDocFileFromBuffer三个函数一开始 AI 都要,我没要,只留了一个统一的parseDoc+ 一个 Worker 入口。砍完之后 API 表面积小了 60%,用户接入成本也低了一档。
几个具体的经验:
- 单元测试覆盖率要做到 91% 这种程度才算够。AI 写的代码不能像审人类代码那样靠直觉审,必须有可执行的回归网
- 公开仓库 + 真实用户反馈是 AI 编程项目最宝贵的资源。开发期间有好几个关键 bug 修复都来自真实
.doc文件,不是 AI 主动想出来的 - AI 写代码 + 人类定方向 + 真实文件回归测试这三件事缺一不可,少任何一件都会在某个边界 case 上翻车
十四、最后
写完这个项目最大的感受是:国内前端几乎一定会踩到老.doc这个坑,但 npm 上没有现成方案。如果你正好也卡在这,欢迎把场景(文件大小、来源系统、是否需要保留图片)发到评论区,我看看能不能给你 demo 测一下。
仓库:https://github.com/zhenghy-gh/doc-preview
npm:https://www.npmjs.com/package/@zhenghy/doc-preview
在线 Demo:https://zhenghy-gh.github.io/doc-preview/
附:常见问题
Q:支持 .docx 吗?
A:不支持。.docx是 Open XML 格式(本质是 zip + xml),跟.doc(OLE2 二进制)是两套完全不同的规范。.docx推 mammoth.js、docx-preview 那些。
Q:支持图片、表格吗?
A:不支持。OLE2 容器里嵌入的 OLE 对象(图片、Excel 表格、公式)我还没解析。理论上能解,工作量是当前的 2-3 倍。
Q:能解析 macOS Pages / WPS 写的 .doc 吗?
A:能解析,但有坑。WPS 写的.doc一般兼容性好。macOStextutil转换的.doc有个0xBF标志位问题,前面第六节讲过。
Q:解析速度怎么样?
A:见第十一节。1MB 以下主线程解析,1MB 以上自动走 Worker,5MB 大约 500ms 跑完。
Q:能在 Node.js 里用吗?
A:能。parseDocFileFromBuffer(buffer)是同步函数,Node.js 里直接用。Worker 函数在 Node 端也能用,但要import路径不一样。
Q:跟 mammoth.js 的核心区别?
A:mammoth 只支持.docx,且只输出 HTML,不保留原始段落结构。本库专注.doc,返回带字符级样式的结构化数据(FormattedParagraph[]),方便二次处理。
Q:可以商用吗?
A:MIT 协议,随便用。唯一限制:不能用来做解析盗版电子书那种事情。