技術文章

在 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)

此驗證器最重要的一個特性在於它拒絕做什麼。它只比對 FPDF_StructElement_GetType 直接回傳的標準 /S 類型名稱——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 記錄配置以及這些檢查背後的問題列舉。