技術文章

在 Delphi 中使用 PDFium 元件將 PDF 文件中的文字

PDF 文字擷取看似簡單,直到您遇到文字層不存在、損毀,或是被拆分到數十個毫無意義順序的微小字元組的文檔為止;PDFium 元件為您提供兩個進入點:Character[] 陣列用於對頁面上的每個字形進行基於索引的原始存取,而 ReadablePageContent 則用於結構化檢視,從 PDF 的標籤樹或啟發式分析中重建段落和標題;兩者並非總是唯一的正確選擇,因此了解每個方法所呈現的內容至關重要

開啟文件與靜默失敗陷阱

TPdf 藉由設定 FileName 並切換到 Active := True 來開啟檔案;關鍵的細節在於:Active := True 絕不會引發異常;如果檔案遺失、受密碼保護或損毀,PDFium 會在內部處理該錯誤,而 Active 只會保持為 False;這代表每個擷取迴圈都必須對此進行防護:

Pdf := TPdf.Create(nil);
try
  Pdf.FileName := 'report.pdf';
  Pdf.Active := True;
  if not Pdf.Active then
  begin
    ShowMessage('Could not open PDF (damaged or wrong password)');
    Exit;
  end;
  // extraction follows here
finally
  Pdf.Active := False;
  Pdf.Free;
end;

受密碼保護的檔案需要在 Active := True 之前設定 Pdf.Password := '...';此時沒有第二次機會:一旦 Active 失敗,您就必須關閉並使用正確的密碼重新開啟

使用 Character[] 進行逐頁擷取

最低階的方法會走過每個頁面上的每個字元;設定 Pdf.PageNumber 以載入該頁面的文字層,然後使用 Character[] 屬性反覆檢視 CharacterCount 項目;每個項目上有兩個旗標值得檢查:CharacterGenerated[i] 標記由轉譯器插入的合成字形(例如換行處的軟連字號),這些字形沒有實際的 Unicode 值;而 CharacterMapError[i] 則發出訊號,表示 PDFium 無法將字形對應到字碼點,這通常發生在缺乏 ToUnicode 表的字型編碼中

procedure ExtractAllText(Pdf: TPdf; Output: TStrings);
var
  Page, I: Integer;
  Line: string;
  Ch: WideChar;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := Page;
    Line := '';
    for I := 0 to Pdf.CharacterCount - 1 do
    begin
      if Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I] then
        Continue;
      Ch := Pdf.Character[I];
      if Ch = #13 then
        Ch := #10;   // normalize CR to LF
      Line := Line + Ch;
    end;
    Output.Add(Line);
  end;
end;

其傳回結果是依據 PDFium 列舉順序排列的 Unicode 字碼點平面字串,這正是它們在內容串流中出現的順序,而不一定是從左到右的閱讀順序;對於大多數由標準辦公工具產生的拉丁文字文件,這樣做是沒有問題的;對於使用異常字形序列進行 OCR 處理的掃描 PDF,或是從右到左的文字,順序可能會出錯;此時 ReadablePageContent 就會變得更加實用

使用 ReadablePageContent 進行結構化擷取

ReadablePageContent 提升了一個層級:它傳回一個 TPdfReadableContent 記錄,其 Fragments 陣列帶有標記的內容片段,每個片段都有一個 Kind,用於識別段落、標頭、清單項目、表格儲存格等;當 PDF 帶有結構樹時(檢查 Pdf.IsTagged),來源為 rosStructure,且閱讀順序是具有權威性的;對於未標記的檔案,PDFium 會退而求其次使用 rosHeuristic,它會根據字元的邊框將其分組為合理的閱讀單元,但無法保證準確性

procedure ExtractStructured(Pdf: TPdf; Output: TStrings);
var
  Page: Integer;
  Content: TPdfReadableContent;
  Fragment: TPdfContentFragment;
begin
  for Page := 1 to Pdf.PageCount do
  begin
    Content := Pdf.ReadablePageContent(Page);
    for Fragment in Content.Fragments do
    begin
      case Fragment.Kind of
        cfHeading   : Output.Add('# ' + Fragment.Text);
        cfParagraph : Output.Add(Fragment.Text);
        cfListItem  : Output.Add('- ' + Fragment.Text);
      else
        Output.Add(Fragment.Text);
      end;
    end;
  end;
end;

如果 Content.Source = rosHeuristic 且您的輸出看起來是亂碼,那文件的文字層在編寫時可能沒有考慮到閱讀順序;此時,唯一可靠的解決方法是從來源應用程式中以正確的標記重新匯出,或是執行一個按照 Y 軸再按 X 軸對字元原點進行排序的後處理步驟

CharacterOrigin 与 CharacterRectangle 提供的資訊

這兩個屬性都會傳回頁面空間中字元的位置(點,原點在左下角,Y 軸向上遞增);CharacterOrigin[i] 是字形的基準線錨點,CharacterRectangle[i] 是完整的邊框;這些是純文字之外任何應用的建置區塊:偵測欄邊界、透過比較容許度內的 Y 座標將字元分組為行,或是建置檢視器中用於文字選取的點擊測試對應表;如果您需要找出滑鼠點擊下是哪個字元,CharacterIndexAtPos(X, Y, ToleranceX, ToleranceY) 可以直接執行該查閱,而無需您重複走過各個矩形

置放 DLL 檔案

PDFium 元件將所有 PDF 解析委派給原生 DLL,根據您的目標平台,可能是 pdfium32.dllpdfium64.dll;該元件隨附一個 CopyDlls.bat 指令碼,可將正確的檔案複製到 Windows 系統目錄中;在開發電腦上以系統管理員身分執行一次就足夠了;對於部署,您需要將 DLL 複製到應用程式可執行檔的旁邊;啟用 V8 的變體(pdfium32v8.dllpdfium64v8.dll)體積明顯較大,且僅在您的 PDF 包含必須執行的 JavaScript 時才需要;對於純文字擷取,標準版本才是正確的選擇

如果執行階段缺少 DLL,Active := True 將會靜默失敗,就像檔案遺失一樣,因為元件會在內部擷取載入錯誤;在出貨前請務必在乾淨的電腦上進行測試

配合 Character[] 使用 FontSize[] 進行版面分析

除了純文字之外,字元層級的 API 還公開了 FontSize[i],它會傳回每個字形轉譯後的點大小;結合 CharacterOrigin[i]CharacterRectangle[i],這可以讓您在不依賴結構樹的情況下,區分內文與標題;在未標記的文件中,字型大小跳到閾值以上的字元組幾乎可以確定是標題;相同的技術也適用於偵測圖說文字(影像邊框下方的小字)或頁尾註腳(靠近頁面底部的小字);這一切都不需要轉譯,所有三個屬性都是直接從 PDFium 在 Active := True 期間建立的文字層中讀取

一個細微差別:FontSize[i] 反映了套用頁面 CTM(當前轉換矩陣)之後的大小,因此如果作者縮放了整個頁面,文件會回報等比例調整後的大小;如果您在具有不同頁面尺寸的頁面之間比較大小,在做出閾值決策之前,請先針對每個頁面的 MediaBox 高度進行標準化

將輸出寫入檔案

自 XE 版本以來,Delphi 的 TStringList 就可以乾淨地處理 UTF-8 輸出;如果您需要無 BOM 的檔案,請設定 WriteBOM := False(許多下游使用者會因為前導 BOM 而無法運作):

var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    ExtractAllText(Pdf, Lines);
    Lines.WriteBOM := False;
    Lines.SaveToFile('output.txt', TEncoding.UTF8);
  finally
    Lines.Free;
  end;
end;

對於記憶體吃緊的極大型文件,請在頁面迴圈內使用 TEncoding.UTF8 直接寫入 TStreamWriter,而不是先將所有內容累積到清單中

此處顯示的 Character[]CharacterCountCharacterOrigin[]CharacterRectangle[]ReadablePageContentCharacterIndexAtPos API 是適用於 Delphi 和 C++Builder 的 PDFium 元件 的一部分