Firefox PDF 查看器中表单填充与无障碍功能的实现技术解析

本文详细介绍了 Firefox 团队如何在 PDF.js 中实现 AcroForm 和 XFA 表单填充功能,通过 JavaScript 沙箱安全执行嵌入式代码,并利用 ARIA 技术为标记 PDF 提供无障碍支持,同时使用爬虫和回归测试确保质量。

在 Firefox PDF 查看器中实现表单填充与无障碍功能

引言

去年疫情期间,许多人在远程处理行政机构和大型组织(如银行)事务时发现了 PDF 表单的重要性。Firefox 虽然支持显示 PDF 表单,但不支持填写:用户必须打印表单、手动填写并扫描回数字格式。我们决定重新投入 PDF 查看器(PDF.js)的开发,支持在 Firefox 中填写 PDF 表单,以方便用户。

在投入更多时间开发 PDF 查看器的同时,我们还处理了积压的工作,并优先改进了辅助技术用户的无障碍访问体验。下文我们将描述如何实现表单支持、改进无障碍功能,并确保在此过程中没有回归问题。

PDF.js 架构简要概述

要理解我们如何添加表单和标记 PDF 支持,首先需要了解 Firefox 中 PDF 查看器(PDF.js)的基本工作原理。

首先,PDF.js 在 Web Worker 中获取并解析文档。解析后的文档会生成绘制指令。PDF.js 将这些指令发送到主线程,并在 HTML5 canvas 元素上绘制。

除了 canvas,PDF.js 还可能创建另外三个叠加在其上的层。第一层是文本层,支持文本选择和搜索。它包含透明的 span 元素,与 canvas 上绘制的文本对齐。另外两层是 Annotation/AcroForm 层和 XFA 表单层。它们支持表单填充,我们将在下文详细描述。

填充表单(AcroForms)

AcroForms 是 PDF 支持的两种表单类型之一,也是最常见的表单类型。

AcroForm 结构

在 PDF 文件中,表单元素存储在注释数据中。PDF 中的注释是与文档主要内容分开的元素。它们通常用于在文档上做笔记或在文档上绘图。AcroForm 注释元素支持用户输入,类似于 HTML 输入,例如文本、复选框、单选按钮。

AcroForm 实现

在 PDF.js 中,我们在 Web Worker 中解析 PDF 文件并创建注释。然后,我们从 Worker 发送它们,并在主进程中使用插入到 div(注释层)中的 HTML 元素渲染它们。我们将这个由 HTML 元素组成的注释层渲染在 canvas 层之上。

注释层在浏览器中显示表单元素效果很好,但它与 PDF.js 支持打印的方式不兼容。在打印 PDF 时,我们在一个特殊的打印 canvas 上绘制其内容,将其插入当前文档并发送到打印机。为了支持打印带有用户输入的表单元素,我们需要在 canvas 上绘制它们。

通过检查(借助 qpdf 工具)使用其他工具保存的表单的原始 PDF 数据,我们发现需要使用一些 PDF 绘制指令来保存已填写字段的外观,并且我们可以通过一个共同的实现来支持保存和打印。

为了生成字段外观,我们需要获取用户输入的值。我们引入了一个名为 annotationStorage 的对象,通过在相应的 HTML 元素中使用回调函数来存储这些值。然后在保存或打印时将 annotationStorage 传递给 Worker,并使用每个注释的值来创建外观。

安全执行 PDF 中的 JavaScript

通过我们的遥测技术,我们发现许多表单包含并使用嵌入式 JavaScript 代码(是的,这是真的!)。

PDF 中的 JavaScript 可用于许多事情,但最常用于验证用户输入的数据或自动计算公式。例如,在这个 PDF 中,根据用户输入自动执行税务计算。由于此功能对用户常见且有用,我们着手在 PDF.js 中实现它。

备选方案

从我们的 JavaScript 实现开始,我们的主要关注点是安全性。我们不希望 PDF 文件成为新的攻击向量。嵌入式 JS 代码必须在 PDF 加载时或在表单元素生成的事件(焦点、输入等)上执行。

我们研究了以下选项:

  1. JS eval 函数
  2. 使用 emscripten 编译为 WebAssembly 的 JS 引擎
  3. Firefox JS 引擎 ComponentUtils.Sandbox

第一个选项虽然简单,但立即被放弃,因为在 eval 中运行不受信任的代码非常不安全。

第二个选项,使用通过 WebAssembly 编译的 JS 引擎,是一个强有力的竞争者,因为它适用于内置的 Firefox PDF 查看器和可在常规网站中使用的 PDF.js 版本。然而,这将是一个需要审计的巨大的新攻击面。它还会显著增加 PDF.js 的大小,并且速度会更慢。

第三个选项,沙箱,是 Firefox 中暴露给特权代码的一个功能,允许在特殊的隔离环境中执行 JS。沙箱是用空主体创建的,这意味着沙箱中的所有内容只能由它访问,并且只能访问沙箱本身内的其他内容(以及由特权 Firefox 代码访问)。

我们的最终选择

我们决定为 Firefox 内置查看器使用 ComponentUtils.Sandbox。ComponentUtils.Sandbox 已在 WebExtensions 中使用多年,因此此实现经过实战检验且非常安全:执行来自 PDF 的脚本至少与执行来自普通网页的脚本一样安全。

对于通用 Web 查看器(我们只能使用标准 Web API,因此对 ComponentUtils.Sandbox 一无所知)和 pdf.js 测试套件,我们使用了 QuickJS 的 WebAssembly 版本(详见 pdf.js.quickjs)。

Firefox 中 PDF 沙箱的实现如下:

  1. 我们收集所有字段及其属性(包括与它们关联的 JS 操作),然后将它们克隆到沙箱中;
  2. 在构建时,我们生成一个包含实现 PDF JS API(与我们熟悉的 Web API 完全不同!)的 JS 代码的包。我们将其加载到沙箱中,然后使用第一步收集的数据执行它;
  3. 在字段的 HTML 表示中,我们添加了回调来处理事件(焦点、输入等)。回调简单地将它们通过包含字段标识符和链接参数的对象分派到沙箱中。我们使用 eval 在沙箱中执行相应的 JS 操作(在这种情况下是安全的:我们在沙箱中)。然后,我们克隆结果并将其分派到沙箱外部,以更新字段的 HTML 表示中的状态。

我们决定不实现与 I/O(网络、磁盘等)相关的 PDF API,以避免任何安全问题。

另一种表单格式:XFA

我们的遥测还告诉我们,另一种类型的 PDF 表单 XFA 相当常见。此格式已从官方 PDF 规范中删除,但许多带有 XFA 的 PDF 仍然存在并被我们的用户查看,因此我们也决定实现它。

XFA 格式

XFA 格式与 PDF 文件中通常的内容非常不同。正常的 PDF 通常是一个绘制命令列表,所有布局由 PDF 生成器静态定义。然而,XFA 更接近 HTML,并且具有 PDF 查看器必须生成的更动态的布局。实际上,XFA 是一种完全不同的格式,被硬塞到 PDF 中。

PDF 中的 XFA 条目包含多个 XML 流:最重要的是模板和数据集。模板 XML 包含渲染表单所需的所有信息:它包含 UI 元素(例如文本字段、复选框等)和容器(子表单、绘图等),这些容器可以具有静态或动态布局。数据集 XML 包含表单本身使用的所有数据(例如文本字段内容、复选框状态等)。所有这些数据都被绑定到模板中(在布局之前)以设置不同 UI 元素的值。

示例模板

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<template xmlns="http://www.xfa.org/schema/xfa-template/3.6/">
  <subform>
    <pageSet name="ps">
      <pageArea name="page1" id="Page1">
        <contentArea x="7.62mm" y="30.48mm" w="200.66mm" h="226.06mm"/>
        <medium stock="default" short="215.9mm" long="279.4mm"/>
      </pageArea>
    </pageSet>
    <subform>
      <draw name="Text1" y="10mm" x="50mm" w="200mm" h="7mm">
        <font size="15pt" typeface="Helvetica"/>
        <value>
          <text>Hello XFA & PDF.js world !</text>
        </value>
      </draw>
    </subform>
  </subform>
</template>

模板输出

XFA 实现

在 PDF.js 中,我们已经有一个相当好的 XML 解析器来检索 PDF 的元数据:这是一个好的开始。

我们决定将每个 XML 节点映射到一个 JavaScript 对象,其结构用于验证节点(例如可能的子节点及其不同数量)。一旦 XML 被解析和验证,表单数据需要绑定到表单模板中,并且可以使用 SOM 表达式(一种 XPath 表达式)来使用一些原型。

布局引擎

在 XFA 中,我们可以有不同种类的布局,最终布局取决于内容。我们最初计划利用 Firefox 布局引擎,但发现不幸的是我们需要自己布局所有内容,因为 XFA 使用一些 Firefox 中不存在的布局功能。例如,当容器溢出时,额外内容可以放在另一个容器中(通常在新页面上,但有时也在另一个子表单中)。此外,一些模板元素没有任何尺寸,必须根据其内容推断。

最终我们实现了一个自定义布局引擎:我们从上到下遍历模板树,并按照布局规则检查元素是否适合可用空间。如果不适合,我们将所有已布局的元素刷新到当前内容区域,并移动到下一个区域。

在布局期间,我们将所有 XML 元素转换为具有树结构的 JavaScript 对象。然后,我们将它们发送到主进程,以转换为 HTML 元素并放置在 XFA 层中。

缺失字体问题

如上所述,某些元素的尺寸未指定。我们必须根据其中使用的字体自行计算它们。这更具挑战性,因为有时字体未嵌入 PDF 文件中。

不在 PDF 中嵌入字体被认为是不良实践,但实际上许多 PDF 不包含一些知名字体(例如 Acrobat 或 Windows 附带的字体:Arial、Calibri 等),因为 PDF 创建者只是期望它们始终可用。

为了使我们的输出更接近 Adobe Acrobat,我们决定提供 Liberation 字体和知名字形的字形宽度。我们使用宽度重新缩放字形绘制,以便为所有知名字体提供兼容的字体替换。

结果

最终结果相当不错,例如,您现在可以在 Firefox 93 中打开诸如 5704 – 鱼类出口许可证申请之类的 PDF!

使 PDF 可访问

什么是标记 PDF?

早期版本的 PDF 对于屏幕阅读器等无障碍工具并不友好。这主要是因为在一个文档中,页面上的所有文本或多或少都是绝对定位的,并且没有逻辑结构的概念,例如段落、标题或句子。也没有办法提供图像或图形的文本描述。例如,以下是 PDF 如何绘制文本的一些伪代码:

1
2
3
4
showText("This", 0 /*x*/, 60 /*y*/);
showText("is", 0, 40);
showText("a", 0, 20);
showText("Heading!", 0, 0);

这将文本绘制为四个独立的行,但屏幕阅读器无法知道它们都是一个标题的一部分。为了帮助无障碍访问,后期版本的 PDF 规范引入了“标记 PDF”。这允许 PDF 创建屏幕阅读器可以使用的逻辑结构。可以将其视为类似于 DOM 节点的 HTML 层次结构。使用上面的示例,可以添加标签:

1
2
3
4
5
6
beginTag("heading 1");
showText("This", 0 /*x*/, 60 /*y*/);
showText("is", 0, 40);
showText("a", 0, 20);
showText("Heading!", 0, 0);
endTag("heading 1");

有了额外的标签信息,屏幕阅读器知道所有行都是“标题 1”的一部分,并可以以更自然的方式阅读它。该结构还允许屏幕阅读器轻松导航到文档的不同部分。

上面的示例仅涉及文本,但标记 PDF 支持比这更多的功能,例如图像的替代文本、表格数据、列表等。

我们如何在 PDF.js 中支持标记 PDF

对于标记 PDF,我们利用了现有的“文本层”和浏览器内置的 HTML ARIA 无障碍功能。我们可以通过一个简单的 PDF 示例轻松看到这一点,该示例包含一个标题和一个段落。首先,我们生成逻辑结构并将其插入到 canvas 中:

1
2
3
4
5
<canvas id="page1">
  <!-- 此内容不可见,但可用于屏幕阅读器 -->
  <span role="heading" aria-level="1" aria-owns="heading_id"></span>
  <span aria_owns="some_paragraph"></span>
</canvas>

在覆盖 canvas 的文本层中:

1
2
3
4
<div id="text_layer">
  <span id="heading_id">Some Heading</span>
  <span id="some_paragaph">Hello world!</span>
</div>

然后,屏幕阅读器将遍历 canvas 中的 DOM 无障碍树,并使用 aria-owns 属性查找每个节点的文本内容。对于上面的示例,屏幕阅读器将宣布:

1
2
Heading Level 1 Some Heading
Hello World!

对于那些不熟悉屏幕阅读器的人,拥有这种额外的结构也使浏览 PDF 变得更加容易:您可以从一个标题跳转到另一个标题,并在不需要的暂停的情况下阅读段落。

确保大规模没有回归,满足参考测试

爬取 PDF

在过去的几个月里,我们构建了一个网络爬虫来从网络检索 PDF,并使用一组启发式方法收集关于它们的统计信息(例如,它们是 XFA 吗?它们使用什么字体?它们包含什么格式的图像?)。

我们还使用爬虫及其启发式方法从 PDF 协会发布的“压力 PDF 语料库”中检索感兴趣的 PDF,这被证明特别有趣,因为它们包含许多我们认为不可能存在的极端情况。

通过爬虫,我们能够构建一个大型的标记 PDF 语料库(约 32000 个)、使用 JS 的 PDF(约 1900 个)、XFA PDF(约 1200 个),我们可以用于手动和自动化测试。向我们的 QA 团队致敬,他们检查了如此多的 PDF!他们现在对在加拿大申请钓鱼许可证、生活技能等一切了如指掌。

参考测试取胜

我们不仅将语料库用于手动 QA,还将其中一些 PDF 添加到我们的参考测试(reftests)列表中。

参考测试是由测试文件和参考文件组成的测试。测试文件使用 pdf.js 渲染引擎,而参考文件不使用(以确保它一致且不受测试验证的补丁更改的影响)。参考文件只是 pdf.js“主”分支中给定 PDF 渲染的屏幕截图。

参考测试过程

当开发人员向 PDF.js 仓库提交更改时,我们运行参考测试并确保测试文件的渲染与参考屏幕截图完全相同。如果有差异,我们确保差异是改进而不是回归。

在接受和合并更改后,我们重新生成参考。

参考测试的缺点

在某些情况下,由于抗锯齿等原因,测试的渲染可能与参考有细微差异。这在结果中引入了噪音,开发人员和审查人员必须筛选“虚假”回归。有时,由于需要查看大量差异,可能会错过真正的回归。

参考测试的另一个缺点是它们通常很大。参考测试中的回归不像单元测试失败那样容易调查。

尽管有这些缺点,参考测试是 pdf.js 武器库中非常强大的回归预防武器。我们拥有的大量参考测试在应用更改时增强了我们的信心。

结论

AcroForms 支持在 Firefox v84 中落地。JavaScript 执行在 v88 中。标记 PDF 在 v89 中。XFA 表单在 v93 中(明天,2021 年 10 月 5 日!)。

虽然所有这些功能都大大提高了表单的可用性和无障碍性,但我们还有更多功能想要添加。如果您有兴趣提供帮助,我们一直在寻找更多的贡献者,您可以在 element 或 github 上加入我们。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计