浏览器预览 WPS 文件:从相信后缀到验证容器

最初的设计很自然:.wps 交给文字解析器,.et 交给表格解析器,.dps 交给演示解析器。
这套设计在准备好的样例上没有问题。真正的问题,是它把文件扩展名当成了内部结构的证明。
第一批真实文件进来后,同样叫 .wps,有的使用 CFB 复合二进制容器,有的实际是 ZIP/XML 包,还有 RTF、MHTML、HTML 和纯文本。解析器并没有少实现同一个格式的某个字段,而是从入口就走错了路。
这篇记录我们怎样把“按后缀分发”改成“按证据路由”,以及这次调整为什么同时改善了兼容性、诊断和安全边界。

原来的模型为什么会逐渐失控
入口代码很短:
switch (extension) {
case 'wps': return parseWriter(file)
case 'et': return parseSpreadsheet(file)
case 'dps': return parsePresentation(file)
}
当一份 .wps 实际是 ZIP/XML 时,最直接的补丁是在 parseWriter() 里先试 ZIP;遇到 CFB,再试旧版 DOC;遇到 RTF,又多一个分支。
很快,parseWriter() 不再只是文字解析器,而是同时承担格式侦测、容器打开、失败猜测和正文解析。表格、演示也会出现同样的问题。后续任何一次兼容调整,都可能影响本不相关的路线。
我们最后承认:这里缺的不是更多 if,而是一层独立的识别模型。
第一步只看外壳
新的入口只读取有限的文件头:
type ContainerKind =
| 'cfb'
| 'zip'
| 'wps2001'
| 'rtf'
| 'mhtml'
| 'html'
| 'text'
| 'unknown'
function sniff(bytes: Uint8Array): ContainerKind {
if (match(bytes, [0xd0, 0xcf, 0x11, 0xe0])) return 'cfb'
if (match(bytes, [0x50, 0x4b, 0x03, 0x04])) return 'zip'
const head = new TextDecoder('latin1').decode(bytes.subarray(0, 1024))
if (/^\s*\{\\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'
}
这一步故意不解析正文,也不决定最终文档类型。它只回答:当前文件最外层是什么。
文件名仍然参与判断,但权重降低。后缀和文件头冲突时,冲突会进入诊断信息,而不是让后缀覆盖字节证据。
第二步打开目录,寻找能改变路线的部件
CFB 和 ZIP 都只是容器。
在 CFB 中,WordDocument、Workbook、PowerPoint Document 等 stream 名称可以把文件分别导向文字、表格和演示解析器。
在 ZIP 中,word/document.xml、xl/workbook.xml、ppt/presentation.xml,以及 [Content_Types].xml 和关系文件,承担同样的角色。
interface RouteEvidence {
container: ContainerKind
family: 'writer' | 'spreadsheet' | 'presentation' | 'unknown'
format?: string
evidence: string[]
warnings: string[]
}
一个 .wps 文件最终可能得到这样的结果:
{
"container": "zip",
"family": "writer",
"format": "wordprocessingml",
"evidence": [
"magic:504b0304",
"part:word/document.xml"
],
"warnings": []
}
另一个被错误改名为 .wps 的工作簿则可能是:
{
"container": "cfb",
"family": "spreadsheet",
"format": "biff",
"evidence": [
"magic:d0cf11e0",
"stream:Workbook"
],
"warnings": [
"extension .wps conflicts with spreadsheet content"
]
}
解析器不再互相猜输入,上层也终于能解释“为什么走这条路线”。

统一的是输出,不是格式细节
不同格式的解析逻辑不适合塞进一个万能函数,但工作区体验可以共享。
我们让解析器分别输出文字、表格、演示的共享模型,上层统一处理文件打开、缩放、分页、导航和诊断。这样,旧版二进制文档与 OOXML 可以共用工作区,却不必共用一套脆弱的正文解析逻辑。
遇到 WPS 私有记录或损坏目录时,解析结果可以明确进入 limited 状态,保留容器、stream、part 和加密标记。它不是“尽力画一页乱码”,而是说明当前识别到了哪里、缺少什么证据。
回归不能只看第一页
路由正确,只说明文件进了正确的门。结构和版式还要单独验证。
一份原生 .wps 基准在浏览器中得到 1455 个正文块、488 个可显示媒体和 3 组组合图形,最终排成 104 页;独立转换基线是 100 页。
这四页偏差来自字体替代、浮动对象和长文档累计重排。它提醒我们:第一屏文字正常并不等于版式可靠。
目前的门禁分为:
路由:容器和文档家族是否判断正确;
结构:正文、媒体、组合图形和页面设置是否完整读取;
版式:纸张、页边距、分页和关键锚点是否在允许区间;
安全:宏、ActiveX、DDE、外部连接和嵌入对象是否只识别、不执行。
目前的边界
直接入口覆盖 .wps/.wpt/.et/.ett/.dps/.dpt 六个 WPS 核心后缀。它们的路由、解析和渲染门禁已经通过,但 .wpt/.ett/.dps/.dpt 仍缺独立的原生公开样本证据,.wpt 还缺版式级证据。
纯前端也不是所有场景的答案。超大文件、严格打印一致、复杂编辑和宏执行仍然更适合服务端或桌面软件。浏览器路线解决的是另一类需求:文件不离开当前页面,用户可以安全地先看内容,同时清楚知道还原边界。
在线 Demo:https://wps.file-viewer.app/
回头看,这次架构调整最有价值的不是“多识别了几种格式”,而是把每一次解析路线从猜测变成了证据。文件后缀仍然有用,只是不再拥有最后一句话。
浙公网安备 33010602011771号