在浏览器里做 WPS 文件预览,最容易写错的不是渲染器,而是第一行路由代码。
很多实现看到 .wps 就调用文字解析器,看到 .et 就调用表格解析器,看到 .dps 就调用演示解析器。固定样例能通过,一换真实业务文件就开始出现乱码、错误降级或"文件损坏"。
根因是:扩展名不等于容器格式。
本文不讨论组件 API,集中讲一个生产环境更关键的问题:怎样在 JavaScript 中先识别真实容器,再把文件交给正确解析链路。

1. WPS 后缀为什么不能直接决定解析器
.wps 文件可能采用以下内部结构:
- CFB/OLE 复合二进制容器;
- ZIP/XML 包;
- 早期 WPS2001 数据;
- RTF;
- MHTML 或 HTML;
- 纯文本。
.et 可能是 BIFF 工作簿、SpreadsheetML 或 UOF;.dps 也可能沿用二进制 PPT 或 PresentationML。
因此,下面这种写法只能作为兜底,不能作为主路由:
javascript
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext === 'wps') return parseWriter(file)
if (ext === 'et') return parseSpreadsheet(file)
if (ext === 'dps') return parsePresentation(file)
更稳妥的顺序是:
text
扩展名弱提示
-> 文件头签名
-> 容器目录或 ZIP 部件
-> 文档类型
-> 具体解析器
2. 读取有限字节,识别第一层容器
不需要一开始把整个文件转成字符串。先读取前 4KB,已经足够识别多数外层格式:
javascript
async function readHead(file, size = 4096) {
return new Uint8Array(await file.slice(0, size).arrayBuffer())
}
function hasPrefix(bytes, signature) {
return signature.every((value, index) => bytes[index] === value)
}
function decodeAscii(bytes) {
return new TextDecoder('latin1').decode(bytes)
}
function detectOuterContainer(bytes) {
// OLE Compound File Binary
if (hasPrefix(bytes, [0xd0, 0xcf, 0x11, 0xe0])) return 'cfb'
// ZIP local file header
if (hasPrefix(bytes, [0x50, 0x4b, 0x03, 0x04])) return 'zip'
const head = decodeAscii(bytes.subarray(0, 1024)).trimStart()
if (head.startsWith('{\\rtf')) return 'rtf'
if (/MIME-Version:|Content-Type:\s*multipart/i.test(head)) return 'mhtml'
if (/^<!doctype\s+html|^<html[\s>]/i.test(head)) return 'html'
return 'unknown'
}
这里要注意两点:
TextDecoder只用于识别文本型文件头,不要用它解码任意二进制正文;unknown不等于损坏,还需要结合扩展名、编码探测和早期 WPS 签名继续判断。
3. CFB 文件要检查 stream 名称
CFB 可以理解成一个嵌在文件里的小型文件系统。只有打开目录后,才能判断它更像 Word、Excel 还是 PowerPoint。
javascript
async function detectCfbDocument(bytes, cfbReader) {
const cfb = await cfbReader.open(bytes)
const names = new Set(cfb.entries.map(entry => entry.name))
if (names.has('Workbook') || names.has('Book')) {
return { family: 'spreadsheet', format: 'biff' }
}
if (names.has('PowerPoint Document')) {
return { family: 'presentation', format: 'ppt-binary' }
}
if (names.has('WordDocument')) {
return { family: 'writer', format: 'doc-binary' }
}
return { family: 'unknown', format: 'cfb' }
}
生产实现还要处理大小写、目录损坏、加密 stream 和 WPS 私有记录。最重要的原则是:没有匹配到关键 stream 时,不要因为文件名叫 .wps 就强行按 Word 文档解析。
4. ZIP 文件要检查核心部件
ZIP 只是外壳。OOXML 和 UOF 都可能使用 ZIP 打包,必须继续检查包内文件。
javascript
async function detectZipDocument(bytes, zipReader) {
const zip = await zipReader.open(bytes)
const names = new Set(zip.entries.map(entry => entry.name.toLowerCase()))
if (names.has('word/document.xml')) {
return { family: 'writer', format: 'wordprocessingml' }
}
if (names.has('xl/workbook.xml')) {
return { family: 'spreadsheet', format: 'spreadsheetml' }
}
if (names.has('ppt/presentation.xml')) {
return { family: 'presentation', format: 'presentationml' }
}
if ([...names].some(name => name.includes('uof.xml'))) {
return { family: 'uof', format: 'uof-package' }
}
return { family: 'unknown', format: 'zip' }
}
只检查文件名仍然不够严谨。进一步可以读取 [Content_Types].xml、根关系文件和命名空间,避免路径变体或伪造条目造成误判。

5. 建立统一路由结果,不让解析器互相猜测
入口层应返回结构化结果:
typescript
interface RouteDecision {
family: 'writer' | 'spreadsheet' | 'presentation' | 'text' | 'unknown'
container: 'cfb' | 'zip' | 'rtf' | 'mhtml' | 'html' | 'text' | 'unknown'
format?: string
confidence: 'strong' | 'weak'
evidence: string[]
warnings: string[]
}
evidence 用来记录改变路由的依据,例如:
json
{
"family": "spreadsheet",
"container": "cfb",
"format": "biff",
"confidence": "strong",
"evidence": ["magic:d0cf11e0", "stream:Workbook"],
"warnings": ["extension is .wps but content is spreadsheet"]
}
这样做有三个收益:
- 解析器不需要重复猜测输入类型;
- 路由错误可以通过日志快速定位;
- 新增 UOF 或历史格式时,不会改动所有渲染器。
6. 解析成功后,还要验证结构和版式
只验证"没有抛异常"远远不够。
一份原生 .wps 基准在浏览器中解析出 1455 个正文块、488 个可显示媒体和 3 组组合图形,最终分页 104 页;独立转换基线为 100 页。
这说明内容已经可读,但字体替代、浮动对象和长文档重排仍然带来四页偏差。因此回归至少要分三层:
javascript
expect(route.family).toBe('writer')
expect(model.blocks.length).toBeGreaterThan(1400)
expect(model.media.length).toBeGreaterThan(480)
expect(model.pageCount).toBeGreaterThanOrEqual(100)
expect(model.pageCount).toBeLessThanOrEqual(105)
上面的数字只适用于固定基准文件,不应写成通用标准。实际项目应为每个回归样本保存哈希、预期路由、结构计数和允许的版式区间。
7. 安全边界:识别,但不执行
Office 家族文件可能包含宏、ActiveX、DDE、外部工作簿、远程图片和嵌入对象。
预览时应该识别并报告这些结构,但不要执行宏、激活 OLE、运行 DDE,也不要自动请求外部数据源。纯前端的价值不仅是文件不上传,还包括陌生文档只能被读取,不能借预览过程执行额外行为。
8. 当前实现边界
现有浏览器版本直接接收 WPS 文字、表格和演示的六个后缀:.wps/.wpt/.et/.ett/.dps/.dpt。六个后缀的路由、解析和渲染门禁已经通过,但 .wpt/.ett/.dps/.dpt 仍缺独立的原生公开样本证据,.wpt 还缺版式级证据。
这套实现可以在线测试:https://wps.file-viewer.app/。选择本地文件后,识别、解析和渲染在当前浏览器内完成。
做这类格式兼容时,最重要的不是把支持列表写长,而是让每一次解析路线都有可以回读的证据。文件名可以说谎,字节和容器结构不会。