在標記 PDF 裡,每一頁頂端的頁首通常落在 /Artifact <</Type /Pagination>> BDC … EMC 之中,而它底下的第一個段落則落在 /P <</MCID 0>> BDC … EMC 之中。兩者就 ISO 32000-1 §14.6 的意義而言都是標記內容,但只有段落能從結構樹觸及,因為只有段落帶著 marked-content identifier,而正是這一個整數,把實際繪出的字形綁到 /H1 或 /P 這類結構元素上。因此,要稽核或修復標籤的程式碼,得從每個頁面物件讀出三樣東西(標籤、它的屬性表與 MCID),再把這個數字拿去對照該頁結構元素實際宣稱的 MCID
從 Delphi 讀取 BDC 標記與屬性表
在 PDFium Component 裡,TPdf.PageObjectMarks(Index) 會把包住 Index 所指頁面物件的每一個標記,以 TPdfContentMark 記錄陣列的形式回傳。每筆記錄在 Name 帶著標籤、在 Params 陣列帶著屬性表,另外還有一個 Handle。名稱回傳時不含開頭的斜線,所以 /P 序列回報為 P,artifact 則回報為 Artifact。標記內容序列可以巢狀,一段先後落在 /Sect、/P、/Span 之內的字形,在同一個物件上會回報三筆記錄,最外層在前
ISO 32000-1 §14.6.2 允許 BDC 的屬性表運算元採用兩種形式:一是如 /P <</MCID 3>> BDC 的行內字典,二是如 /OC /oc1 BDC 這種指向頁面資源 /Properties 子字典的名稱。optional content(§8.11.3.2)採用第二種形式。PDFium 在剖析頁面時就會把名稱形式解析掉,所以兩種情況下 Params 列出的都是作用於該序列的那份字典的條目
TPdf.PageObjectMarkedContentID(Index) 回答的是結構樹真正關心的那個問題。它回傳物件標記中找到的 MCID,若沒有任何標記帶整數 /MCID 則回傳 -1。開頭範例裡的頁首回傳 -1,而這是正確的:§14.8.2.2 把 artifact 排除在邏輯結構之外,所以 artifact 本來就沒有東西可連
var
Marks: TPdfContentMarks;
M: TPdfContentMark;
P: TPdfContentMarkParam;
I: Integer;
begin
Pdf.PageNumber := 1; // PageNumber 以 1 為基底
for I := 0 to Pdf.ObjectCount - 1 do // 頁面物件索引以 0 為基底
begin
Marks := Pdf.PageObjectMarks(I);
if Length(Marks) = 0 then
Continue;
Memo1.Lines.Add(Format('object %d, MCID %d',
[I, Pdf.PageObjectMarkedContentID(I)]));
for M in Marks do // 最外層標記在前
begin
Memo1.Lines.Add(' mark ' + M.Name);
for P in M.Params do
case P.Kind of
pmpInt: Memo1.Lines.Add(' ' + P.Key + ' = ' + IntToStr(P.IntValue));
pmpFloat: Memo1.Lines.Add(' ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
pmpString: Memo1.Lines.Add(' ' + P.Key + ' = ' + P.StringValue);
pmpBlob: Memo1.Lines.Add(' ' + P.Key + ' = ' +
IntToStr(Length(P.BlobValue)) + ' bytes');
pmpUnknown: Memo1.Lines.Add(' ' + P.Key + ' = (no readable value)');
end;
end;
end;
end;
pmpUnknown 能告訴你什麼,不能告訴你什麼
每個 TPdfContentMarkParam 帶有一個 Key、一個 Kind,以及一個由該 kind 決定的值欄位。這組對應關係短到可以直接記在腦子裡:
- 整數值的數字會變成
pmpInt,IntValue有值,FloatValue也填入同一個數,所以/MCID 3和不小心出現的3.0都會讀成整數 - 帶小數部分的數字會變成
pmpFloat - PDF 字串與 PDF 名稱都變成
pmpString;以 UTF-16 位元組順序記號(FE FF或FF FE)開頭的文字字串會解碼進StringValue,所以/ActualText會以可讀文字送達 - 其餘值型別在 PDFium 交出原始位元組時回報為
pmpBlob,否則維持pmpUnknown
pmpUnknown 涵蓋兩種無法區分的情況。一是 PDFium 回報為 FPDF_OBJECT_UNKNOWN、連一個位元組都不交出來的值;二是列在清單上卻讀不回來的鍵:受損檔案裡以非 ASCII 位元組拼寫的鍵可以出現在清單上,讀回來仍是 pmpUnknown。兩種情況下記錄長得完全一樣
把 pmpUnknown 當作「沒有可用值」,回報這個標記而不是默默接受它。如果你的工作流程依賴 /Lang 或 /ActualText 存在,請用你認得的 kind 去找那個鍵;永遠不要從 unknown kind 推論出屬性不存在
Handle 標識的是目前載入頁面裡的標記,一旦標記被移除、頁面物件被摧毀或頁面被卸載,它就失效。切換頁面前,先把 Name 與 Params 複製進你自己的結構。需要活得比目前頁面更久的資料,例如清單控制項或背景稽核,ReadPageObjectsSnapshot 回傳的 TPdfContentMarkSnapshot 記錄只保存名稱與參數,完全不含 handle。同樣的規則對頁面物件 handle 一體適用,頁面物件 handle 在轉換後失效的筆記有完整說明
新增、更新與移除標記
編輯工作由四個呼叫涵蓋。AddPageObjectMark(Index, Name, Params) 在物件上建立新標記,依序套用參數,並回傳新標記在該物件上的索引,其餘三個呼叫都以 MarkIndex 接收它。名稱傳入時不帶斜線:'P'、'Span'。要改單一鍵,SetPageObjectMarkParam(Index, MarkIndex, Param) 會插入或取代它,標記上的其他參數一概不動。移除就直接多了:RemovePageObjectMarkParam(Index, MarkIndex, Key) 刪掉一個鍵;RemovePageObjectMark(Index, MarkIndex) 刪掉整個標記
AddPageObjectMark 把標記與其參數當成單一步驟處理。若有參數被拒絕(pmpUnknown kind、空鍵、空 blob),剛建立的標記會在例外傳出前被再次移除,所以物件上不會留下一個屬性表只剩一半的標籤
這些呼叫每一個都會在回傳前重建頁面內容串流。這一點很重要,因為 SaveAs 是照內容串流當下的樣子寫出,不會從 PDFium 的物件模型重新產生它。只存在於記憶體物件上的標記,會讓存出的檔案裡連一個 BDC 運算子都沒有。如果你也透過 ObjectHandle 上的原始 PDFium 匯入函式編輯頁面物件,儲存前請自己呼叫 UpdatePage
每次呼叫都重建整條串流,對少數幾次編輯沒問題,對幾百次就是浪費。逐頁替一份未標籤的報告加標籤屬於後一種工作,這時編輯就該放進 BeginPageObjectEditTransaction 與 CommitPageObjectEditTransaction 之間。交易進行中,每次編輯只把頁面標記為已變更;commit 時才產生一次內容串流,而 RollbackPageObjectEditTransaction 會把頁面放回原樣。交易綁定單一頁面,交易開著時把 PageNumber 設到另一頁會直接 raise。對已簽署的文件,不傳 AllowSignedDocument = True 的話 begin 呼叫會拒絕啟動,反正編輯頁面內容本來就會破壞簽章
字串參數還需要一個決定。pmpString 的值以位元組字串寫入,對 /Lang (en-US) 這類 ASCII 值來說恰到好處。帶重音符號、連字或 CJK 字元的替代文字,則應放進以 UTF-16BE 編碼、帶 FE FF 前置的 PDF 文字字串(§7.9.2.2)。這些位元組請用 pmpBlob 傳入:PDFium 會把它們存成字串物件,上面的讀取端再把它們解碼回 pmpString 的 StringValue
// 你自己的輔助函式,不是函式庫的一部分:以 UTF-16BE 加 BOM 的 PDF 文字字串
function Utf16TextString(const S: string): TBytes;
var
I: Integer;
begin
SetLength(Result, 2 + Length(S) * 2);
Result[0] := $FE;
Result[1] := $FF;
for I := 1 to Length(S) do
begin
Result[I * 2] := Byte(Ord(S[I]) shr 8);
Result[I * 2 + 1] := Byte(Ord(S[I]));
end;
end;
procedure TagWord(Pdf: TPdf; ObjectIndex, Mcid: Integer; const Spoken: string);
var
Params: TPdfContentMarkParams;
Lang: TPdfContentMarkParam;
MarkIndex: Integer;
begin
Pdf.BeginPageObjectEditTransaction;
try
SetLength(Params, 2);
Params[0].Key := 'MCID';
Params[0].Kind := pmpInt;
Params[0].IntValue := Mcid;
Params[1].Key := 'ActualText';
Params[1].Kind := pmpBlob; // 以文字字串儲存,讀回來是 pmpString
Params[1].BlobValue := Utf16TextString(Spoken);
MarkIndex := Pdf.AddPageObjectMark(ObjectIndex, 'Span', Params);
Lang := Default(TPdfContentMarkParam);
Lang.Key := 'Lang';
Lang.Kind := pmpString; // ASCII 值,用純位元組字串即可
Lang.StringValue := 'de-DE';
Pdf.SetPageObjectMarkParam(ObjectIndex, MarkIndex, Lang);
except
Pdf.RollbackPageObjectEditTransaction;
raise;
end;
Pdf.CommitPageObjectEditTransaction; // 整批只重建一次內容串流
end;
commit 刻意放在 try 區塊之外。若內容產生失敗,CommitPageObjectEditTransaction 會先自行還原頁面再重新 raise,這時再呼叫一次 rollback 只會蓋掉真正的錯誤,raise 出「No page-object edit transaction is active」
為什麼一個段落會冒出三個 MCID 5 序列?
因為每次 AddPageObjectMark 呼叫都會建立一個全新、獨立的標記字典,即使值完全相同。一個段落若排版成三個文字物件(每行一個),用三次各傳 /MCID 5 的呼叫加上標籤,就帶著三個互不相干的標記。PDFium 重新產生內容串流時,只有在連續物件共用同一個標記時,才會把它們留在同一個 BDC/EMC 對裡;它比的是標記的身分,值相等不算數。存出的頁面於是連續出現三段 /P <</MCID 5>> BDC … EMC
ISO 32000-1 §14.7.4.2 把 MCID 定義為在其所屬內容串流內唯一識別一個標記內容序列。三個序列共用一個識別碼並不符合這一點,而透過頁面的 parent tree 條目解析 MCID 的工具,只能去猜結構元素指的到底是三個中的哪一個
SharePageObjectMark(TargetIndex, SourceIndex, SourceMarkIndex) 把來源標記自己的屬性字典掛到目標物件上,而不是另建副本。第一行加上標記一次,其餘行共用同一個,並讓這些物件在繪製順序上連續;重新產生的串流就會把三行包進同一個序列
procedure TagParagraph(Pdf: TPdf; const LineObjects: array of Integer; Mcid: Integer);
// LineObjects:段落各行的物件索引,在繪製順序上連續
var
Params: TPdfContentMarkParams;
MarkIndex, I: Integer;
begin
SetLength(Params, 1);
Params[0].Key := 'MCID';
Params[0].Kind := pmpInt;
Params[0].IntValue := Mcid;
Pdf.BeginPageObjectEditTransaction;
try
MarkIndex := Pdf.AddPageObjectMark(LineObjects[0], 'P', Params);
for I := 1 to High(LineObjects) do
Pdf.SharePageObjectMark(LineObjects[I], LineObjects[0], MarkIndex);
except
Pdf.RollbackPageObjectEditTransaction;
raise;
end;
Pdf.CommitPageObjectEditTransaction;
end;
當你把既有標記複製到新內容上,例如把 artifact 延伸到重繪的頁尾線上,共用還有第二個好處。PDFium 寫得出的參數 kind 只有整數、浮點數、字串與位元組 blob,沒有名稱 kind。把 /Artifact <</Type /Pagination>> 讀成 pmpString、再經 AddPageObjectMark 重建,出來會變成字串 (Pagination),而 §14.8.2.2 要的是名稱 /Pagination。共用的標記就是原始字典,名稱仍是名稱。只要來源標記已經存在頁面上,就共用它;只有要寫入一個從未存在過的標記時,才從 Params 重建
把 MCID 對照結構樹
頁面物件上的 MCID 只有在同一頁的結構元素宣稱它時才有意義。StructureElementsForPage[PageNumber] 以 TPdfStructureElement 記錄回傳該頁的元素,其中三個欄位帶 MCID:MarkedContentID(元素沒有時為 -1)、MarkedContentIDs(給擁有多個序列的元素用),以及 ChildMarkedContentIDs(子項是元素而非標記內容的槽位填 -1)。稽核三個都得看,因為結構元素的 /K 條目可以在同一個陣列裡混雜子元素與標記內容參照
下面的稽核會回報兩種發現。MCID 沒有任何元素宣稱、或根本沒有 MCID 的文字與圖片,是輔助技術會跳過的內容;而 MCID 被人宣稱的 Artifact 則直接牴觸 §14.8.2.2。頁面物件取自 ReadPageObjectsSnapshot,所以結果裡沒有任何 handle,切換頁面也不受影響
// 你自己的輔助函式,不是函式庫的一部分
function McidClaimed(const Elements: TPdfStructureElements; Mcid: Integer): Boolean;
var
E: TPdfStructureElement;
Id: Integer;
begin
for E in Elements do
begin
if E.MarkedContentID = Mcid then
Exit(True);
for Id in E.MarkedContentIDs do
if Id = Mcid then
Exit(True);
for Id in E.ChildMarkedContentIDs do
if Id = Mcid then
Exit(True);
end;
Result := False;
end;
procedure AuditPage(Pdf: TPdf; PageNo: Integer; Log: TStrings);
var
Snap: TPdfPageObjectsSnapshot;
Status: TPdfPageObjectSnapshotStatus;
Elements: TPdfStructureElements;
Obj: TPdfPageObjectSnapshot;
IsArtifact: Boolean;
J: Integer;
begin
Snap := Pdf.ReadPageObjectsSnapshot(PageNo,
TPdfPageObjectSnapshotOptions.Full, Status);
if not Status.Completed then
Log.Add(Format('page %d: snapshot hit a limit, results are partial', [PageNo]));
Elements := Pdf.StructureElementsForPage[PageNo];
for Obj in Snap.Objects do
begin
IsArtifact := False;
for J := 0 to High(Obj.Marks) do
if Obj.Marks[J].Name = 'Artifact' then
IsArtifact := True;
if IsArtifact then
begin
if (Obj.MarkedContentID >= 0) and McidClaimed(Elements, Obj.MarkedContentID) then
Log.Add(Format('object %d: artifact claimed by the structure tree', [Obj.Index]));
end
else if Obj.ObjectType in [otText, otImage] then
if (Obj.MarkedContentID < 0) or not McidClaimed(Elements, Obj.MarkedContentID) then
Log.Add(Format('object %d: content with no structure owner', [Obj.Index]));
end;
end;
路徑類物件是刻意不納入的。裝飾線與表格框線往往沒有標籤,把它們一一標出來只會淹沒文字方面的發現。Form XObject 反而要反向留意:form 內部的 MCID 屬於該 form 自己的 /StructParents 命名空間,所以頁面上的 MCID 0 與 form 內的 MCID 0 是不同序列。標記 Form XObject 中的 MCID 命名空間逐步解說涵蓋如何正確讀取它們
光靠標記,仍只算半份標記文件。ISO 32000-1 §14.7 要求一棵元素參照這些 MCID 的結構樹、catalog 裡帶 /Marked true 的 /MarkInfo 字典,以及標準的結構類型,或經 role map 對應到標準類型的結構類型。Pdf.IsTagged 會告訴你 catalog 是否宣告文件已標記。一個 MCID 沒有任何元素指向的 /P 標記,產生出來的是宣稱已標記、卻出現在結構樹從未提及的頁面上的內容
這項工作的結構樹那一半,PDF/UA 結構樹驗證的逐步解說會從 MCID 稽核停下的地方接手;而標籤最終服務的閱讀體驗,則是打造 Delphi 中的可存取 PDF 閱讀器筆記的主題
本文用到的呼叫(標記讀取、編輯交易、共用標記、頁面物件快照與結構元素)都屬於 PDFium Component 在 PDFium 引擎之上提供給 Delphi、C++Builder 與 Lazarus 專案的同一套 VCL API。PDFium Component 產品頁涵蓋其餘的可存取性與頁面編輯 API