技术文章

在 Delphi 中使用 PDFium 進行 PDF/UA 结构树验证 (Structure Tree Validation)

你的预检报告指出文件符合 PDF/UA 标準。veraPDF 打开同一个文件时,卻在條款 7.3 下标示出一个没有替代文字的图形 (Figure)。这两个工具都是对的,而它们之間的落差,正是透过扫描字节來检查无障碍访问 (accessibility) 的整体問题所在。字节等級的扫描只能確認文件宣告其具有标記 (tagged):它找到了 /StructTreeRoot/MarkInfo /Marked true、XMP 封包中的 pdfuaid:part、文件标题以及語言。这些是格式标記,而且是必要的。但它们无法告訴你,位於第四页的实际图形是否带有螢幕閱读器可以朗读出來的描述。该答案存在於标签树 (tag tree) 中,而要獲得该答案,你必须走訪这棵树

PDFium 元件 (PDFium Component) 是一个适用於 Delphi 和 C++Builder 的原生 VCL PDF 函式庫,其 ValidatePdfUa 会执行上述两项扫描。字节等級的扫描处理格式标記。在此之上,还有一个结构树 (structure-tree) 扫描,它会加载即时的标記树 (tagged tree),走訪每个元素,并检查一小組具備高信賴度的内容规则;在这些规则中,缺失属性意味著真正的无障礙缺陷,而不僅僅是風格偏好。本文将探討这第二项扫描:它检查什么、为何其规则邏輯是一个底层没有 DLL 的纯函数 (pure function),以及它刻意在哪里停下腳步

为什么字节扫描看不到遗失的 Alt

ISO 14289-1 (PDF/UA-1) 是一层附加在 ISO 32000 之上的需求。这些需求中有部分是結构性的,而且在原始文件中可見:目录必须宣告一个结构树、查看器偏好设置必须设置 DisplayDocTitle,字体必须内嵌。一个会剝離流主体 (stream bodies) 并将名稱标記 (name tokens) 与分隔符号边界匹配的标記扫描器,可以验证所有这些项目,而 PDFium 的 ValidatePdfUaCompliance 正是針对 7.1、7.18 和 7.21 等條款执行这项工作

但是,“每个图形 (Figure) 都有替代文字”并不是文件語法的属性。它是邏輯結构 (logical structure) 的属性——将内容映射到意义的标記元素树。一个图形的 Alt 项目可以位在結构元素字典中、透过 /ActualText 跨度 (span) 提供,或來自於一个扮演特定角色的自訂类型 (role-mapped custom type)。你无法透过在字节流中 grep 搜寻 /Alt 來可靠地找到它,因为该字串会出现在不相关的上下文中,可能在对象流中被压缩,且完全无法告訴你它属於哪個結构元素。回答这个問题最誠实的方法,是逐個元素地詢問文件本身的结构树,这也是 veraPDF 和 PAC 進行評估的层面。这就是 PDFium 的 Tier-1 检查所围繞的界线:字节扫描用於格式,树状走訪用於内容

读取即时标签树 (live tag tree)

原始素材是 TPdf.GetStructureElements (也作为 StructureElements 属性呈现),它会返回一个 TPdfStructureElements — 这是 TPdfStructureElement 記录 (records) 按照文件順序排列的平面数组 (flat array)。每筆記录都是一个結构元素透过 PDFium 的存取函式所投射出的結果,其中包含无障礙规则实际需要的字段:

type
  TPdfStructureElement = record
    Level: Integer;            // depth in the tag tree
    ParentIndex: Integer;      // index of parent element, or -1
    TypeName: WString;         // standard /S name: Figure, Formula, Note...
    Title: WString;            // /T
    AlternateText: WString;    // /Alt   (FPDF_StructElement_GetAltText)
    ActualText: WString;       // /ActualText
    Expansion: WString;        // /E
    ID: WString;               // /ID    (FPDF_StructElement_GetID)
    Language: WString;         // /Lang
    MarkedContentIDs: TPdfIntegerArray;
    // ... child bookkeeping fields
  end;

TypeName 字段是验证器運作的樞紐。它來自 FPDF_StructElement_GetType,该函式在 PDFium 解析角色映射 (role map) 后,返回元素的标準結构类型 (它的 /S 名稱)。AlternateText 來自 FPDF_StructElement_GetAltTextActualText 來自 FPDF_StructElement_GetActualText,而 ID 來自 FPDF_StructElement_GetID。由於该数组是扁平 (flat) 且有順序的,验证器可以一次性地推論整份文件,而无须使用递迴 (recursing) ——这对於那唯一一个全域性而非針对個別元素的规则來说非常重要

检查器是一个纯函数 (pure function),而且这是刻意设計的

规则邏輯并不存在於与 DLL 溝通的方法内部。它是一个独立的、公开的纯函数:

function ValidatePdfUaStructureElements(
  const Elements: TPdfStructureElements): TPdfUaValidationIssues;

它接收一个扁平的元素数组并返回一个問题集合 (issue set)。它不呼叫任何 PDFium 函式,不打开任何文件,也不会觸碰全域状態 (global state)。这种分離是刻意为之的,且带來了雙重好处。首先,可測試性 (testability):你可以在單元測試中建置一个合成的 TPdfStructureElements 数组——一个没有 Alt 的图形、一个唯一可存取文字位於 ActualText 中的公式、两个共用同一个 ID 的注释——并对結果集進行斷言 (assert),完全不需要 pdfium.dll 存在。规则邏輯是離线验证的;而 DLL 的遍历 (traversal) 则由即时文件冒煙測試 (live-document smoke test) 分开验证,该測試在缺少函式庫时会跳过

其次,職責的清晰度 (clarity of responsibility)。TPdf.ValidatePdfUa 負責处理繁杂的部分——加载每一页,提取其元素,将它们累積起來——然后将乾淨的数组交給純检查器。“取得数据”(DLL、副作用、生命週期) 和“評判规则”(純粹、確定性) 永遠不会糾纏在一起。当规则需要改變时,你只需修改一个没有包含任何 I/O 的函式

这三條规则实际上检查了什么

结构树扫描会提出三個問题值,并将其附加在 TPdfUaValidationIssues 的末尾,以確保现有呼叫端 (callers) 的列舉 (enum) 維持 ABI 稳定性:pvuaiFigureMissingAltpvuaiFormulaMissingAltpvuaiNoteMissingId。程式码主体夠小,足以完全理解:

for I := 0 to High(Elements) do
begin
  T := string(Elements[I].TypeName);
  if T = 'Figure' then
  begin
    // §7.3 — a Figure needs an alternate representation:
    // an Alt entry OR ActualText. Flag only when BOTH are empty.
    if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
      Include(Result, pvuaiFigureMissingAlt);
  end
  else if T = 'Formula' then
  begin
    // §7.7 — same rule as Figure: Alt OR ActualText.
    if (Elements[I].AlternateText = '') and (Elements[I].ActualText = '') then
      Include(Result, pvuaiFormulaMissingAlt);
  end
  else if T = 'Note' then
  begin
    // §7.9 — every Note must have a unique ID.
    NoteId := string(Elements[I].ID);
    if NoteId = '' then
      Include(Result, pvuaiNoteMissingId)
    else
      for J := 0 to I - 1 do
        if (string(Elements[J].TypeName) = 'Note') and
           (string(Elements[J].ID) = NoteId) then
        begin
          Include(Result, pvuaiNoteMissingId);
          Break;
        end;
  end;
end;

條款 7.3 规範图形:一个 Figure 元素必须提供文字替代方案。这个检查的早期版本只看 Alt 项目,这让它比参考验证器还要严格。PDF/UA 接受透过 ActualText 取代提供可存取文字的图形——替代文字是一种有效的替代表示方式——所以该规则只有在 Alt 和 ActualText 两者 皆为空时,才会将图形标示出來。條款 7.7 涵蓋公式 (formulas),而在同样的修正之后,它使用了完全相同的 Alt-or-ActualText 測試;一个僅透过 ActualText 提供公式可存取文字的合规語料庫样本 (conformance-corpus sample) 曾被错误拒絕,直到公式分支与图形分支对齊后才解決

條款 7.9 本質上有所不同。一个 Note 必须擁有一个 /ID,且该 ID 在整份文件中必须是独一无二的。遗失 ID 是單一元素的失敗。而重复的 ID 则是两个元素之間的关係,这就是扁平数组如此重要的原因:对於每个 Note,检查器会向后扫描已經看过的元素,并标示出与任何带有相同 ID 之較早 Note 发生的衝突。这会产生明顯的 O(n²) 成本 (相对於 Note 数量),但这对任何真实文件來说都无关紧要,且能保持函式为一个單一的、易读的迴圈,无需同步維护任何輔助索引 (auxiliary index)

跨页累積以確保唯一性是全域的 (global)

PDFium 是按页 (per page) 揭露結构元素,而不是按文件 (per document),所以在 ValidatePdfUa 中的編排工作必须在规则执行之前将它们收集起來。它使用 FPDF_LoadPage / GetStructureElementsForPage / FPDF_ClosePage 走訪每一页 (独立於元件目前打开的任何页面),并将每一页的元素附加到同一个数组中。只有在那之后,它才会呼叫该純检查器:

// inside TPdf.ValidatePdfUa, after the byte-level pass
if (FDocument <> nil) and
   (not (pvuaiMissingStructTreeRoot in Result.Issues)) then
begin
  AllElems := nil;
  PageTotal := FPDF_GetPageCount(FDocument);
  for I := 0 to PageTotal - 1 do
  begin
    Page := FPDF_LoadPage(FDocument, I);
    if Page = nil then Continue;
    try
      PageElems := GetStructureElementsForPage(Page);
    finally
      FPDF_ClosePage(Page);
    end;
    // append PageElems into AllElems ...
  end;
  Result.Issues := Result.Issues + ValidatePdfUaStructureElements(AllElems);
end;

这种累積正是 7.9 唯一性检查正確運作的基础。不同页面上的两个注释可以共用一个 ID;如果你逐页验证,永遠都不会看到衝突,因为每一页的元素集合在内部看起來都是一致的。建立一个全文件范围的数组是让重复项目现形的唯一方法。最前方的防护機制也值得注意:只有在字节等級扫描没有回报 pvuaiMissingStructTreeRoot 时,树状走訪才会执行。一个没有标記的文件没有树可以走訪,且已經被标示为遗失結构根節点 (structure root),因此每页的加载工作会被完全跳过。深入扫描在无法从中受益的文件上不会耗費任何成本

在设計上趨於保守:寧可错失,絕不虛报警报 (miss quietly, never cry wolf)

此验证器最重要的一个特性在於它拒絕做什么。它只比对 /S 直接返回的标準 FPDF_StructElement_GetType 类型名稱——FigureFormulaNote。如果一份文件定义了自訂类型并将其角色映射到 Figure,根据 PDFium 解析类型的方式,它会回报自己的名稱。当这种情況发生时,检查器不会識別它并保持沉默。这是一个偽陰性 (false negative),而且这是预期的行为。设計準则为寧可少报,也絕不产生偽陽性 (false positive),因为一个在合规文件上頻頻发出假警报的预检工具,只会訓練使用者去忽略它——而一个被忽略的验证器比没有更糟。裝飾性图片存在於假影流 (artifact stream) 而非结构树中,因此它们一开始就絕不会作为图形出现;你不会收到关於正確标記为假影的背景线條“遗失 Alt”的抱怨

这也是为什么范围限制在三條规则的原因。标题层級的巢状結构 (條款 7.4)、表格表头范围 (7.5) 以及角色映射循環偵測 (7.1) 都是合理的 PDF/UA 需求,但要把它们检查好需要真正的图形和属性分析,而單純的检查则会产生设計所禁止的偽陽性結果——PDF/UA 允許像 H1, H2, H3, H3 这样的标题模式,而一个簡單的“必须严格递增”规则卻会错误地拒絕它。那些检查就留給专门的合规工具吧。Tier-1 集合指的是缺失属性会带來明確错误的子集

界线,说清楚講明白

在你将其連接到发布关卡之前,有两个限制是值得了解的。第一,检查器的優劣取決於 PDFium 能从結构元素中读取到什么。有少数几个参考验证器通过的合规語料庫文件,使用了 PDFium 未揭露的替代文字機制,所以即使该文件真的合规,FPDF_StructElement_GetAltText 也会返回空值。此时純检查器会在不完整的数据上“正確地”标示遗失 Alt——这是一个源自 DLL 存取器涵蓋范围而非规则邏輯的偽陽性。放寬规则來吸收这些案例也会使其对原本应该捕捉到的真正失敗視而不見,因此它们被記录为已知的 PDFium 限制,而不是粉飾太平

第二,这是一个预检,而不是一个認证。Tier-1 捕捉了字节扫描在結构上无法捕捉的高信賴度内容错误,且没有错误警报——但是完整的 PDF/UA 合规性,包括标题語意 (heading semantics)、表格結构和閱读順序的正確性,仍然属於完整验证器并最终交由人类审閱者來处理的工作。在你自己的管线中使用 ValidatePdfUa 來快速且低成本地使明顯缺陷失敗,然后让 veraPDF 或 PAC 來做最终決定。在建置Delphi 中的无障礙 PDF 閱读器时,相同的结构树遍历提供了基础支援,在该閱读器中,标签树驅动了閱读順序和朗读文字,且这也与从 Delphi 审閱 PDF 注释的中繼数据等級工作相輔相成

这里展示的结构树 API 和 ValidatePdfUa 验证器,包含在适用於 Delphi 和 C++Builder (VCL) 以及 Lazarus/FPC (LCL) 的 PDFium Component 之中。产品页面連結了完整的 API 参考数据,包含完整的 TPdfStructureElement 記录配置以及这些检查背后的問题列舉