一份無障礙的 PDF 靠的是一個可見頁面從不顯示的結構:ISO 32000-1 §14.7 所定義的結構樹。它是一套由標題、段落、表格與圖片組成的邏輯階層,疊在已繪製的內容之上,並透過角色對映表對映到標準角色。螢幕閱讀器讀的是那棵樹,不是頁面上的墨跡。少了它,一張看起來無懈可擊的產生式發票在語意上是空的,因為內容串流記錄的只有繪製順序,別無其他。總金額可能在明細項目之前被念出來,頁尾可能切進某個段落中間,明細表格可能塌縮成一長串沒有區隔的字。防止這件事的代價,對您這一邊有利到不成比例。邊繪製邊發出結構是幾分鐘的程式碼;把結構補進已完成的文件則是一個矯正專案。losLab PDF Library(PDF Library for Delphi)透過一小組把每次繪製操作包進其邏輯角色的呼叫,把這棵樹公開給 Delphi 與 C++Builder
標記內容如何綁到結構樹
有兩層在合作。在內容串流裡,繪製操作被括進標記內容序列,每一段各帶一個整數 MCID。在文件目錄裡,結構樹把那些 MCID 對映成一套有型別的元素階層(H1、P、Table、Figure),並帶有替代文字與語言之類的屬性。自訂元素型別是合法的,但每一個都必須透過角色對映表解析到某個標準角色(ISO 32000-1 §14.8.4)。完全不承載意義的內容,像是線條、背景與重複出現的頁面裝飾,會被標記成裝飾內容,好讓輔助科技跳過它,而不是在句子中間把它念出來
PDF Library for Delphi 用一對括號在背後同時維護這兩層。BeginTag 開啟一個結構元素並開始標記內容序列,繪製呼叫落在它裡面,然後 EndTag 把兩者都關上。那些絆倒手工標籤化的記帳工作,也就是 MCID、父節點樹與頁面參照,都在內部發生,在那裡您弄不錯
有兩個文件層級的開關,在任何標籤開啟之前就框住了整件事。SetMarkInfo 寫出宣告這份文件已加標籤的目錄旗標,而 IsTaggedPDF 把它讀回來,這是在判斷一份進來的檔案有沒有值得保留的結構時,最便宜的第一道探測。語言有兩個入口。SetDocumentLanguage 單獨設定文件預設語言,而 SetPDFUAMode 則在啟用完整 PDF/UA 輸出的同時把它設好。一份檔案可以在不宣稱 PDF/UA 合規的情況下有用地加上標籤,而分階段的推行常常正是從那裡起步
邊繪製邊標籤,不是事後補
行得通的產生模式,是把標籤括號當成每一次繪製呼叫簽章的一部分,絕不當成之後的一趟掃描:
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
Lib.SetOrigin(1); // 左上角原點
Lib.SetPDFUAMode('en-US'); // 會把存檔版本拉到 PDF 1.7
Lib.SetInformation(1, 'Service Manual'); // PDF/UA 強制要求 /Title
Lib.AddRoleMap('ManualTitle', 'H1'); // 自訂型別 -> 標準角色
Lib.AddStandardFont(4);
Lib.SetTextSize(18);
Lib.BeginTagEx2('ManualTitle', '', '', 'en-US', '', 'h1-cover', '');
Lib.DrawText(72, 96, 'Service Manual');
Lib.EndTag;
Lib.BeginTag('Figure', 'Exploded view of the gearbox assembly', '');
Lib.AddImageFromFile('gearbox.png', 0);
Lib.EndTag;
Lib.BeginArtifact('Layout'); // 頁面裝飾:排除在閱讀之外
// ... 在這裡繪製線條與背景底色 ...
Lib.EndArtifact;
Lib.SaveToFile('manual.pdf');
finally
Lib.Free;
end;
end;
那段序列裡有三個呼叫扛著合規的份量。SetPDFUAMode 啟用 PDF/UA 輸出,並悄悄把文件版本拉到 PDF 1.7,這會跟版本鎖定相撞。一份用 LockSaveVersion 鎖在 PDF 1.4 的文件,在 UA 模式生效之後會拒絕存檔並傳回錯誤碼 602,而這種衝突往往在封存設定檔與無障礙需求由不同團隊設定時才浮上檯面。SetInformation(1, ...) 寫入文件標題,ISO 14289 期待檢視器拿它取代檔名來顯示;標題缺席是實務上最常見的 PDF/UA 檢出項之一。AddRoleMap 把自訂的 ManualTitle 型別註冊成 H1,略過它就會讓下面所述的診斷標出一個未對映的角色
標題層級值得一項刻意的政策,而不是為了某一頁好不好看而臨時決定。螢幕閱讀器的使用者靠標題捷徑在章節之間跳躍,所以一份因為中間層級在視覺設計上看起來太大、就從 H1 直接跳到 H3 的範本,會悄悄弄壞那套瀏覽方式,而且任何視覺審查都永遠抓不到。這正是 HEADING-LEVEL-SKIP 這條診斷存在的意義。把每份範本的視覺樣式一次性、在同一個地方對映到一組固定的標題階梯,漂移就永遠不會開始
螢幕閱讀器真的走得動的表格
畫出來的格線離開螢幕就什麼都不是。螢幕閱讀器走的是結構關係:哪些儲存格是標頭、每個標頭管轄什麼,以及在不規則版面裡資料儲存格如何繫到標頭。結構元素的屬性呼叫這三件事全包:
Lib.BeginTag('Table', '', '');
Lib.BeginTag('TR', '', '');
Lib.BeginTagEx2('TH', '', '', '', '', 'col-part', '');
Lib.SetStructElemScope('Column'); // 只在這個 TH 開著時有效
Lib.DrawText(72, 120, 'Part');
Lib.EndTag;
Lib.BeginTagEx2('TH', '', '', '', '', 'col-torque', '');
Lib.SetStructElemScope('Column');
Lib.SetStructElemColSpan(2); // 標頭橫跨數值欄與單位欄
Lib.DrawText(200, 120, 'Tightening torque');
Lib.EndTag;
Lib.EndTag;
Lib.BeginTag('TR', '', '');
Lib.BeginTag('TD', '', '');
Lib.SetStructElemHeaders('col-part'); // 為不規則表格做明確繫結
Lib.DrawText(72, 140, 'M8 flange bolt');
Lib.EndTag;
Lib.EndTag;
Lib.EndTag; // Table
順序規則很嚴格,而且是在沉默中被強制執行。每一個 SetStructElem* 呼叫都套用到當下開著的那個標籤,也就是介於它的 BeginTag 與 EndTag 之間,而當沒有標籤開著、或該屬性不適用於目前這個標籤時,它會傳回 0 而不拋出任何東西。一個擺錯位置的呼叫就這麼人間蒸發。開發期間把傳回值包進斷言裡,能在您還看得見的時候抓到漂移;放著不管,一個漏掉的 scope 只會在無障礙稽核拿真正的螢幕閱讀器走過那張表格時才現形。經由 BeginTagEx2 傳入的元素 ID 餵養的是 ID 樹(ISO 32000-1 §14.7.4),而那也正是 SetStructElemHeaders 繫結之所以解析得出來的前提
同一族屬性涵蓋了輔助科技所倚賴的其餘部分。SetStructElemListNumbering 宣告清單項目如何標號,於是螢幕閱讀器會念出它在清單中的位置,而不是逐一背誦項目符號字形。SetStructElemBBox 記錄圖片與表格的邊界框,重排檢視會拿它來擺放內容。SetStructElemActualText 為那些字形對映不到可讀字元的區段提供替代文字,比方說一個由向量圖組出來的首字放大字。每一個都遵循同一條規則:它繫到開著的那個標籤,否則就消失
裝飾內容、語言,以及存檔前的診斷關卡
重複出現的頁面裝飾,也就是頁首標題行、摺線標記、浮水印與背景底色,屬於 BeginArtifact 與 EndArtifact 這對括號之內,好讓它永遠不進入閱讀串流。語言是可繼承的。文件預設值來自 SetPDFUAMode 的引數,而另一種語言的區段則透過 BeginTagEx 或 SetStructElemLang 逐元素覆寫它。這就是讓一份英文手冊裡的法文引文仍念得出來的原因
存檔之前,GetPDFUADiagnostics 會對記憶體中的文件跑一遍函式庫的結構檢查,並以文字傳回檢出項,空字串代表什麼都沒查到。這些代碼直接點名經典的撰寫錯誤:FIGURE-NO-ALT 代表一張沒有替代文字的影像,HEADING-LEVEL-SKIP 代表 H1 之後接了 H3,ROLEMAP-UNMAPPED 代表一個從未註冊過的自訂型別。把這件事接進建置(產生整組文件,診斷非空就讓該步驟失敗),無障礙迴歸就從幾個月後的稽核檢出項,變成類似編譯期的失敗。完整的合規裁決仍然屬於對已存檔案所做的預檢,涵蓋於 Delphi 中的 PDF/A 與 PDF/UA 預檢,因為有些正規化只在序列化期間才套用
註釋的瀏覽有它自己的旋鈕。PDF/UA 期待表單欄位與連結的鍵盤走訪順序跟隨結構順序,而 SetTabOrderMode 會寫出檢視器會遵循的頁面層級定位順序項目,另有 GetTabOrderMode 可用來稽核進來的檔案。這是那種在只用鍵盤的使用者提出臭蟲之前沒人會注意到的需求,而把它做對,一份文件只要一次呼叫
結構樹不是每一次合併都活得下來
已加標籤的文件,只有在之後每一道處理步驟都保住那棵樹時才會一直有標籤,而 PDF Library for Delphi 裡的那把利刃就是合併清單家族。MergeFileListFast 拿結構樹的保留去換速度。對掃描影像批次而言那是對的交易,對已加標籤的報表而言就是錯的,因為輸出開得起來、算繪得一模一樣,卻悄悄丟掉了它的無障礙層。只要有任何一個輸入檔已加標籤,就用預設的 MergeFileList 或嚴格變體,並把 IsTaggedPDF 納入組版後的斷言,這樣一批被壓平的文件就無法在沒人察覺的情況下出貨。大型文件集的組版管線還帶著更多這一類取捨,在 大型 PDF 的合併、分割與直接存取裡有所探討
驗證的迴圈在函式庫之外閉合:在 Acrobat 裡開啟輸出、檢視標籤面板,並且每個範本家族至少用真正的螢幕閱讀器讀過一份文件。診斷抓得到結構錯誤;只有人的耳朵抓得到一個技術上有效、實務上卻莫名其妙的閱讀順序。評估版建置與完整的標籤化 API 參考在 losLab PDF Library for Delphi 產品頁