技術文章

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

把一份程式產生的報告丟給螢幕閱讀器,常見的失敗一分鐘內就現形:每一頁都朗讀「第 3 頁、共 40 頁」,公司商標被唸成「圖形」然後沒了下文,表格則變成一長串沒有欄名的數字。PDF Library for Delphi 用自動結構標記避開這三件事:呼叫一次 SetAutoTagMode,一般的繪圖呼叫就會在繪製文件的過程中順手產出段落、標題、圖形、表格,以及可被略過的頁面裝飾

手動路線還在。您可以把每個繪圖呼叫包進 BeginTag 與 EndTag,對結構不尋常的文件,這仍是正確的工具。但對普通的報告、發票或對帳單來說,手工標記等於把可存取性押在「產品壽命內、每一條會畫東西的程式路徑上,都沒有人忘記任何一對標記」。合規要求我們不會押在這種事上

SetAutoTagMode 開啟了什麼

SetAutoTagMode 接受一個位元遮罩,回傳呼叫前生效的模式。每個位元對應一種內容:

  • AUTOTAG_TEXT (1):文字成為段落,或在前一個層級被註冊過時成為標題
  • AUTOTAG_FURNITURE (2):重複的頁首、頁尾與頁碼成為閱讀器會略過的工件
  • AUTOTAG_FIGURE (4):繪製的圖片成為圖形,或在您宣告它為裝飾性時成為工件
  • AUTOTAG_TABLE (8):繪製的表格把它的列與儲存格帶進結構樹
  • AUTOTAG_DEFAULT (15):四者全開,也是我們建議任何新報表程式碼使用的值

開啟這個模式的同時,也會把文件標記為已加標記,這一步比看起來更重要。按 ISO 32000-1 §14.7.1,除非目錄的 /MarkInfo 字典寫著 /Marked true,閱讀器會把檔案當成未加標記。一份檔案可以帶著完整、正確的結構樹,卻仍被宣告為完全沒有結構,因為告訴輔助科技去找結構的那一面旗標缺了。樹就在那裡。沒有東西讀它

文字標記以呼叫為單位。每個 DrawText、DrawWrappedText 或 DrawTextBox 呼叫各自成為一個元素,所以用五個 DrawText 逐行畫出來的段落,到閱讀器那裡就是五個單行段落。如果您的版面程式碼本來就自己斷行,這點值得改:把整段交給 DrawWrappedText,讓一次呼叫產生一個段落

SetAutoTagMode 位元遮罩圖解:把一般的 Delphi 繪圖呼叫映射到加標籤的 PDF 結構樹,並標註 Marked 旗標為必要
SetAutoTagMode 的每個啟用位元會把一般繪圖呼叫導向結構樹中對應的元素
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // 左上原點,y 向下增長
    Lib.SetDocumentLanguage('en-US');
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);       // 在任何 RegisterHeading 呼叫之前
    Lib.AddStandardFont(4);

    Lib.SetTextSize(18);
    Lib.RegisterHeading(1, 'Annual service report');
    Lib.DrawText(72, 96, 'Annual service report');        // 標記為 H1

    Lib.SetTextSize(11);
    Lib.DrawWrappedText(72, 130, 450,
      'Every unit installed before 2024 was inspected. ' +
      'Units that failed the torque check were serviced on site.');  // 一個 P

    // ... 其餘頁面 ...

    // 會跑過每個既有頁面,等頁面畫完再呼叫
    Lib.AddPageFooter('', 'Page &P of &N', 36, 1.0, 1);  // 分頁工件
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

第三行的註解不是白寫的。RegisterHeading 只在文字標記已經開啟時才把標題層級排進佇列,而 SetAutoTagMode 執行時會清掉任何已排隊的層級。先註冊標題、後開模式,標題就會以普通段落的身分出來,而且任何地方都不會有錯誤告訴您這件事

標題為什麼會等過中間繪製的圖片?

RegisterHeading 為下一份文字命名層級,而且只有文字能消耗它。中間若畫了一張圖片,圖片成為圖形,標題繼續排隊等後面的文字。直覺的實作會讓任何標記消耗排隊的層級,於是「註冊小節標題、畫一張橫幅圖、再畫標題文字」這個常見順序,會產出被標成 H2 的橫幅圖,和被降級成段落的真標題。靠標題導覽的閱讀者,就此降落在一張圖片上

佇列的運作方式還帶出兩件事。標題層級會附在接下來出現的文字上,無論您傳給 RegisterHeading 的字串是什麼:如果版面在標題上方畫了一個小小的「第 3 節」眉題,眉題就成為標題。而超過六的層級會寫成 H6,閱讀器不認得更深的東西。Title 引數仍照常做它自己的工作,餵給 DrawTOC 稍後轉成目錄的大綱

圖片也適用同一條「一份宣告只花在一個對象」的規則。RegisterFigure 提供下一張圖片要帶的描述,RegisterDecoration 把下一張圖片宣告為不帶意義的橫線、邊框或背景,之後以 Layout 工件寫出(ISO 32000-1 §14.8.2.2)。每個宣告都被剛好一張圖片消耗,所以較晚的圖片永遠不會繼承較早圖片的描述。替代文字附錯圖片比沒有更糟,因為閱讀器會滿懷信心地陳述一件錯的事

我們刻意不去猜哪些圖片是裝飾性的。一張圖片要嘛是閱讀器該描述的內容,要嘛是它該跳過的內容,只有呼叫端程式碼知道答案是哪個。該避免的是第三種情況:兩種宣告都沒有的圖片會成為沒有描述的圖形,過不了 PDF/UA,還會讓螢幕閱讀器停下來說「圖形」然後沒了下文。在報表程式碼裡 grep DrawImage,每一個命中上方都該有一個 RegisterFigure 或 RegisterDecoration

PDF Library for Delphi 圖解:已註冊的 PDF 標題因中間插入的圖片而等待,而 RegisterFigure 與 RegisterDecoration 各自只能花在單一圖片上
RegisterHeading 會等待下一段文字,無論期間還繪製了什麼
Lib.RegisterHeading(2, 'Gearbox inspection');   // 排隊等下一段文字

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);       // 載入並選取圖片
Lib.DrawImage(72, 160, 360, 270);             // 成為加標記的圖形

Lib.RegisterDecoration;                       // 無意義的橫線
Lib.AddImageFromFile('divider.png', 0);
Lib.DrawImage(72, 450, 360, 4);               // 畫在版面工件裡

Lib.DrawText(72, 480, 'Gearbox inspection');  // 直到這裡 H2 才出現

描述值得比文件裡任何其他字串更多的用心。視障讀者拿到的是它,用來取代圖片,而且這就是全部。「圖表」對他們毫無意義;「各區域的季度營收,東區在第三季最高」則說出了明眼讀者兩秒鐘就從圖片上帶走的資訊

表格:標題列靠宣告,不靠猜

表格位元開啟時,DrawTableRows 會把表格、它的列與儲存格寫進結構樹,閱讀器因此說得出某個值位於哪一欄,而不是把整個網格當成不相干的文字朗讀。SetTableHeaderRowCount 說明有多少前導列是標題。這些列會寫成帶欄範圍的 TH 儲存格,閱讀器因此能在使用者移到的值之前,先唸出「Torque」

標題列數預設為零,這是刻意選擇。表格第一列通常是標題,猜測多半會對。但猜錯的代價不對稱:一列資料被誤當成標題,它會被朗讀在該欄每一個值的前面,這比完全沒有標題的表格困惑得多。您多寫一行程式碼,比那種失敗模式便宜

宣告標題列與在每頁重複它們,是兩個獨立的決定。SetTableHeaderRowCount 是語意層面的,頁面上什麼都不變。重複是版面,屬於 DrawTaggedTableRows 的 RepeatHeaderRows 引數。若兩者合而為一,要求正確的標題標記就會順手畫出您沒要求的重複標題帶,而一份逐頁檢查過的版面就會位移。傳給 RepeatHeaderRows 的列也會被標記為標題,所以同一件事不必說兩次

PDF Library for Delphi 圖解:表格表頭儲存格在結構樹中帶有欄範圍,而 RepeatHeaderRows 讓跨頁重複表頭純粹是版面層面的決定
表頭列在結構樹中只登錄一次,欄範圍附加在每個 TH 儲存格上
var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // 第 1 列是標頭帶
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... 填入資料列 ...
  // 將第 1..40 列繪製到 600pt 區帶中,每頁重複一列標頭
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

自動文件裡的手動標記段落

只要您用手開的標記還開著,自動標記就會退到一邊。這讓程式碼可以自己描述文件的一部分,其餘交給函式庫,兩者互不巢狀——大多數真實文件要的正是這種安排。封面頁與簽名區塊的結構只有您懂;中間那兩百頁內文的結構不是

不管怎麼混用,三條規則讓輸出保持有效。工件裡面不標記任何東西,因為工件的標記內容序列無法再承載結構元素,硬要這麼做的檔案過不了 PDF/UA 驗證。空文字不開元素,所以帶空字串的零星 DrawText 不會留下被閱讀器朗讀為空白的元素。而內部會透過其他繪圖呼叫完成工作的函式庫呼叫,只在外層開一個標記,它的各個組成部分不會再被巢進自己衍生的第二個元素裡

存檔之後、信任之前,先把樹讀回來看一眼。讀取 API 是 1 起算的,這點把我們絆過:第一版檢查從 0 迴圈到 StructTreeItemCount - 1,開頭拿到空記錄、結尾從沒碰到最後一個元素,有一陣子我們還堅信某個段落憑空消失了。什麼都沒少。只是迴圈兩頭都差了一

var
  I: Integer;
begin
  // 手動標記的引文;自動標記不插手
  Lib.BeginTag('BlockQuote', '', '');
  Lib.DrawWrappedText(72, 520, 450,
    'All readings were taken with the unit at operating temperature.');
  Lib.EndTag;
  Lib.SaveToFile('report.pdf');

  // 把樹讀回來:索引從 1 到 StructTreeItemCount
  if Lib.LoadFromFile('report.pdf', '') = 1 then
    for I := 1 to Lib.StructTreeItemCount do
      Writeln(Lib.GetStructTreeItemText(I, 2), '  ',    // 解析後的型別
              Lib.GetStructTreeItemText(I, 4));         // /Alt,若有的話
end;

這樣一份清單是快速的健康檢查,不是判決。想看到的是:第一個段落之前有 H1、每個圖形都帶著描述、沒有任何圖形的 /Alt 是空的

當繪製順序與閱讀順序分道揚鑣

自動標記按內容繪製的順序記錄元素。如果版面程式碼先畫側邊欄再畫內文,樹記的就是這個順序,螢幕閱讀器唸的也是這個順序。視覺順序與閱讀順序確實不同的文件、或需要其他結構型別與逐元素語言的文件,手動標記 API 更合適,標記 PDF 與可存取性結構的逐步解說詳細涵蓋角色、範圍與標題繫結

對不是自己畫的頁面,例如沒有標記內容的匯入頁,AutoTagPages 可以事後補標記:它把給定頁面的文字大小中位數當成內文,明顯更大的文字串當成標題,其他繪製內容標為工件。對無法重新產生的內容,這是正確的工具。凡是自己程式碼畫的東西,我們偏好繪製當下標記,因為那樣函式庫是被明確告知什麼是標題,而不是從字型大小去猜

文件完成後,跑一遍檢查工具,別靠假設:PDF/A 與 PDF/UA 預檢的筆記示範如何對您產出的結構取得判定,資料集驅動報表匯出一文則說明這些呼叫在一份從資料建構版面的報表引擎裡該放在哪

PDF Library for Delphi 以原生 Pascal 寫成,面向 Delphi、C++Builder 與 Lazarus,不必隨附任何外部 PDF 執行階段,加標記的輸出就來自畫頁面的同一份程式碼。完整 API 參考與平台清單見 PDF Library for Delphi 產品頁