技術文章

以 HotPDF 在 Delphi 中做結構順序 PDF 文字擷取

每個幾何文字擷取器都在猜。它讀取頁面繪出的字形,按基線與水平位置排序,然後期望視覺排列符合人類閱讀的順序。在單欄報告上這個猜測是對的;在雙欄期刊文章、帶側邊欄的表單,或以欄為序輸出儲存格的表格上,它以難以察覺、下游才發現且代價高昂的方式出錯。HotPDF 用 ExtractLoadedPageStructureText 回答這個問題,它完全忽略幾何:按 ISO 32000-1 §14.8.4 定義的撰寫順序走訪文件結構樹,然後按標記內容識別碼重組頁面字形。對加標籤的 PDF 來說,這不是啟發式,而是產出應用程式宣告的順序

當頁面沒有可用的結構樹時,該函式回傳 False,這是回退到幾何擷取器而不是失敗的訊號。雙路徑設計比演算法更重要:真實的文件進件會在同一個資料夾裡看到加標籤的政府表單與掃描器輸出,只處理其中一種的管線不是管線

為什麼幾何擷取會把閱讀順序弄錯?

因為 PDF 內容串流完全不攜帶閱讀順序。它是一串繪圖運算子,產出者可以用任何適合自己排版引擎的順序發出它們。文書處理器通常按流程順序發出,幾何排序看起來沒問題;排版工具、表單設計器與報表產生器經常不是:頁尾可能在正文之前發出,表格可能以欄為主序填入,雙欄頁面的兩欄行可能交錯,因為排版器把它們一起解析

雙欄 PDF 頁面對照:按基線排序的幾何擷取把兩欄拼接錯亂,對照 HotPDF 的結構順序 MCID 擷取
按基線排序字形會把兩欄交錯成無意義文字,結構樹則重播產出者宣告的順序

這種失敗模式很安靜。幾何擷取器從不報錯,只是交回句子被兩欄拼接起來的散文。任何消費這段文字的東西——搜尋索引、電子發票欄位對應器、餵給語言模型的檢索管線——都在無警告下繼承損害。HotPDF 也隨附已載入文件的幾何擷取器,對未加標籤的檔案它們仍是正確工具;結構順序路徑的重點是:當文件已經攜帶答案時,不再猜測

結構樹實際儲存什麼

加標籤的 PDF 為頁面保存一份平行的第二描述。Catalog 指向一個 /StructTreeRoot,其 /K 子節點構成結構元素樹:/Document/Sect/P/Table/TR/TD 等等。樹的葉節點是標記內容參照,即命名頁面內容串流某一段的整數。在內容側,這些段以攜帶 /MCIDBDC 運算子開啟、以 EMC 關閉。每個結構元素還攜帶 /Pg 項,指名它屬於哪一頁,這正是讓逐頁走訪在結構樹橫跨數百頁的文件中成為可能的原因

PDF 結構樹解剖:把 StructTreeRoot 下的 Sect、Table、TR、TD 等元素連到 HotPDF 頁面內容串流中的 BDC MCID 段
樹的葉節點是標記內容參照,每個元素攜帶 Pg 項,讓走訪能過濾到目前頁面

HotPDF 以 128 層深度上限走訪那棵樹,並按 /Pg 過濾,只有目前頁面貢獻內容。走訪的輸出不是文字,而是一個有序的 MCID 值清單:此頁標記內容段的撰寫順序。重組文字接下來就只是按那個順序重播字形

MCID 在字形擷取時記錄,而不是事後查表

這是讓此功能變得便宜的實作細節。HotPDF 已經在擷取的每個字形上記錄作用中的標記內容識別碼,存在 THPDFGlyphRecordMCID 欄位裡,因為內容串流直譯器在處理每個 TjTJ 運算子時,知道當下開著哪個 BDC 作用域。因此結構順序擷取不需要對內容串流跑第二輪。它從結構樹收集 MCID 序列,然後把已擷取的字形按 MCID 分桶,再按該序列發出

var
  Pdf: THotPDF;
  PageCount, I, Untagged: Integer;
  PageText, AllText: UnicodeString;
  Report: TStrings;   // 呼叫端自己的診斷輸出容器
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('accessible-form.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
    begin
      if Pdf.ExtractLoadedPageStructureText(I, PageText, Untagged) then
      begin
        // 直接來自結構樹的撰寫順序
        if Untagged > 0 then
          Report.Add(Format('page %d: %d glyphs outside the structure tree',
            [I, Untagged]));
      end
      else
        // 此頁沒有可用的結構樹:幾何回退
        Pdf.ExtractLoadedPageText(I, PageText);
      AllText := AllText + PageText + #13#10;
    end;
  finally
    Pdf.Free;
  end;
end;

未加標籤的字形會被計數,絕不無聲丟棄

一頁可能只有部分加標籤。產出者在任何 BDC 作用域之外加入裝飾線、頁碼或後期浮水印,那些字形不屬於任何 MCID。丟棄它們是整潔的實作,卻是錯的,因為同樣的缺口也出現在產出者加標籤了正文卻忘記表格的時候,而您會在不知不覺中失去整個表格

HotPDF 把未認領的字形作為幾何尾段附加在結構順序文字之後,並透過 UntaggedGlyphCount 輸出參數回報其數量。那個數字是可以行動的品質訊號。兩千個字形的一頁上出現寥寥幾個,是頁面裝飾,可以忽略;頁面四成在結構樹之外,意味著標籤只是裝飾,對該檔案幾何擷取器才是更誠實的答案

HotPDF 結構文字擷取的決策流程:頁面沒有可用結構樹或標籤只是裝飾時,回退到幾何擷取
True 代表結構順序並附加未標籤尾段,False 則把頁面導向幾何擷取器而不是失敗
function ExtractPageBestEffort(Pdf: THotPDF; PageIndex: Integer;
  out AText: UnicodeString; out UsedStructure: Boolean): Boolean;
var
  Untagged, TotalGlyphs: Integer;
  Glyphs: THPDFGlyphArray;
begin
  UsedStructure := False;
  if Pdf.ExtractLoadedPageStructureText(PageIndex, AText, Untagged) then
  begin
    TotalGlyphs := 0;
    if Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
      TotalGlyphs := Length(Glyphs);
    // 只有當結構樹宣稱涵蓋頁面大部分時才信任它
    if (TotalGlyphs = 0) or (Untagged * 4 <= TotalGlyphs) then
    begin
      UsedStructure := True;
      Result := True;
      Exit;
    end;
  end;
  Result := Pdf.ExtractLoadedPageText(PageIndex, AText);
end;

什麼情況讓函式回傳 False

三種情況,值得區分,因為只有一種是文件的缺陷。第一種是普通未加標籤的 PDF:沒有 /StructTreeRoot、沒有東西可走訪,False 只是如實陳述。第二種是掃描頁,文字來自從未加標籤的 OCR 層。第三種才是有趣的:內容攜帶帶 /MCID 值的 BDC 運算子,但頁面沒有 /StructParents 項,結構樹也從未引用那些識別碼。標記內容存在,結構側不存在,沒有順序可恢復。HotPDF 回報 False 而不是發明一個

最後一種情況出現在手改檔案,以及為 optional content 或 artifact 用途發出標記內容、卻不建結構樹的工具輸出中。如果您自己生產加標籤的 PDF,同樣的不對稱正是 PDF/UA 驗證檢查的內容,寫入側的對應物在發出加標籤、分頁輸出的版面 DOM 中涵蓋

結構順序在哪裡值得回本

無障礙稽核是最明顯的:如果您要對 PDF/UA 認證文件,螢幕閱讀器會朗讀的閱讀順序正是結構順序,所以擷取它就是在沒有螢幕閱讀器的情況下審閱它的方式。資料擷取是更大的商業案例。加標籤的政府表單、法規揭露文件與電子發票附件按宣告順序攜帶欄位標籤與值,按那個順序讀取,消除了幾何擷取在多欄版面上製造的一整類對應錯誤

最新的消費者是語言模型的檢索。為向量嵌入對文件分塊,好壞完全取決於文字順序,一個拼接了兩欄的區塊會產出從未存在過的句子。結構順序擷取是這個問題最便宜的現成修法,因為對加標籤文件,正確順序已經在檔案裡,只等著被讀

HotPDF 是 Delphi 與 C++Builder 的原生 VCL 元件,結構樹走訪與字形重播都在同一行程內針對已載入文件執行,不涉及任何外部渲染器。已載入文件擷取家族的完整 API 細節在 HotPDF Delphi PDF component 產品頁