技術文章

Delphi 中可存取 PDF 的自動結構標記

PDFlibPas 可以一邊繪製文件、一邊為它加上標記。開啟 SetAutoTagMode 後,一般的 DrawText 呼叫會變成段落、緊接在 RegisterHeading 之後繪製的文字會變成該層級的標題、重複出現的頁首與頁尾會變成閱讀器會略過的工件、圖片會變成圖形,而 DrawTableRows 會把表格、列與儲存格帶進結構樹

另一種做法——直到不久前還是唯一選項——是用手把每個繪圖呼叫包進 BeginTagEndTag 裡。這行得通,對於結構不尋常的文件,它依然是正確的工具。但對於一份普通的報告、發票或對帳單,這意味著輸出的可存取性,取決於沒有人會在任何一條繪製東西的程式路徑上忘記配對

模式位元涵蓋了什麼

SetAutoTagMode 接受一個位元遮罩,並回傳先前生效的模式。AUTOTAG_TEXT (1) 把文字標記為段落,或到期時標記為標題。AUTOTAG_FURNITURE (2) 把重複出現的頁首、頁尾與頁碼標記為工件。AUTOTAG_FIGURE (4) 把繪製的圖片變成圖形,或在它被宣告為裝飾性時變成工件。AUTOTAG_TABLE (8) 把繪製的表格帶進結構樹。AUTOTAG_DEFAULT 是 15,也就是四者全開

開啟這個模式的同時,也會把文件標記為 tagged,而這個步驟不如聽起來那麼可有可無。除非目錄另有宣告(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 命名有多少前導列是標題列;這些列會被寫成帶有欄範圍的標題儲存格,這正是讓閱讀器能朗讀出使用者目前所在值的標題的原因

用這種方式命名的標題列會留在原處。在每一頁頂端重複它們是一個版面決定,而且它維持是版面決定: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 是一套原生的 Pascal PDF 函式庫,支援 Delphi、C++Builder 與 Lazarus,沒有任何外部 PDF 執行階段,所以可存取的輸出是由繪製文件的同一份程式碼所產生——完整的 API 與平台清單請見 PDFlibPas 產品頁