技術文章

在 Delphi 中讀寫 PDF 標記內容

標記內容是 ISO 32000-1 §14.6 為標記頁面內容而定義的機制,而標記 PDF 與 PDF/UA 都建立在它之上。PDFium Component 直接對此公開:PageObjectMarks 從某個頁面物件讀取每一個 BDC 標記與其屬性表、AddPageObjectMark 寫入一個、RemovePageObjectMark 刪除一個,而 PageObjectMarkedContentID 則回報把內容連到結構樹的 MCID

在結構樹能與它所描述的內容接回來之前,可存取性工具都只是猜測。結構樹說「這是一個標題」;MCID 說那個標題實際上是哪些頁面上的哪些標記。兩半都必須可讀,應用程式才能檢查、修復或回報標記

一個標記,用位元組來看,是什麼?

一個帶著標記名稱與選用屬性表的 BDC 運算子,由 EMC 關閉。在內容串流裡它看起來像 /P <</MCID 3>> BDC ... EMC:標記 /P 命名角色、字典承載屬性,而運算子之間的一切都是標記內容。落在這段範圍內的頁面物件承載著這個標記,而這正是 PDFium 交回來的東西,也是 PDFium Component 轉成一筆記錄的東西

TPdfContentMark 持有一個 handle、標記的 Name,以及一個 TPdfContentMarkParam 陣列。每一個參數帶有一個 Key、一個 Kind,以及一個由該 kind 選定的有意義值欄位:pmpIntpmpFloatpmpStringpmpBlob。這個 kind 來自 PDFium 自身的型別回報,而不是來自哪個 getter 湊巧成功,而這正是讀取屬性表與猜測屬性表之間的差別

var
  Marks: TPdfContentMarks;
  M: TPdfContentMark;
  P: TPdfContentMarkParam;
  I: Integer;
begin
  Pdf.PageNumber := 1;                    // PageNumber is 1-based
  for I := 0 to Pdf.ObjectCount - 1 do    // page object indexes are 0-based
  begin
    Marks := Pdf.PageObjectMarks(I);
    for M in Marks do
    begin
      Memo1.Lines.Add('mark ' + M.Name +
        ' (MCID ' + IntToStr(Pdf.PageObjectMarkedContentID(I)) + ')');
      for P in M.Params do
        case P.Kind of
          pmpInt:    Memo1.Lines.Add('  ' + P.Key + ' = ' + IntToStr(P.IntValue));
          pmpString: Memo1.Lines.Add('  ' + P.Key + ' = ' + P.StringValue);
          pmpFloat:  Memo1.Lines.Add('  ' + P.Key + ' = ' + FloatToStr(P.FloatValue));
          pmpBlob:   Memo1.Lines.Add('  ' + P.Key + ' = ' +
                       IntToStr(Length(P.BlobValue)) + ' bytes');
        end;
    end;
  end;
end;

為什麼 pmpUnknown 代表兩種不同意義

當 PDFium 回報 FPDF_OBJECT_UNKNOWN 時會回傳 pmpUnknown,而 PDFium 對一個不存在的金鑰也會回傳這個值。在這一層無法區分這兩種情況,而假裝能區分,會比承認無法區分更糟

對你的程式碼而言,實際後果是:把 pmpUnknown 當作「這裡沒有可用值」,而不是一個你也許還能解碼的型別。如果某個屬性對你的工作流程很重要,請用你認得的 kind 驗證它存在,而且不要從 unknown 推斷出不存在——一個你無法讀取屬性表的標記,是一個你應該回報的標記,而不是你應該默默接受的標記

一筆標記記錄是快照,不是你擁有的 handle

Handle 欄位屬於函式庫。它在標記被移除、頁面物件被摧毀或頁面被卸載的那一刻就失效,所以這筆記錄是一個壽命很短的唯讀快照。跨頁面切換快取它,你持有的就是一個指向引擎已回收記憶體的指標

這是 PDFium 中對頁面物件 handle 一體適用的同一條紀律,而且它在同一個地方抓到人:一個以標記記錄填滿的清單控制項、使用者切換到另一頁,以及一次看起來與導覽無關的當機。把你需要的值複製出來——名稱、金鑰、數字——然後放手讓 handle 離開。頁面物件 handle 在轉換後失效的筆記涵蓋了通用規則以及它在其他地方如何咬人

加入一個標記,以及容易漏掉的儲存步驟

AddPageObjectMark 接受頁面物件索引、一個標記名稱與一份完整的參數集合。參數以一個集合寫入,而不是一次修補一個金鑰,這正是 TPdfContentMarkParam 沒有 Has* 哨兵值的原因——它們本來要守護的「更新既有記錄中單一欄位」情況並不會發生

值得明說的部分:加入一個標記會重建頁面內容串流,好讓該標記能在儲存後存活。這一點必須明說,因為 SaveAs 本身不會重新產生內容——只存在於物件模型中的變更會被丟棄,而儲存後的檔案看起來會跟一開始那份完全一樣。如果你曾經把某個東西加到 PDFium 頁面上、卻發現輸出裡沒有它,這通常是原因

var
  Params: TPdfContentMarkParams;
begin
  SetLength(Params, 1);
  Params[0].Key := 'MCID';
  Params[0].Kind := pmpInt;
  Params[0].IntValue := NextMcid;
  Pdf.AddPageObjectMark(ObjectIndex, 'P', Params);   // rebuilds the content stream
  Pdf.UpdatePage;
  Pdf.SaveAs('tagged-out.pdf');
end;

這能為文件做到什麼,又做不到什麼

光靠標記,做不出一份標記 PDF。一份符合規範的標記文件,需要一棵其元素參照這些 MCID 的結構樹、一個宣告文件已標記的 /MarkInfo 項目,以及意義如同標準所言的角色名稱。寫一個帶著 MCID、卻沒有任何結構元素指向它的 /P 標記,你得到的是一份宣稱已標記的內容,以及一棵從不提及它的結構樹

在這個層級,標記內容真正發揮價值的地方,是在檢查與修復:稽核哪些頁面物件已標記、找出本應被標記為工件的項目,或把 MCID 對照結構樹以找出孤兒。關於這項工作的結構樹那一半,請見 PDF/UA 結構樹驗證的逐步解說;而對於標記最終所服務的閱讀體驗,請見打造 Delphi 中的可存取 PDF 閱讀器的筆記

PDFium Component 為 Delphi、C++Builder 與 Lazarus 應用程式,在 PDFium 引擎之上提供一套高階 VCL API,標記內容、結構樹與可存取性驗證都能從普通的 Pascal 程式碼觸及——完整的 API 面請見 PDFium Component 產品頁