为什么需要 Imposia
浏览器出版反复遇到的问题,以及 Imposia 为此做出的设计取舍。
Imposia 源于一个容易复现、难以修复的故障:做一个让用户编辑内容并打印的应用,你最终会得到三份自称是同一份的文档。
三份自称是同一份的文档
典型的浏览器出版功能按这样的顺序生长。
首先,编辑器渲染内容,让用户可以编辑。接着有人要预览打印效果,于是加上预览——通常是把内容克隆到另一个容器里,套上另一套 CSS。然后打印必须真正可用,于是出现打印路径,在别处再次重建内容,常常是在服务器或无头浏览器里。
现在每个输出面都在测量自己那棵树。没有任何机制强迫它们一致,于是它们各自漂移:
- 预览显示 12 页,PDF 有 13 页。
- 表头在屏幕上正确重复,在纸上却没有。
- “见第 7 页”在一个产物里指向第 7 页,在另一个产物里指向第 8 页。
- 同一个 bug 在这位用户那里复现,在另一位那里不复现——因为漂移取决于字体、时机,以及哪个输出面先渲染。
这些都不是布局 bug。它们是三个布局权威并存、又没有规则说明以哪一个为准的必然结果。
常见方案及各自的代价
这个领域有很好的工具。它们大多通过选定一个权威、并为此放弃某些东西来化解冲突。
| 方案 | 做法 | 放弃的东西 |
|---|---|---|
| 服务端渲染器(无头浏览器或专用引擎) | 一个权威渲染器,脱离客户端 | 预览只是别处生成结果的图片,仍可能与用户正在编辑的内容不一致 |
| 自带组件的独立文档渲染器 | 从你掌控的布局树得到确定性输出 | 现有 HTML 和 CSS 不再适用;内容要写两遍 |
| 把屏幕光栅化 | 屏幕上是什么,产物就是什么 | 不是真正的分页;文本不再是文本 |
这些取舍都合理,对许多产品来说正是正确的选择。如果你需要的是在服务器上生成 PDF 字节,无头浏览器就是比 Imposia 更好的答案——我们会直说。
Imposia 的取舍
Imposia 选择另一种交换:一份由浏览器拥有的文档,所有输出面都必须观察它。
这带来一个结果:Imposia 不包含排版引擎。排版大致分四层:
- 字形整形 — 字距、连字、特定文字的连写规则
- 断行 — 行在哪里结束、连字符、CJK 规则
- 块布局 — 边距、浮动、flex、grid、表格
- 页面切分 — 纸张用尽时在哪里裁切内容
第 1 到 3 层正是浏览器经过几十年打磨、做得极好的部分。第 4 层是浏览器几乎没有实现的一层,因为它只在纸张存在时才存在。
于是 Imposia 只实现第 4 层,其余交给浏览器。它在真实的浏览器框架中渲染你的内容,测量浏览器实际产出的结果,再依据这些测量裁切页面。
这种方式带来什么
韩文和日文的断行规则、阿拉伯文整形、emoji 序列、可变字体,以及你的浏览器支持的每一项 CSS 布局特性都能工作——不是因为 Imposia 实现了它们,而是因为它们从未被从浏览器手中拿走。
这个选择的代价
这个选择有实实在在的缺点,假装没有会让这份文档的其余部分失去可信度。
- 测量不是免费的。 决定在哪里裁切,意味着追加内容、向浏览器询问几何信息,有时还要撤销重来。这比在封闭模型中计算布局慢。
- 引擎是浏览器的。 Chromium、Firefox 和 WebKit 的断行不同,页数也可能不同。Chromium 是结构基准;其余浏览器验证 API、隔离、生命周期和导出行为,不验证完全一致的输出。
- CSS 分片尚未被完全解决。 表格、flex、grid 和多栏布局按文档列出的子集支持。子集之外的内容保持完整不被拆分,并发出类型化警告,而不是被悄悄近似。
Imposia 拒绝做什么
边界是产品的一部分,不是产品的缺口。
- 不生成 PDF 字节。
print()把完成的页面交给浏览器自身的打印对话框,读者在其中选择打印机或另存为 PDF。加一个 PDF 渲染器等于制造第四份可能与其他三份不一致的文档——而这正是这个库要消除的问题。 - 没有 Node 或 CLI 渲染器,没有服务端导出。 运行时边界就是浏览器。
- 不承诺各引擎页数一致。 只要第 1 到 3 层归浏览器所有,这个承诺就无法兑现,所以不做这个承诺。
Imposia 适合你吗
大概率适合
你有一个 React 应用,用户在其中查看并编辑内容,而打印或 PDF 结果必须与他们眼前看到的一致。
大概率不适合
你需要在服务器上生成 PDF 字节,而内容没有人在浏览器里预览。