执子念

浏览器端 iWork 预览:从 ZIP、Snappy、IWA 到静态渲染

给文件预览器增加 .pages 时,我把时间主要估在版式上。

这个估算错得很快。第一批样本里,有的以 index.xml 为核心,有的使用 index.apxl,还有一批完全是 .iwa 流。同一个后缀横跨两代结构,入口就已经分叉了。

后来 Numbers 和 Keynote 也进来,我又改了一次模型。它们可以共享容器和对象协议,却不能共享排版思路。这篇把几次改动留下来,给以后再碰文档格式的自己提个醒。

Pages 原生文件的浏览器分页预览

第一次改动:后缀只判断文档家族

iWork 文件以 ZIP 为外壳。iWork '09 包里通常能找到 index.xmlindex.apxl 或 gzip 变体;2013 年后的文件包含多个 .iwa 流。

async function classify(file: File) {
  const zip = await openZip(await file.arrayBuffer())
  const entries = zip.entries()

  if (entries.some(isLegacyIndex)) return 'iwork-09'
  if (entries.some(entry => entry.name.endsWith('.iwa'))) return 'iwa'

  throw new UnsupportedFormatError('Unknown iWork package')
}

.pages 告诉我这是 Pages,包内结构再决定走旧版 XML 还是现代 IWA。分类器读到足够证据就停,不在这里解压图片、解析正文。

我原来的入口做得太热心,探测时顺便展开了资源。简单文件没问题,遇到大图以后,用户还没看到预览,内存已经替代码做了自我介绍。

第二次改动:解码器别碰 DOM

旧版 Pages、Numbers 可以从 XML 关系恢复正文,Keynote 读取 APXL。现代文件要拆 Snappy frame,解释 archive 和 message,再按对象 ID 接回引用。

最早的实验代码边解析边创建节点,出图很快。代价是格式判断、对象关系和 CSS 修补全挤在一个流程里。多支持一个宿主界面,都像在拆一团打了死结的线。

后来两条解析链统一输出中间模型:

interface DocumentModel {
  kind: 'pages' | 'numbers' | 'keynote'
  scenes: SceneModel[]
  media: Map<string, BinaryResource>
  diagnostics: Diagnostic[]
}

SceneModel 只保存坐标空间和对象,不决定工具栏,也不持有宿主状态。未知 message 会留一条诊断,已知对象继续恢复。渲染器终于可以专心处理页面。

Numbers 让我把 scene 重新想了一遍

我起初把 scene 理解成“页”。这个定义对 Pages 和 Keynote 都顺手,到了 Numbers 就别扭了。

Numbers 的 sheet 是自由画布。一张画布上能放几个表格,也能摆文本框、图片、形状和图表。单元格坐标只属于某个表格,不属于整张工作表。

Numbers 工作表中的表格与画布

于是 scene 改成了更中性的坐标空间。Pages 用它表示页面,Keynote 表示幻灯片,Numbers 表示工作表画布。table 退回对象层,和图片、图表并列。

公式显示保存结果,不重新计算。这不是因为公式不重要,恰好相反。重新实现一套公式、区域设置和外部引用语义,可能让预览值偏离发送方保存时的状态。阅读场景里,旧结果通常比新计算更可信。

Keynote 保持静态

Keynote 能恢复幻灯片、母版背景、文本、图片、形状、表格、图表和演讲者备注。动画、转场和视频不执行。

const viewer = await renderIworkDocument(file, host, 'keynote')
viewer.fit('width')

window.addEventListener('beforeunload', () => viewer.destroy())

我没有把“不会播放动画”藏到兼容说明的最后一行。预览器面对的是不可信附件。它可以读内容,不应该因为有人上传了一份演示文稿就开始执行动作。

加密 iwpv2 也只做识别,不尝试在浏览器里解密。

Keynote 文件的静态幻灯片预览

Worker 最容易漏掉的是退出

解析运行在 module Worker 中,并限制压缩条目、解压字节、对象数量、字符串长度和图片像素。Worker 返回结构化模型与可转移二进制,主线程创建需要的 Blob URL。

打开一份文件时,大家都会看加载速度。连续打开二十份以后,问题通常出在另一头:旧 Worker 是否终止,Blob URL 是否释放,DOM 是否还挂着事件。

所以 destroy() 和成功解析走同一套回归。它不出现在截图里,却决定组件能不能安静地留在后台系统中。

“支持”要能被测试推翻

当前 fixture 跨越 iWork '09、2015 年前后的 IWA,以及 Apple 15.3.1 生成的原生文件。每份样本记录来源和 SHA-256,依次通过 parser、真实 Worker、打包后冷安装和浏览器视觉对比。

Pages / Keynote:像素差异不超过 3%
Numbers:像素差异不超过 5%

这不是对任意文件的像素级承诺。它的用途很朴素:已覆盖的文件一旦变差,构建会失败。新样本出问题,就补结构和回归,不给某个文件写专属位置。

独立的 iwork-viewer 0.0.2 已公开发布。File Viewer 最新主线已经接入,公共核心包仍停在 2.2.9,完整集成需要等下一次正式发版。项目代码和测试说明在 iWork Viewer

现在再看这三个后缀,我已经不会先问“页面怎么画”。我会先问里面是哪一代结构,未知对象准备怎么退,用户关掉附件后还有什么留在内存里。

这几句不如一张漂亮截图直观,却更接近预览器每天要处理的事情。

posted on 2026-08-18 10:51  执子念  阅读(11)  评论(0)    收藏  举报

导航