第一次把 .wps 文件拖进浏览器时,一切正常。
第二份文件却只显示乱码。第三份仍然叫 .wps,用解压工具打开后,里面竟然是一组 XML。再往后,还遇到了 RTF、MHTML、纯文本,以及和旧版 DOC 使用同类容器的文件。
问题不是某个解析器少实现了一个字段,而是入口假设错了:扩展名只能描述用户希望它是什么,不能证明文件内部究竟是什么。
把后缀当路由,会错在哪里
最直观的实现通常长这样:
function selectParser(file) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext === 'wps') return parseWps(file)
if (ext === 'et') return parseEt(file)
if (ext === 'dps') return parseDps(file)
}
它在样例固定的 Demo 里没问题,在真实语料里却有三个缺陷:
- 同一扩展名可能对应不同历史格式和兼容容器;
- 文件被误改后缀时,错误会被推迟到解析深处,最后只剩一个含糊的“文件损坏”;
- 解析器会为了兼容错误输入不断增加猜测逻辑,彼此边界越来越模糊。
WPS 文字文件可能是 CFB/OLE 二进制容器、ZIP/XML 包、WPS2001、RTF、HTML、MHTML 或纯文本。.et 和 .dps 同样可能沿用 Excel、PowerPoint、OOXML 或 UOF 的内部结构。
所以入口不应该先问“后缀是什么”,而应该先问“前几个字节和内部部件说明了什么”。
第一层:只识别容器,不急着解析正文
我们把入口拆成了一个很小的签名识别器。它只读取有限字节,不碰正文结构:
function sniffContainer(bytes) {
if (startsWith(bytes, [0xd0, 0xcf, 0x11, 0xe0])) return 'cfb'
if (startsWith(bytes, [0x50, 0x4b, 0x03, 0x04])) return 'zip'
const head = decodeAscii(bytes.subarray(0, 512))
if (/^\{\\rtf/i.test(head)) 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 looksLikeText(bytes) ? 'text' : 'unknown'
}
这一步不决定最终交给 Writer、Spreadsheet 还是 Presentation,只把搜索范围缩小。
后缀仍然有用,但角色变了:当文件头无法给出唯一答案时,它提供弱提示;当后缀与容器冲突时,系统记录冲突,而不是无条件相信文件名。
第二层:打开容器,找能改变路由的证据
识别出 CFB 或 ZIP 之后,还不能马上开始渲染。
CFB 是一种复合二进制文件,可以理解成“一个文件里的小型文件系统”。里面出现 WordDocument、Workbook 或 PowerPoint Document,会把同一个外壳分别导向文字、表格或演示链路。
ZIP 包也一样。它可能包含 WordprocessingML、SpreadsheetML、PresentationML,也可能是 UOF。真正可靠的判断来自包内的内容类型、关系文件和核心部件。
async function routeDocument(file) {
const bytes = new Uint8Array(await file.arrayBuffer())
const container = sniffContainer(bytes)
if (container === 'cfb') {
const streams = await listCfbStreams(bytes)
if (streams.has('Workbook')) return 'spreadsheet-biff'
if (streams.has('PowerPoint Document')) return 'presentation-ppt'
if (streams.has('WordDocument')) return 'writer-doc'
}
if (container === 'zip') {
const parts = await inspectPackageParts(bytes)
if (parts.has('xl/workbook.xml')) return 'spreadsheet-ooxml'
if (parts.has('ppt/presentation.xml')) return 'presentation-ooxml'
if (parts.has('word/document.xml')) return 'writer-ooxml'
}
return container
}
真实实现还会处理大小写、损坏目录、加密标记、UOF 命名空间和路径变体,但核心原则没有变化:只有能从文件本身读到的证据,才有资格改变解析路线。
第三层:让解析器输出共享模型
如果每条路线直接操作 DOM,七种入口很快会长成七套 UI。缩放、分页、导航、错误提示和资源生命周期都会重复。
我们让各解析器先输出共享模型,再由工作区渲染:
type PreviewDocument =
| { kind: 'writer'; sections: WriterSection[]; diagnostics: Diagnostic[] }
| { kind: 'sheet'; sheets: SheetModel[]; diagnostics: Diagnostic[] }
| { kind: 'slides'; slides: SlideModel[]; diagnostics: Diagnostic[] }
| { kind: 'limited'; container: string; diagnostics: Diagnostic[] }
limited 不是失败的委婉说法。它明确告诉上层:容器已识别,但当前没有足够证据完整解码。比起把陌生记录硬塞进某个解析器,诚实降级更容易排查,也更安全。
“能打开”为什么仍然不等于“完成”
在一份原生 .wps 基准中,浏览器解析出了 1455 个正文块、488 个可显示媒体和 3 组组合图形,最后排成 104 页;独立转换基线是 100 页。
内容已经可读,但四页偏差说明字体替代、浮动对象和长文档重排仍在累积。此时最危险的做法,是只截第一页并宣布还原完成。
我们的回归因此分成三层:
- 路由门禁:文件是否进入正确解析器;
- 结构门禁:正文块、媒体、组合图形和页面设置是否被读取;
- 版式门禁:分页、纸张、边距和关键锚点是否落在可接受范围。
当前六个 WPS 核心后缀的路由、解析和渲染门禁已经通过,但 .wpt/.ett/.dps/.dpt 仍缺独立的原生公开样本证据,.wpt 还缺版式级证据。这个缺口需要继续补语料,不能用一张“支持列表”抹平。
纯前端还要多画一条安全线
文件留在浏览器,不代表可以执行文件里的任何东西。
预览链路会识别宏、ActiveX、DDE、外部工作簿、远程图片和嵌入对象,但不会执行宏,不会激活 OLE,不会自动请求外部数据源。预览器的职责是读取内容,不是成为文档的运行环境。
这也是容器识别必须早于正文解析的原因之一。越早知道文件里有什么,越早能阻断不该进入渲染链路的内容。
最后
WPS 预览真正难的地方,不是给 .wps/.et/.dps 各写一个入口,而是承认文件名并不可靠,然后为每一次路由保存可验证的证据。
在线版本放在这里:wps.file-viewer.app/。文件在本地浏览器内完成识别和预览。
下一步我们会继续补原生模板和复杂演示样本。对这种兼容跨度很长的格式,真实文件比“又支持一个后缀”的数字更能推动实现向前。