Firefox PDF查看器中表单填充与无障碍功能的实现技术解析
在Firefox PDF查看器中实现表单填充与无障碍功能
引言
去年疫情期间,许多人在远程处理行政机构和大型组织(如银行)事务时发现了PDF表单的重要性。Firefox虽然支持显示PDF表单,但不支持填写:用户必须打印表单、手工填写并扫描回数字格式。我们决定重新投入PDF查看器(PDF.js)的开发,支持在Firefox中直接填写PDF表单以方便用户。
在加大对PDF查看器的投入时,我们还处理了积压的工作,并优先改进了对辅助技术用户的无障碍访问支持。下文我们将描述如何实现表单支持、改进无障碍功能,并确保在整个过程中没有回归问题。
PDF.js架构简要概述
要理解我们如何添加对表单和标记PDF的支持,首先需要了解PDF查看器(PDF.js)在Firefox中的基本工作原理。
首先,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加载时或在表单元素生成的事件(焦点、输入等)上执行。
我们研究了以下选项:
- JS eval函数
- 使用emscripten编译为WebAssembly的JS引擎
- 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沙箱的实现如下:
- 我们收集所有字段及其属性(包括与它们关联的JS操作),然后将它们克隆到沙箱中;
- 在构建时,我们生成一个包含实现PDF JS API(与我们熟悉的Web API完全不同!)的JS代码的包。我们将其加载到沙箱中,然后使用第一步收集的数据执行它;
- 在字段的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元素的值。
示例模板
<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如何绘制文本的一些伪代码:
showText("This", 0 /*x*/, 60 /*y*/);
showText("is", 0, 40);
showText("a", 0, 20);
showText("Heading!", 0, 0);
这将文本绘制为四个独立的行,但屏幕阅读器无法知道它们都是一个标题的一部分。为了帮助无障碍访问,后来版本的PDF规范引入了“标记PDF”。这允许PDF创建屏幕阅读器可以使用的逻辑结构。可以认为这与DOM节点的HTML层次结构类似。使用上面的例子,可以添加标签:
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:
<canvas id="page1">
<!-- 此内容不可见,但可用于屏幕阅读器 -->
<span role="heading" aria-level="1" aria-owns="heading_id"></span>
<span aria_owns="some_paragraph"></span>
</canvas>
在叠加canvas的文本层中:
<div id="text_layer">
<span id="heading_id">Some Heading</span>
<span id="some_paragaph">Hello world!</span>
</div>
屏幕阅读器然后将遍历canvas中的DOM无障碍树,并使用aria-owns属性查找每个节点的文本内容。对于上面的例子,屏幕阅读器将宣布:
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添加到我们的参考测试列表中。
参考测试是由测试文件和参考文件组成的测试。测试文件使用pdf.js渲染引擎,而参考文件不使用(以确保其一致性并且不受测试验证的补丁更改的影响)。参考文件只是pdf.js“主”分支中给定PDF渲染的屏幕截图。
参考测试过程
当开发人员向PDF.js存储库提交更改时,我们运行参考测试并确保测试文件的渲染与参考屏幕截图完全相同。如果有差异,我们确保差异是改进而不是回归。
接受并合并更改后,我们重新生成参考。
参考测试的缺点
在某些情况下,由于抗锯齿等原因,测试的渲染可能与参考有细微差异。这在结果中引入了噪声,开发人员和审查人员必须筛选“虚假”回归。有时,由于需要查看的差异数量众多,可能会错过真正的回归。
参考测试的另一个缺点是它们通常很大。参考测试中的回归不像单元测试失败那样容易调查。
尽管有这些缺点,参考测试是pdf.js武器库中非常强大的回归预防武器。我们拥有的大量参考测试在应用更改时增强了我们的信心。
结论
对AcroForms的支持在Firefox v84中落地。JavaScript执行在v88中。标记PDF在v89中。XFA表单在v93中(明天,2021年10月5日!)。
虽然所有这些功能都大大提高了表单的可用性和无障碍性,但我们仍然希望添加更多功能。如果您有兴趣提供帮助,我们一直在寻找更多的贡献者,您可以在Element或GitHub上加入我们。
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
公众号二维码


浙公网安备 33010602011771号