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

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

相关推荐
alloc1 小时前
从延迟聚合到可解释诊断:MetricKit 的原理与工程化实践
前端
颜进强1 小时前
06 - OpenSpec change 从模糊想法到完整契约:new change / explore / propose 三连
前端·后端·ai编程
Cache技术分享1 小时前
499. Java 反射 - 获取类型上的注解
前端·后端
用户921080262861 小时前
左侧历史对话模块:使用 Conversations 搭建会话入口
前端
用户345208153881 小时前
我给 DSH 写了读 10x 数据的插件
javascript
用户69371750013841 小时前
9531 款 AI 工具流量真相:当 90% 的访问涌向 100 个平台,普通创业者还有机会吗?
前端·后端
莫问ABC1 小时前
HTML、CSS、JavaScript 前端三件套
前端·css·html
执子念的飞鱼1 小时前
70MB Excel 浏览器预览失败:定位 XLSX 重复解压与内存峰值
javascript·性能优化
颜进强1 小时前
07 - OpenSpec change 修正带与照单施工:update 修订 + apply 实现
前端·后端·ai编程