执子念

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

纯前端 WPS 预览

最初的设计很自然:.wps 交给文字解析器,.et 交给表格解析器,.dps 交给演示解析器。

这套设计在准备好的样例上没有问题。真正的问题,是它把文件扩展名当成了内部结构的证明。

第一批真实文件进来后,同样叫 .wps,有的使用 CFB 复合二进制容器,有的实际是 ZIP/XML 包,还有 RTF、MHTML、HTML 和纯文本。解析器并没有少实现同一个格式的某个字段,而是从入口就走错了路。

这篇记录我们怎样把“按后缀分发”改成“按证据路由”,以及这次调整为什么同时改善了兼容性、诊断和安全边界。

浏览器中的 WPS 文字预览

原来的模型为什么会逐渐失控

入口代码很短:

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 中,WordDocumentWorkbookPowerPoint Document 等 stream 名称可以把文件分别导向文字、表格和演示解析器。

在 ZIP 中,word/document.xmlxl/workbook.xmlppt/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"
  ]
}

解析器不再互相猜输入,上层也终于能解释“为什么走这条路线”。

ET 文件从 ZIP 容器进入表格渲染链路

统一的是输出,不是格式细节

不同格式的解析逻辑不适合塞进一个万能函数,但工作区体验可以共享。

我们让解析器分别输出文字、表格、演示的共享模型,上层统一处理文件打开、缩放、分页、导航和诊断。这样,旧版二进制文档与 OOXML 可以共用工作区,却不必共用一套脆弱的正文解析逻辑。

遇到 WPS 私有记录或损坏目录时,解析结果可以明确进入 limited 状态,保留容器、stream、part 和加密标记。它不是“尽力画一页乱码”,而是说明当前识别到了哪里、缺少什么证据。

回归不能只看第一页

路由正确,只说明文件进了正确的门。结构和版式还要单独验证。

一份原生 .wps 基准在浏览器中得到 1455 个正文块、488 个可显示媒体和 3 组组合图形,最终排成 104 页;独立转换基线是 100 页。

这四页偏差来自字体替代、浮动对象和长文档累计重排。它提醒我们:第一屏文字正常并不等于版式可靠。

目前的门禁分为:

  1. 路由:容器和文档家族是否判断正确;

  2. 结构:正文、媒体、组合图形和页面设置是否完整读取;

  3. 版式:纸张、页边距、分页和关键锚点是否在允许区间;

  4. 安全:宏、ActiveX、DDE、外部连接和嵌入对象是否只识别、不执行。

目前的边界

直接入口覆盖 .wps/.wpt/.et/.ett/.dps/.dpt 六个 WPS 核心后缀。它们的路由、解析和渲染门禁已经通过,但 .wpt/.ett/.dps/.dpt 仍缺独立的原生公开样本证据,.wpt 还缺版式级证据。

纯前端也不是所有场景的答案。超大文件、严格打印一致、复杂编辑和宏执行仍然更适合服务端或桌面软件。浏览器路线解决的是另一类需求:文件不离开当前页面,用户可以安全地先看内容,同时清楚知道还原边界。

在线 Demo:https://wps.file-viewer.app/

回头看,这次架构调整最有价值的不是“多识别了几种格式”,而是把每一次解析路线从猜测变成了证据。文件后缀仍然有用,只是不再拥有最后一句话。

posted on 2026-08-13 10:54  执子念  阅读(16)  评论(0)    收藏  举报

导航