JavaScript 纯前端预览 WPS:先识别容器,再路由解析器

在浏览器里做 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'
}

这里要注意两点:

  1. TextDecoder 只用于识别文本型文件头,不要用它解码任意二进制正文;
  2. 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/。选择本地文件后,识别、解析和渲染在当前浏览器内完成。

做这类格式兼容时,最重要的不是把支持列表写长,而是让每一次解析路线都有可以回读的证据。文件名可以说谎,字节和容器结构不会。

相关推荐
计算机魔术师1 小时前
新兴多智能体系统的模式与问题
前端
用户059540174462 小时前
AI Agent 上下文污染踩坑实录:用 pytest + Redis 揪出 3 类记忆串号 bug,排查 6 小时
前端·css
满栀5852 小时前
状态管理:Redux、Vuex、Pinia 核心区别
前端·javascript·typescript
一拳不是超人3 小时前
DeepSeek Harness 为什么敢说"一切皆插件"?拆透 Cordis 引擎的五大核心机制
前端·人工智能·agent
IT_陈寒3 小时前
Python的GIL让我多写了500行代码
前端·人工智能·后端
风骏时光牛马3 小时前
AI智能体能力调度微服务
前端
大龄码农有梦想4 小时前
Vue + BPMN.js + Camunda:搭建企业级流程设计器完整实践
javascript·vue.js·流程设计器·bpmn.js·bpmn·流程审批·工作流设计器
Eloudy5 小时前
ReAct 原理简介
前端·javascript·人工智能·react.js·agent·gpu
ZJU_统一阿萨姆6 小时前
【DSH】如何将 DeepSeek Harness 嵌入生产环境?万字解构 DeepSeek Agent Harness 的运行机制与安全边界
前端·javascript·安全