浏览器直接预览 Pages、Numbers、Keynote:iWork 格式真正难在哪

刚开始做 .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。麻烦都发生在更早的地方:认出眼前是哪一代文件,承认三类文档各有自己的布局,再为未知输入留出能解释、能退出的路径。

做到这些,后缀才算真的进了浏览器。

相关推荐
三十而立洋4 分钟前
JavaScript 原型链:一张图讲透「对象继承」的底层真相
前端·javascript
工具派9 分钟前
markdown在线编辑器怎么选?渲染管线的3个坑和md转PDF跑版记录
前端·后端
平头哥技术团队9 分钟前
Day 13 | 调 line-height 和 margin:三处间距让名片页脱离模板感
开发语言·前端·javascript·学习·html5
一位正在转型AI全栈的前端工程师11 分钟前
AI 全栈学习之旅 -Week 9:什么是AI Agent?从Function Calling到LangGraph
前端·python
计算机魔术师28 分钟前
Claude Opus 5 干不过人类客服?23.9% 的通过率撕开 Agent 真相
前端
cjy0001111 小时前
2026年9月零基础能听懂国内 FDE 讲师的课吗?
大数据·前端·人工智能·fde
南雨北斗1 小时前
wangeditor5 在vue3项目中的正确配置
前端
老三_Micheal1 小时前
一文搞懂 Axios 请求参数详解:headers、params 与 body 的区别和使用场景
javascript
Cache技术分享1 小时前
517. Java 方法句柄 - 方法句柄 vs 反射 API
前端·后端
小刘在重生~1 小时前
Web 前端基础|HTML+CSS+JavaScript+Bootstrap
前端·css·html