技術文章

PDFium VCL:Delphi 中的結構化 PDF 文字擷取

PDFiumPas 會把頁面文字以結構化形式回傳,而不是一整串字串。GetStructuredText 會產生一個 TPdfStructuredTextPage,內含區塊,每個區塊內含多行,每一行內含樣式化片段,每一層都附有頁面座標邊界,並保留原始字元索引,讓任何片段都能對應回底層的文字頁面

多數程式碼一開始都會用到的扁平字串擷取方式依然存在,對它原本的用途而言依然正確。但只要你需要知道哪些文字屬於標題、哪些屬於左欄、或是某個相符結果究竟在頁面上的什麼位置,這種方式就不再夠用了

為什麼扁平字串對多數工作來說是錯誤的輸出?

因為人們對擷取文字提出的問題,幾乎從來不是「這一頁上有哪些字元」。他們問的是「標題是什麼」「這是不是一個表格」「這個段落是不是屬於第 4 節」「這個標記該畫在哪裡」。單一字串一個問題都答不了,而你從它重建出來的每一個答案,都是一套你得自己維護的推測

雙欄版面正好能說明這一點。把一篇雙欄文章擷取成字串,依產生器寫入內容串流的方式不同,你可能會得到「第一欄接著第二欄」,也可能得到「第一欄第一行、第二欄第一行、第一欄第二行」這樣一路往下的順序。兩者都可能出自一份合規的 PDF。兩者在格式層面都沒有錯,因為 PDF 描述的是頁面上的標記,不是文件大綱。以區塊為基礎的模型,能讓擷取器把排序決策明確做出來,並告訴你它做了哪一種決定

依內容順序,還是依實體版面?

TPdfStructuredTextOptions.ReadingOrder 可在 roContentOrderroPhysicalLayout 之間選擇,正確答案取決於你更信任誰:產生器,還是幾何位置

內容順序會依內容串流繪製的先後順序回傳文字。這樣做速度快,而且對於行為良好的產生器所產生的文件而言,通常就是預期中的閱讀順序。實體版面則會忽略串流順序,改依字元實際所在位置重建順序,先聚合成行,再聚合成欄。這正是掃描後經過 OCR 的頁面、依字型順序而非閱讀順序輸出文字的工具,以及任何只能依靠視覺結果來判斷的情況所需要的方式

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfStructuredTextOptions;
  Page: TPdfStructuredTextPage;
  B, L: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'article.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // 以 1 起算

    Options := TPdfStructuredTextOptions.Default;
    Options.ReadingOrder := roPhysicalLayout;
    Options.IncludeFontInfo := True;
    Options.IncludeSemantics := True;
    Options.MaxCharacters := 200000;         // 失效即中止的預算

    Page := Pdf.GetStructuredText(Options);

    for B := 0 to High(Page.Blocks) do
    begin
      if Page.Blocks[B].Kind = cfHeading then
        Emit(Format('H%d: %s',
          [Page.Blocks[B].HeadingLevel, Page.Blocks[B].Text]))
      else
        for L := 0 to High(Page.Blocks[B].Lines) do
          Emit(Page.Blocks[B].Lines[L].Text);
    end;
  finally
    Pdf.Free;
  end;
end;

標記能提供幾何位置給不了的什麼?

意圖。啟用 IncludeSemantics 後,來自已標記 PDF 的區塊會攜帶一個取自結構樹的 Kind,因此一個標題之所以是標題,是因為產生器這麼宣告的,而不是因為它的字型比平均值大。這些類型涵蓋了對重複使用而言重要的形狀:cfParagraph、帶有 HeadingLevelcfHeadingcfListItemcfTableCellcfCaptioncfFigure,以及未標記時的預設值 cfPlain

Source 欄位記錄每一項分類的來源,結構樹來源記為 rosStructure,推斷來源記為 rosHeuristic,這正是在判斷一套擷取管線在整批文件上該有多大信任度時,該去查看的欄位。圖片是一個值得留意的特例:對於 cfFigure 區塊,文字來自替代描述,而不是來自任何字型符號,因為一張圖片本身沒有字元。沒有比對到的替代文字仍然會被呈現出來,而不是被捨棄,這正是讓無障礙稽核能看出「頁面上什麼都沒畫、卻確實存在一段描述」的關鍵。標記模型本身涵蓋在 PDF/UA 結構樹驗證

片段承載樣式與出處資訊

每一個 TPdfStructuredTextSpan 都帶有自身文字、頁面座標邊界、FontNameFontSizeFontWeightAngle,加上 SourceStartIndexSourceCharacterCount。片段會在樣式改變處斷開,因此一句含有三個粗體字詞的句子會變成三個片段,要在 HTML 或 Markdown 中重建強調樣式,只需要讀取屬性,而不必從字型名稱去猜測

那兩個原始索引欄位,正是把「擷取」變成一項功能、而不只是一份報告的關鍵。它們指回頁面的字元序列,代表你在搜尋中比對到的一個區塊,不必再花第二趟、順序又不同的掃描,就能轉換成字元層級的選取幾何或標記矩形;其中的機制說明見 以字元框進行視覺化文字選取Angle 欄位比看起來更重要:印章或浮水印中旋轉的文字,會落在與內文相同的座標空間中,一套忽略角度的管線,很可能把一個斜著印的「DRAFT」硬生生合併進一段內文的正中間

預算,以及兩個品質計數器

MaxCharacters 是一個失效即中止的預算,不是截斷設定:超出它的頁面會直接中止,而不會靜默回傳部分內容。在不受信任的接收路徑上,這正是你想要的行為,因為一頁多達百萬字元,要麼是機器產生的異常內容,要麼就是有人想讓你的擷取器變成系統中最慢的一環

回傳的頁面上有兩個計數器,能直接反映擷取品質。UnmappedCharacterCount 計算的是沒有可用 Unicode 對映的字元,這是子集字型內嵌時未附 /ToUnicode CMap 的經典症狀;這種文字渲染起來完全正常,擷取出來卻毫無用處。GeometryFailureCount 計算的是無法判斷邊界框的字元,這會削弱實體版面排序的準確度。兩者都應該記錄下來。一批文件若這兩個數字持續接近零,就可以放心索引;若不是,就代表管線中有些產生器需要先處理,任何下游結果才值得信任

var
  Page: TPdfStructuredTextPage;
  B, S, L: Integer;
  Emphasised: Boolean;
begin
  Page := Pdf.GetStructuredText(Options);

  if Page.UnmappedCharacterCount > 0 then
    Log(Format('page %d: %d characters without a Unicode mapping',
      [Page.PageNumber, Page.UnmappedCharacterCount]));
  if Page.GeometryFailureCount > 0 then
    Log(Format('page %d: %d characters without geometry',
      [Page.PageNumber, Page.GeometryFailureCount]));

  for B := 0 to High(Page.Blocks) do
    for L := 0 to High(Page.Blocks[B].Lines) do
      for S := 0 to High(Page.Blocks[B].Lines[L].Spans) do
      begin
        Emphasised := Page.Blocks[B].Lines[L].Spans[S].FontWeight >= 600;
        AppendRun(Page.Blocks[B].Lines[L].Spans[S].Text, Emphasised,
          Page.Blocks[B].Lines[L].Spans[S].SourceStartIndex);
      end;
end;

在真實頁面上的效能表現

實體版面擷取是成本較高的模式,而這套實作是為真正龐大的頁面打造的:字元排序以 O(n log n) 而非重複掃描的方式執行,行與片段緩衝區以倍增方式成長、而不是逐字元重新配置,Unicode 文字在緩衝區中組建、而不是靠字串串接,相鄰文字物件的字型查詢也會被快取。這些組合,正是讓一頁 5,000 字元的密集內容維持可預期效能、而不是變成平方級成長的關鍵

對於頁數龐大的工作而言,在可行之處選用成本較低的模式仍然值得。對於你信任的已標記文件,使用啟用語意的 roContentOrder;把 roPhysicalLayout 保留給掃描與舊式素材,因為在那些情況下,幾何位置是唯一可靠的訊號。如果你只需要純文字字串,從 PDF 文件擷取文字 中描述的簡化 API 仍是速度更快的路徑;而當你需要把文字追溯回標記內容識別碼時,讀寫 BDC 與 MCID 標記內容 涵蓋了那個層次

這套區塊模型也能乾淨地對應到檢索管線所需要的東西:一個帶有段落的標題就是一個有標題的區塊,邊界資訊則讓引用能指向頁面上的一個位置,而不只是指向一份文件。PDFiumPas 是圍繞 PDFium 引擎打造的 Delphi 與 Lazarus 元件,並附有範例文件,請參見 PDFium Delphi 元件頁面