技术文章

Delphi 中的 Tagged PDF 结构树

一份无障碍 PDF 依托于一个可见页面从不展示的结构:ISO 32000-1 §14.7 所定义的结构树。它是标题、段落、表格和图形构成的逻辑层级,叠加在已绘制的内容之上,并通过一张角色映射表映射到标准角色。屏幕阅读器读的是这棵树,不是页面上的墨迹。没有它,一份看上去无可挑剔的生成发票在语义上是空的,因为内容流记录的只有绘制顺序,别无其他。总额可能被抢在明细之前念出来,页脚可能切进一个段落中间,明细表可能坍缩成一长串不分彼此的词。防止这一切的成本对你极其有利。边绘制边输出结构是几分钟的代码;把它补进已完成的文档则是一个整改项目。losLab PDF Library(PDF Library for Delphi)通过一小组调用把这棵树暴露给 Delphi 和 C++Builder,让每一次绘制操作都被包进它的逻辑角色里

标记内容是怎样绑到结构树上的

两个层协同工作。在内容流里,绘制操作被括进标记内容序列,每一段携带一个整数 MCID。在文档目录里,结构树把这些 MCID 映射进一个由带类型元素(H1PTableFigure)组成的层级,并附带替代文本、语言之类的属性。自定义元素类型是合法的,但每一种都必须通过角色映射表解析到某个标准角色(ISO 32000-1 §14.8.4)。完全不承载意义的内容,比如分隔线、背景和重复的版面装饰,会被标记为 artifact,好让辅助技术跳过它而不是在句子中间把它念出来

PDF Library for Delphi 用一对括号维护这两层。BeginTag 打开一个结构元素并启动标记内容序列,绘制调用落在它里面,而 EndTag 把两者一起关掉。那些绊倒手工打标签的记账工作,也就是 MCID、父级树和页面引用,都发生在内部,你想弄错也弄不错

PDF Library for Delphi 示意图:携带整数 MCID 的标记内容段通过角色映射表绑定到 H1、P 和 Figure 结构树上,artifact 被排除在阅读顺序之外
整数 MCID 把标记内容段系到带类型的结构树上,角色映射表负责解析自定义角色,而 artifact 留在阅读顺序之外

在任何标签打开之前,有两个文档级开关为这项工作定下框架。SetMarkInfo 写入声明该文档已打标签的目录标志,而 IsTaggedPDF 把它读回来,这是在判断一份接入文件是否含有值得保留的结构时那次最省事的初探。语言有两个入口。SetDocumentLanguage 单独设定文档默认语言,而 SetPDFUAMode 在启用完整 PDF/UA 输出的同时把它一并设好。一份文件可以打了有用的标签却不声称符合 PDF/UA,而分阶段推进往往恰恰从这里起步

边绘制边打标签,而不是事后补

行得通的生成模式,是把标签括号当作每个绘制调用签名的一部分,绝不留作后续的一遍处理:

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // 原点取左上角
    Lib.SetPDFUAMode('en-US');                 // 会把保存版本抬到 PDF 1.7
    Lib.SetInformation(1, 'Service Manual');   // PDF/UA 强制要求 /Title
    Lib.AddRoleMap('ManualTitle', 'H1');       // 自定义类型 -> 标准角色
    Lib.AddStandardFont(4);
    Lib.SetTextSize(18);
    Lib.BeginTagEx2('ManualTitle', '', '', 'en-US', '', 'h1-cover', '');
    Lib.DrawText(72, 96, 'Service Manual');
    Lib.EndTag;
    Lib.BeginTag('Figure', 'Exploded view of the gearbox assembly', '');
    Lib.AddImageFromFile('gearbox.png', 0);
    Lib.EndTag;
    Lib.BeginArtifact('Layout');               // 版面装饰:排除在阅读之外
    // ... 在这里绘制分隔线和背景底色 ...
    Lib.EndArtifact;
    Lib.SaveToFile('manual.pdf');
  finally
    Lib.Free;
  end;
end;

这段序列里有三个调用带着合规分量。SetPDFUAMode 启用 PDF/UA 输出,并悄悄把文档版本抬到 PDF 1.7,这会与版本锁定相冲突。一份用 LockSaveVersion 锁在 PDF 1.4 上的文档,在 UA 模式激活后会拒绝保存并返回错误码 602,而这种冲突往往在归档规范和无障碍要求由不同团队配置时才浮出水面。SetInformation(1, ...) 写入文档标题,ISO 14289 期望查看器用它来代替文件名显示;它的缺失是现实中最常见的 PDF/UA 检查项之一。AddRoleMap 把自定义的 ManualTitle 类型登记为 H1,略过它就会让下文所述的诊断报出一个未映射的角色

标题层级值得一套刻意的策略,而不是为了某页好看而临时拍板。屏幕阅读器用户靠标题快捷键在章节间跳转,因此一个因为中间层级在视觉设计上显得太大就从 H1 跳到 H3 的模板,会悄悄毁掉这种导航,而任何视觉审阅都永远抓不到它。这恰恰是 HEADING-LEVEL-SKIP 诊断项存在的意义。把每套模板的视觉样式一次性、集中地映射到一个固定的标题阶梯上,漂移就压根不会开始

屏幕阅读器真能导航的表格

画出来的网格线一离开屏幕就什么都不是。屏幕阅读器导航的是结构关系:哪些单元格是表头、每个表头管辖什么,以及在不规则版面中数据单元格怎样绑到表头上。结构元素属性调用把这三件事都处理了:

Lib.BeginTag('Table', '', '');
Lib.BeginTag('TR', '', '');
Lib.BeginTagEx2('TH', '', '', '', '', 'col-part', '');
Lib.SetStructElemScope('Column');          // 仅在这个 TH 打开期间有效
Lib.DrawText(72, 120, 'Part');
Lib.EndTag;
Lib.BeginTagEx2('TH', '', '', '', '', 'col-torque', '');
Lib.SetStructElemScope('Column');
Lib.SetStructElemColSpan(2);               // 表头横跨数值列和单位列
Lib.DrawText(200, 120, 'Tightening torque');
Lib.EndTag;
Lib.EndTag;
Lib.BeginTag('TR', '', '');
Lib.BeginTag('TD', '', '');
Lib.SetStructElemHeaders('col-part');      // 为不规则表格作显式绑定
Lib.DrawText(72, 140, 'M8 flange bolt');
Lib.EndTag;
Lib.EndTag;
Lib.EndTag; // Table

顺序规则很严格,而且是无声执行的。每一个 SetStructElem* 调用作用于当下打开着的那个标签,也就是它的 BeginTagEndTag 之间;当没有标签打开、或该属性不适用于当前标签时,它返回 0 而不抛出任何东西。一个放错位置的调用就这么凭空消失了。开发期间把返回值包进断言里,能在你还看得见的时候抓住这种漂移;放任不管,一处缺失的 scope 只会在无障碍审计拿真实屏幕阅读器扫过这张表时才现形。经 BeginTagEx2 传入的元素 ID 会喂进 ID 树(ISO 32000-1 §14.7.4),而这正是 SetStructElemHeaders 绑定得以解析的前提

同一族属性覆盖了辅助技术依赖的其余部分。SetStructElemListNumbering 声明列表项是如何编号的,好让屏幕阅读器播报在列表中的位置,而不是把项目符号字形念一遍。SetStructElemBBox 记录图形和表格的边界框,重排视图靠它来放置内容。SetStructElemActualText 为字形无法映射到可读字符的内容段提供替换文本,比如一个由矢量图拼出来的首字下沉。每一个都遵循同一条规则:要么绑到打开着的标签上,要么消失

PDF Library for Delphi 表格示意图:展示 TH 的 scope、跨两列的 colspan 以及绑定到数据单元格的 headers 属性,旁边标注属性调用只在其标签打开期间才绑定的规则
屏幕阅读器跟随的是 TH 的 scope、colspan 和 headers 绑定,而不是画出来的线,而属性调用只在其标签打开期间才绑定

Artifact、语言与保存前的诊断关卡

重复的版面装饰,也就是页眉页脚、折页标记、水印和背景底色,应当放在 BeginArtifactEndArtifact 括号之内,好让它永远不进入阅读流。语言是可继承的。文档默认值来自 SetPDFUAMode 的参数,而另一种语言的内容段可经 BeginTagExSetStructElemLang 按元素覆盖它。正是这一点让英文手册里的一句法语引文仍然读得出来

保存之前,GetPDFUADiagnostics 会对内存中的文档跑一遍库的结构检查,并把发现以文本形式返回,空字符串表示什么也没查到。这些代码直接点名经典的写作失误:FIGURE-NO-ALT 表示图像没有替代文本,HEADING-LEVEL-SKIP 表示 H1 之后跟了个 H3ROLEMAP-UNMAPPED 表示某个自定义类型从未被登记。把它接进构建(生成整套文档,诊断非空就让这一步失败),无障碍回归就从几个月后的审计结论变成了编译期式的失败。完整的合规裁决仍然属于对已保存文件所做的预检,见 Delphi 中的 PDF/A 与 PDF/UA 预检,因为有些归一化只在序列化期间才施加

PDF Library for Delphi 示意图:GetPDFUADiagnostics 返回空字符串或诸如 FIGURE-NO-ALT 的具名检查项,在预检评判已保存文件之前先让构建失败
GetPDFUADiagnostics 在保存前报出 FIGURE-NO-ALT 之类的检查项,而把非空结果接进构建就能立刻让这一步失败

注释导航有自己的旋钮。PDF/UA 期望表单字段和链接的键盘遍历顺序与结构顺序一致,而 SetTabOrderMode 会写入查看器所遵循的页面级 Tab 顺序条目,审计接入文件时则可用 GetTabOrderMode。这类要求在纯键盘用户报缺陷之前没人会注意到,而把它做对每份文档只需一次调用

结构树并非在每次合并中都能幸存

带标签的文档只有在此后每一个处理步骤都保住这棵树时才继续带标签,而 PDF Library for Delphi 里那个锋利的边缘是合并列表家族。MergeFileListFast 拿结构树的保留去换速度。对扫描图像批次而言这是正确的交换,对带标签的报表则是错误的,因为输出打得开、渲染一模一样,却悄悄丢掉了它的无障碍层。只要有任何输入带标签,就请使用默认的 MergeFileList 或严格变体,并把 IsTaggedPDF 纳入装配后的断言,好让一批被压平的文件没法在无人察觉的情况下发出去。面向大型文档集的装配流水线还有更多这类取舍,见 大型 PDF 的合并、拆分与直接访问

验证闭环在库之外收尾:用 Acrobat 打开输出,检视标签面板,并对每个模板家族至少用真实屏幕阅读器通读一份文档。诊断能抓结构性错误;而一个技术上合法、实际上让人一头雾水的阅读顺序,只有人耳才抓得住。评估版构建和完整的打标签 API 参考位于 losLab PDF Library for Delphi 产品页