在Firefox PDF浏览器中实现表单填写和可访问性的介绍

554 阅读14分钟

介绍

去年,在封锁期间,许多人发现了PDF表格的重要性,因为他们必须与行政部门和银行等大型机构进行远程处理。火狐浏览器支持显示PDF表格,但不支持填写:用户必须打印出来,用手填写,然后扫描回数字表格。 我们决定是时候重新投资于PDF浏览器(PDF.js),并支持在Firefox中填写PDF表格,以使我们的用户的生活更轻松。

当我们在PDF查看器上投入更多时间的时候,我们也通过积压的工作,优先改善 我们的PDF阅读器对辅助技术用户的可及性 。下面我们将描述我们是如何实现表单支持、改善无障碍性,并确保我们在这一过程中没有退步。

对PDF.js架构的简要总结

Overview of the PDF.js Architecture要理解我们如何增加对表单和标记的PDF的支持,首先要理解一些关于PDF浏览器(PDF.js)在Firefox中如何工作的基本知识。

首先,PDF.js会在一个网络工作者中获取并解析文档。然后,被解析的文档将生成绘图指令。PDF.js将它们发送到主线程,并在一个HTML5画布元素上绘制。

除了画布,PDF.js 还可能创建三个层,并在上面显示。第一个层,即文本层,实现文本选择和搜索。它包含跨度元素,这些元素是透明的,并与画布上画在它们下面的文本保持一致。另外两个层是注释/AcroForm层XFA表单层。它们支持填表,我们将在下面更详细地描述它们。

填充表单(AcroForms)

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

AcroForm结构

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

AcroForm的实现

在PDF.js中,我们解析PDF文件并在一个网络工作者中创建注释。然后,我们将它们从工作器中发送出去,并在主进程中使用插入到一个div(注释层)中的HTML元素来渲染它们。我们将这个由HTML元素组成的注释层渲染在画布层的顶部。

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

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

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

Example PDF.js Form Rendering

上面是在 Firefox 中填写的表单,下面是在 Evince 中打开的打印 PDF。

在 PDF 中安全地执行 JavaScript


感谢我们的遥测技术,我们发现许多表单包含并使用嵌入式的JavaScript代码(是的,那是一种东西!)。

PDF中的JavaScript可以用来做很多事情,但最常见的是用来验证用户输入的数据或自动计算公式。例如,在这个PDF中,从用户输入开始自动进行税收计算。由于这个功能很常见,而且对用户有帮助,所以我们着手在PDF.js中实现它。

替代品

从我们的JavaScript实现开始,我们主要关注的是安全问题。我们不希望PDF文件成为一个新的攻击载体。嵌入的JS代码必须在PDF被加载或由表单元素(焦点、输入......)产生的事件中执行。

我们使用以下方法进行了调查。

  1. JS评估 功能
  2. emscriptenWebAssembly中编译的JS引擎
  3. Firefox JS引擎ComponentUtils.Sandbox

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

方案二,使用WebAssembly编译的JS引擎,是一个强有力的竞争者,因为它可以与Firefox内置的PDF浏览器和可以在普通网站中使用的PDF.js版本一起使用。然而,这将是一个巨大的新攻击面,需要审计。它还会大大增加PDF.js的大小,而且速度也会更慢。

第三个选择,沙盒,是在Firefox中暴露给特权代码的一个功能,它允许 在一个特殊的隔离环境中执行JS.沙盒是用一个空本金创建的,这意味着沙盒内的一切只能由它来访问,并且只能由沙盒本身(以及有特权的火狐代码)来访问其他东西。

我们的最终选择

我们最终选择了使用ComponentUtils.Sandbox作为Firefox内置浏览器。ComponentUtils.Sandbox已经在WebExtensions中使用多年了,所以这个实现是经过测试的,非常安全:从PDF中执行脚本至少和从普通网页中执行脚本一样安全。

对于一般的网络浏览器(我们只能使用标准的网络API,所以我们对ComponentUtils.Sandbox一无所知)和pdf.js测试套件,我们使用了WebAssembly版本的QuickJS(详见pdf.js. quickjs)。

在Firefox中 实现 PDF沙盒的工作方式如下。

  • 我们收集所有的字段和它们的属性(包括与它们相关的JS动作),然后将它们克隆到沙盒中。
  • 在构建时,我们生成一个包含JS代码的包 ,以实现PDF JS API(与我们习惯的Web API完全不同!)。我们把它加载到沙盒中,然后用第一步中收集的数据来执行它。
  • 在 字段的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>

模板的输出

Rendering of XFA Document

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,我们决定提供解放 字体和知名字体的字形宽度。我们使用这些宽度来重新调整字形的大小,以便为所有知名字体提供兼容的字体替换。

Comparing glyph rescaling

左边:没有重新调整字形比例的默认字体。在右边。解放字体,重新调整了字形比例以模拟MyriadPro。

结果

最后,结果相当不错,例如,你现在可以在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创建一个逻辑结构,然后屏幕阅读器可以使用。我们可以把它看作是一个类似于HTML层次结构的DOM节点的概念。使用上面的例子,人们可以添加标签。

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支持比这更多的功能,例如,图像的alt文本、表格数据、列表等。

我们是如何在PDF.js中支持标签PDF的

对于标记的PDF,我们利用了现有的 "文本层 "和浏览器内置的HTML ARIA可访问性功能。我们可以通过一个简单的有一个标题和一个段落的PDF例子很容易看出这一点。首先,我们生成逻辑结构并将其插入画布中。

<canvas id="page1">
  <!-- This content is not visible, 
  but available to screen readers   -->
  <span role="heading" aria-level="1" aria_owns="heading_id"></span>
  <span aria_owns="some_paragraph"></span>
</canvas>

在覆盖在画布上的文本层中。

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

然后,屏幕阅读器将在画布中行走DOM可访问性树,并使用`aria-owns`属性来寻找每个节点的文本内容。对于上面的例子,屏幕阅读器会宣布。

Heading Level 1 Some Heading Hello World!

对于那些不熟悉屏幕阅读器的人来说,有了这个额外的结构,也使得在PDF中的导航变得更加容易:你可以从一个标题跳到另一个标题,在没有不必要的停顿下阅读段落。

确保在规模上没有退步,满足重新测试

Reference Test Analyzer

抓取PDF文件

在过去的几个月里,我们建立了一个网络爬虫,从网络上检索PDF,并使用一套启发式方法,收集关于它们的统计数据(例如,它们是XFA吗?它们使用什么字体?它们包括哪些格式的图像?)。

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

有了这个爬虫,我们能够建立一个大型语料库,其中包括标签PDF(约32000)、使用JS的PDF(约1900)、XFA PDF(约1200),我们可以用它们进行人工和自动测试。为我们的QA团队经历了这么多的PDF而喝彩!他们现在知道了关于在加拿大申请钓鱼许可证的一切,生活技能!

重新测试的胜利

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

reftest是由一个测试文件和一个参考文件组成的测试。测试文件使用pdf.js渲染引擎,而参考文件不使用(以确保它是一致的,不会被测试所验证的补丁的变化所影响)。参考文件只是pdf.js的 "主 "分支中给定PDF的渲染截图。

reftest过程

当开发者向PDF.js repo提交修改时,我们会运行reftests并确保测试文件的渲染与参考截图完全相同。如果有差异,我们确保这些差异是改进而不是回归。

在接受和合并了一个变化之后,我们重新生成参考资料。

reftest的不足之处

在某些情况下,由于抗锯齿等原因,一个测试与参考资料相比,在渲染方面可能会有细微的差异。这就在结果中引入了噪音,开发人员和评审员必须对 "假的 "回归进行筛选。有时,由于要看大量的差异,有可能会错过真正的回归。

重新测试的另一个缺点是,它们往往很大。重新测试中的回归并不像单元测试的失败那样容易调查。

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

总结


对AcroForms的支持在Firefox v84中登陆了。在v88中执行JavaScript。在 v89 中的标签 PDF。XFA表单在v93(明天,2021年10月5日!)。

虽然所有这些功能都极大地提高了表单的可用性和可访问性,但仍有更多的功能我们想加入。如果你有兴趣帮忙,我们一直在寻找更多的贡献者,你可以在elementgithub上加入我们。

The postImplementing form filling and accessibility in the Firefox PDF viewerappeared first onMozilla Hacks - the Web developer blog.