技術文章

在 Delphi 中使用 HotPDF 從已載入的 PDF 提取文字

HotPDF Component 透過兩次呼叫即可從您在 Delphi 中載入的任何 PDF 提取 Unicode 文字:ExtractLoadedPageText 傳回頁面的閱讀流 (reading-flow) 文字,而 ExtractLoadedPageTextLayout(於 v2.263.0 新增)則將頁面的視覺排列重建為純文字,因此欄位、縮排以及表格對齊皆能在輸出中保留。兩者都適用於並非由 HotPDF 建立的文件,而這正是實際情況中最重要的:客戶用電子郵件寄給您的發票、掃描局交付的報告、由沒人能叫得出名字的軟體所產生的合約

要達成這個目標,所需的核心機制比這兩個函式簽章 (signature) 所暗示的還要多,因為 PDF 儲存文字的方式與純文字檔不同。本文將探討這兩種提取模式,然後揭開底層三個元件的面紗——CMap 讀取器、內容串流直譯器,以及字型解碼退回鏈 (fallback chain)——因為了解對應關係如何運作,正是「對亂碼輸出聳聳肩」與「能夠診斷問題」之間的差異所在

為什麼文字提取比從檔案中讀取字串更難?

PDF 內容串流記錄的是字元代碼 (character codes),而不是字元。TjTJ 運算子(ISO 32000-1 §9.4.3)承載位元組字串,其意義完全取決於前面 Tf 所選擇的字型:在 WinAnsi 下,位元組 0x41 可能是字母 A,而在子集字型 (subset font) 中可能是任意字形 (glyph),或者在複合型 CJK 字型中可能是雙位元組 CID 的一半。ISO 32000-1 §9.10 將文字提取精確定義為這種解碼問題——使用字型字典提供的任何資訊,將每個代碼對應回 Unicode——而且該標準明確指出,一個符合規範的檔案並沒有義務提供足夠的資訊來完成這項工作

最後一個條款解釋了您所見過每個「為什麼從這個 PDF 複製貼上會產生亂碼」的錯誤報告。如果產生器內嵌了沒有 /ToUnicode 表格的子集字型,它所寫出的檔案能完美渲染,但提取出的卻是無意義的內容,因為「代碼到字形」的對應存在,但「代碼到 Unicode」的對應卻從未被提供。因此,任何誠實的提取 API 都是一條「盡力而為」的退回鏈,而真正有用的問題在於,這條鏈能深入到什麼程度

使用 ExtractLoadedPageText 進行閱讀流提取

對於搜尋索引、關鍵字比對,或是將文字送入分析管線,ExtractLoadedPageText 就是您想要的呼叫。其簽章為 function ExtractLoadedPageText(PageIndex: Integer; out AText: UnicodeString): boolean——頁面索引以零起始,結果會作為原生的 Delphi UnicodeString 到達,且當該頁面沒有可讀的內容串流時,該函式會傳回 False,而不是引發例外

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText, AllText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('invoice.pdf');
    AllText := '';
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        AllText := AllText + PageText + #13#10;
    // AllText 現在保存了整份文件的閱讀流文字
  finally
    Pdf.Free;
  end;
end;

輸出中的換行 (Line breaks) 來自一個刻意簡單的啟發式演算法 (heuristic):當字形的垂直起點移動超過目前字型大小的一半時——這是內容串流中 TdT* 步驟的特徵——就會插入一個換行符號。解碼器無法解析的字元會變成空白而不是消失,因此即使個別字形不復存在,單字邊界依然能夠保留。這個模式不會嘗試進行閱讀順序分群 (clustering) 或多欄檢測:雙欄頁面的輸出將按照內容串流的順序交錯出現,這通常(但並非總是)與視覺順序相同

何時該改用保留版面的提取?

每當位置承載著意義時——表格、表單、程式碼列表,或是任何您打算透過欄位來進行比對 (diff)、grep 或解析的內容——ExtractLoadedPageTextLayout 才是正確的呼叫。它不是將字形扁平化為串流,而是將它們分群為基線 (baselines),依據 X 座標對每條基線進行排序,並在由中位數字形寬度與字型大小決定尺寸的等寬字元網格上,重現水平與垂直的空白。同一條基線上字元串之間的寬大間隙會變成連續空白;基線之間的巨大間隙則會變成空白行。最終的結果讀起來就和頁面看起來一模一樣

var
  Grid: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1.txt', Grid, TEncoding.UTF8);
  // 欄位、縮排與表格對齊
  // 將在字元網格上以空白與空白行的形式保留
end;

這兩種模式共用了解碼機制的每一個位元組,差別僅在於它們如何排列解碼後的字形,因此這樣的選擇不會在保真度上造成任何代價。當只有文字本身重要時,選擇 ExtractLoadedPageText;當排列方式也很重要時,選擇 ExtractLoadedPageTextLayout。多欄閱讀順序檢測仍超出了兩者的範圍——雙欄頁面的網格渲染會忠實地將兩欄並排顯示給您,這對比對差異 (diffing) 來說完全正確,但對於散文 (prose) 重新排版來說則不然

HotPDF 如何將字元代碼解碼為 Unicode?

HotPDF Component 透過具有優先順序的退回鏈來解析每個字元代碼:首先是字型內嵌的 /ToUnicode CMap,然後是 /Encoding 條目(串流或命名的 CMap),接著——對於複合字型而言——是 Adobe-GB1、Adobe-CNS1、Adobe-Japan1 與 Adobe-KR 等字元集合的 Adobe 標準 CMap 檔案,最後則是針對簡單字型的內建 WinAnsi 與 MacRoman 表格。無法提供答案的策略會安靜地降級到下一個策略,而不是引發例外,而窮盡整條鏈都無法解析的代碼會解析為 0,讓呼叫者能夠計算未命中的數量,而不是只能靠猜測

/ToUnicode CMap(ISO 32000-1 §9.10.3)排在第一位,因為它是產生器特別為了提取而寫入的對應表。Adobe 標準 CMap 路徑對於使用預定義 CMaps(如 UniGB-UTF16-H)而不是內嵌任何東西的 CJK(中日韓)文件來說很重要:HotPDF 在其 resources\CMap 目錄下附帶了這些集合檔案,在執行階段相對於執行檔進行定位,並針對每個處理程序 (process) 快取每個解析過的對應表——這一點值得了解,因為其中最大的一個,Adobe-GB1 對應表,大約有 2 MB 的原始文字,您絕對不會想每一頁都重新解析一次。如果該目錄不存在,解碼器只會略過以磁碟為後盾的 CMaps,並依靠內嵌表格以及內建編碼來運作。這正是使用 HotPDF 進行複雜腳本文字塑形 (shaping) 一文中在寫入時所面臨的「代碼與字形區分」問題,在讀取端的鏡像反映

兩個值得了解的 CMap 語法陷阱

CMap 檔案看起來像是可以輕易解析,但其實不然,有兩個細節導致了大多數首次嘗試解析器的失敗。第一個是紀錄數量出現在區段關鍵字之前:一個區段讀作 2 beginbfchar,而不是 beginbfchar 2。預期數量會出現在關鍵字之後的解析器會把這個數字當作多餘的標記 (token) 給消耗掉,然後在每個區段中找到零個條目。強健的做法——也就是 HotPDF 讀取器最終採用的做法——是完全忽略這個數量,然後一直迴圈到相符的 endbfchar / endbfrange 關鍵字,這帶來的好處是能夠容忍現實世界中數量根本就是錯的檔案

第二個陷阱是 bfcharbfrange 的目標值是 UTF-16BE 字串,而不是整數。目標 <D83DDE00> 代表 U+1F600——一個代理對 (surrogate pair),必須重組為一個代碼點 (code point)——若將這四個位元組讀作一個大端序 (big-endian) 整數,則會對基本多文種平面 (Basic Multilingual Plane) 之外的每個代碼點產生毫無意義的值。PDF 中的表情符號已經不再稀奇,因此略過代理對重組的解碼器,在面對您的使用者實際擁有的檔案時將會失敗。HotPDF 首先將十六進位字串解析為原始位元組,然後再重組 UTF-16BE 代碼單元,這也涵蓋了連字 (ligature) 對應所產生的多字元目標值

使用 ExtractLoadedPageGlyphs 下放至字形層級

這兩個文字呼叫都是建立在 ExtractLoadedPageGlyphs 之上,且底層的 THPDFGlyphArray 也可以供您的程式碼使用。每一個 THPDFGlyphRecord 承載著解析出的 Unicode 代碼點以及原始字元代碼、該代碼的位元組寬度(1、2 或 4,由 CMap 的 codespacerange 決定)、使用中的字型資源鍵值與大小、使用者空間 (user-space) 的 X 與 Y 座標原點,以及水平前進量 (advance)。這足以用來建立單字邊界檢測、定位反白顯示,或是自訂的排版演算法,而無須您親自去接觸內容串流

var
  Glyphs: THPDFGlyphArray;
  I, Unresolved: Integer;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
  begin
    Unresolved := 0;
    for I := 0 to High(Glyphs) do
      if Glyphs[I].Unicode = 0 then
        Inc(Unresolved);
    if Unresolved > 0 then
      ShowMessageFmt('%d 個字形(共 %d 個)沒有 Unicode 對應',
        [Unresolved, Length(Glyphs)]);
  end;
end;

如上所示,計算 Unicode = 0 的紀錄,是在您信任下游文字之前,衡量給定文件提取品質的誠實方法。字形紀錄也將每個字元錨定到內容串流中的原始運算元,這正是讓 HotPDF 能夠在相同的基礎上,實現對已載入文件進行文字搜尋與取代的原因

哪些 PDF 不會交出它們的文字?

有些檔案會擊敗所有的提取器,而最好能偵測到它們,而不是將它們的輸出給發布出去。掃描的文件是最明顯的例子:一個本身就是一張大圖片的頁面完全不包含任何文字運算子,因此提取操作正確地傳回空字串——解決方案是 OCR,而從已載入的 PDF 提取頁面圖片則是該管線的第一步。沒有 /ToUnicode 表格的子集字型則是更困難的情況:如果 /Encoding 路徑與標準 CMaps 也一無所獲,這些字形就會解析為 0,並在文字呼叫中以空白的形式呈現。只要您透過 LoadFromFile 的多載版本並附帶密碼來載入加密文件,它們就能正常提取,因此串流在直譯器看到它們之前就已經被解密了

還有一個較窄的限制值得坦率說明:解碼鏈是透過 HotPDF 的 Flate 路徑來讀取 CMap 與內容串流的,因此如果某個字型的 ToUnicode 串流使用了不尋常的濾鏡,它將會降級到下一個策略,而不是讓整個頁面失敗。實際上,FlateDecode 涵蓋了過去二十年所產生的幾乎所有內容,且這種降級是刻意設計成無聲無息的——您將會獲得該檔案所允許的最佳文字,而不是一個例外錯誤。在此負責解析字型字典的同一套讀取端物件機制,也同時為在已載入文件上編輯中繼資料提供動力,因此文件接收管線可以在單一通道中完成提取、檢查與標註工作

文字提取、保留版面的渲染、字形層級的存取,以及建立在這些基礎上的搜尋與取代功能,全都是 Delphi 與 C++Builder 版標準 HotPDF Component 的一部分——無需外部 DLL,無需 OS 文字服務,只需使用 Object Pascal,當一個奇怪的檔案落入您的佇列時,您可以親自單步除錯