技术文章

Delphi 中无障碍 PDF 的自动结构标签

PDFlibPas 可以在绘制文档的同时完成标签标注。打开 SetAutoTagMode 之后,普通的 DrawText 调用会变成段落,紧跟在 RegisterHeading 之后绘制的文本会变成对应级别的标题,页眉页脚会变成阅读器跳过的工件(artifact),图片会变成插图,而 DrawTableRows 会把表格、行和单元格一并写入结构树

另一种做法——也是直到不久前唯一的选择——是手动用 BeginTagEndTag 包裹每一次绘图调用。这种做法能工作,对于结构特殊的文档它仍然是合适的工具。但对于常见的报表、发票或对账单来说,这意味着输出的可访问性依赖于每一条绘图路径上都没有人漏掉一对标签

模式位各覆盖哪些内容

SetAutoTagMode 接收一个位掩码,并返回之前生效的模式。AUTOTAG_TEXT(1)把文本标注为段落,或在标题待写时标注为标题。AUTOTAG_FURNITURE(2)把页眉、页脚和页码标记为工件。AUTOTAG_FIGURE(4)把绘制的图片变成插图,或在图片被声明为装饰性时变成工件。AUTOTAG_TABLE(8)把绘制的表格带入结构树。AUTOTAG_DEFAULT 等于 15,也就是这四位全开

打开这个模式同时会把文档标记为已标记(tagged),这一步的意义比听上去更大。除非目录(catalog)另有声明,否则阅读器会把文档视为未标记(ISO 32000-1 §14.7.1),所以一个携带完整结构树却没有 /MarkInfo 声明的文件,在辅助技术看来就等于完全没有结构。树是存在的,只是没人去读它

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);   // text + furniture + figures + tables
    Lib.AddStandardFont(4);
    Lib.SetTextSize(18);
    Lib.RegisterHeading(1, 'Annual service report');
    Lib.DrawText(72, 96, 'Annual service report');   // becomes H1
    Lib.SetTextSize(11);
    Lib.DrawText(72, 130, 'Every unit installed before 2024 was inspected.');
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

标题怎么知道它属于哪段文本

RegisterHeading 为接下来要绘制的文本指定级别,并且它会等待文本。如果中间插入了一张图片,图片会变成插图,而标题继续挂起,等待后面的文本。这种行为是刻意设计的:另一种做法——让图片占走标题级别——产出的文档里,标题下方的一条装饰性分隔线会被宣读成标题本身

同一条"只对下一项生效"的规则也适用于插图。RegisterFigure 提供下一张图片要携带的描述,RegisterDecoration 则把下一张图片声明为没有含义的分隔线、边框或背景。两者都只被一张图片消耗,所以后画的图片绝不会继承前一张的描述——这正是手写标签代码里替代文字被挂到错图上的根源

这段描述在无障碍文档中比任何其他字符串都重要。视障读者听到的是描述,而不是图片本身,而且这就是他们能听到的全部。"图表"不是描述;"按地区划分的季度营收,第三季度东部地区最高"才是

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);      // becomes a tagged Figure

Lib.RegisterDecoration;                       // meaningless rule
Lib.AddImageFromFile('divider.png', 0);       // drawn inside a layout artifact

表格、表头与重复决策归属何处

打开表格位之后,DrawTableRows 会把表格、行和单元格写入结构树,让阅读器能说出某个数值所在的列,而不是把整张表格当成一串无关的文本读出来。SetTableHeaderRowCount 指定最前面几行是表头;这些行会写成带列作用域(scope)的表头单元格,阅读器正是借此才能读出当前数值对应的表头

这样指定的表头行就停留在原地。在每个分页顶端重复表头属于排版决策,也依然是排版决策:DrawTaggedTableRows 正是为这个目的提供了一个 RepeatHeaderRows 参数。把两者分开,避免结构树在每次分页时多出一份表头副本——自动重复就会产生这样的副本

var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // row 1 is the header band
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... fill the data rows ...
  // Draw rows 1..40 into a 600pt band, repeating one header row per page
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

自动标签与手动标签混用

在手动打开的标签内部,自动标签会自动让位。文档的一部分可以由你的代码来描述结构,其余部分交给库,两者不会彼此嵌套——而这正是大多数真实文档想要的组织方式。封面和签名块只有你自己的代码能理解;中间那两百页正文则不然

两条安全规则保证输出干净。工件内部不会打标签,因为标记为工件的内容不允许携带任何结构元素。空文本不会打开任何元素,所以一次误发的 DrawText 携带空字符串不会产生一个被阅读器宣读为空白的结构元素。这两类缺陷在手写标签的文档里会悄悄累积,几个月后才被校验器成批报出来

自动标签仍不会替你决定的事

超出绘制顺序的阅读顺序、段落/标题/插图/表格以外的语义角色,以及语言声明。自动标签按内容绘制的顺序分配结构——如果你的排版代码先画侧栏再画正文,树里记录的就是这个顺序。对于视觉顺序与阅读顺序确实不同的文档,手动标签 API 仍然是合适的工具,而 标签化 PDF 与可访问性结构那篇详解覆盖了角色、作用域和表头绑定的细节

文档完成时,请校验而不是假定:PDF/A 与 PDF/UA 预检的笔记展示了如何对你产出的结构拿到一份判定结论,而 数据集驱动的报表导出的详解覆盖了这些调用在一个从数据生成版式的报表引擎中处于什么位置

PDFlibPas 是面向 Delphi、C++Builder 和 Lazarus 的原生 Pascal PDF 库,没有外部 PDF 运行时,所以无障碍输出由绘制文档的同一份代码产生——完整的 API 与平台清单见 PDFlibPas 产品页