刚开始做 .pages 预览时,我以为任务可以概括成一句话:再写一个解析器。
第一批真实文件很快把这个想法推翻了。有的包里是 index.xml,有的是 index.apxl,还有一批塞满 .iwa 文件。它们顶着同一个后缀,里面却隔着两代格式。
Pages 跑通以后,我又顺手打开了 Numbers。更麻烦的事情来了:它根本不是一张从 A1 铺到右下角的表格。
这篇不列功能清单。我想把几处真正花时间的地方讲透,也把目前没做的能力说明白。

后缀只能把你带到门口
iWork 文件通常套着 ZIP 外壳。iWork '09 的 Pages、Numbers 主要围绕 XML 展开,Keynote 使用 APXL;现代 iWork 则把正文放进 Snappy 压缩的 IWA archive。
所以 .pages 只说明它属于 Pages,没告诉我们该走旧版 XML 还是现代 IWA。入口要先看包内证据:
typescript
type Kind = 'pages' | 'numbers' | 'keynote'
async function inspectIwork(file: File, kind: Kind) {
const zip = await openZip(await file.arrayBuffer())
if (zip.has('index.xml') || zip.has('index.apxl')) {
return { kind, generation: 'iwork-09' as const, zip }
}
if (zip.some(name => name.endsWith('.iwa'))) {
return { kind, generation: 'iwa' as const, zip }
}
throw new Error('Unrecognized iWork package')
}
真实代码还要照顾 gzip 旧索引、路径变体、资源上限和加密标记。这里有个我后来才接受的原则:探测层别急着读正文。它只回答"下一步交给谁",信息够用就停。
这样出错也更诚实。未知结构、加密文件、资源超限,不该最后都变成同一句"解析失败"。
IWA 里没有一棵等你遍历的 DOM 树
现代文件先解 Snappy frame,再读 archive info 和 message。文本存储、样式、几何、媒体、容器关系分散在不同对象里,彼此靠 ID 引用。
这部分最诱人的写法是边解码边创建 DOM。第一份样例会很快出图,等第二代文件、未知字段和另一个宿主界面进来,解析逻辑就和页面样式缠成一团。
我们先把两代文件都收敛成一个不认识 DOM 的模型:
typescript
interface IworkDocument {
kind: Kind
scenes: IworkScene[]
resources: Map<string, Uint8Array>
diagnostics: string[]
}
interface IworkScene {
name: string
width: number
height: number
objects: IworkObject[]
}
未知 message type 会被记进诊断,再跳过。能恢复的对象继续往下走。比起"猜一下这个字段大概是什么",这种处理没那么刺激,却更能扛住格式变化。
Numbers 不是披着苹果皮的 Excel
Pages 的 scene 可以对应页面,Keynote 对应幻灯片。Numbers 的 scene 对应自由画布工作表,工作表里可能同时摆着多个表格、文本框、图片和图表。

如果解析结果只保留二维单元格数组,第二个表格和浮动对象只能丢掉。模型必须先保住工作表的坐标空间,再把 table 当作其中一种对象。
公式也没有在浏览器里重新计算。预览显示文件保存时的结果。这个选择很克制,但我觉得对:用户要确认的是发送方保存了什么,不是让接收方的浏览器用另一套区域设置和外部引用重新算一遍。
为什么我把解析关进 Worker
文档是用户输入,不能因为扩展名看起来熟悉就信任它。文件可以带着异常条目、巨大图片、错误长度或远超预期的对象数量。
解析放进 module Worker 后,主线程和解析生命周期分开了。解压字节、图片像素、对象数量和字符串长度都有明确上限。切换附件时,Worker、Blob URL 和节点一起销毁。
typescript
const instance = await renderIworkDocument(file, mount, 'keynote')
instance.fit('width')
const printableScenes = instance.getPrintPages()
instance.destroy()
我以前也容易盯着"首屏多少毫秒"。把组件放进后台系统以后才发现,连续打开二十份附件还能不能正常退出,往往更重要。
静态,有时就是正确答案
Pages 目前覆盖分页与页面布局里的文本、图片、形状、表格、图表和样式。
Numbers 能读取多个工作表、多个表格、数字格式、浮动对象和文件保存时的公式结果。Keynote 能读取幻灯片、母版背景、常见对象与演讲者备注。
动画、转场和视频不会播放。加密 iwpv2 会被识别,但浏览器端不解密。文档动作和外部程序也不会执行。

这些限制不是发布前临时补上的免责声明。预览器服务的是阅读场景,静态本来就是它的安全边界。
三张自己做的样例,证明不了兼容性
当前语料覆盖 iWork '09、2015 年前后的 IWA 文件,还有 Apple 15.3.1 生成的当前原生文件。fixture 记录来源和 SHA-256,测试会经过真实 parser、module Worker、打包后冷安装和固定字体的浏览器视觉回归。
text
Pages / Keynote:最大像素差异 3%
Numbers:最大像素差异 5%
阈值只约束已经纳入回归的文件。我们没有因此声称任意陌生文件都能逐像素复刻 Apple 应用。新样本暴露问题时,先补数据结构和回归语料,不给文件名写一组专属坐标。
独立解析器 iwork-viewer 0.0.2 已公开发布。File Viewer 最新主线也完成了适配,公共核心包会随下一次正式版本发布。源码、安装方法和兼容边界在 iWork Viewer。
回头看,这项工作最费时间的地方并不在 DOM。麻烦都发生在更早的地方:认出眼前是哪一代文件,承认三类文档各有自己的布局,再为未知输入留出能解释、能退出的路径。
做到这些,后缀才算真的进了浏览器。