把文字、影像與字型從一份既有的 PDF 裡拉出來,聽起來像個已經解決的問題,直到您拿一批真實語料跑過去為止。把搜尋索引器指向四萬份客戶檔案,壞掉的地方會分成幾堆認得出來的樣子。字全黏在一起,因為沒人告訴擷取器多寬的間隙才算一個空格。另一些頁面回來的是亂碼,因為某個子集化字型沒帶著從字形代碼到實際字元的對映。而「公司標誌」原來是九個各自獨立、疊在一層柔邊遮罩後面的影像物件。這些都不是函式庫的臭蟲。這是「呼叫一個擷取函式」跟「弄懂這個函式從磁碟上的位元組裡救得回來什麼、救不回來什麼」之間的差別
losLab PDF Library 的 Pascal 版,給 Delphi 與 C++Builder 程式碼不只一種方式去讀這三道串流,而各個層級所保證的東西並不相同。訣竅在於把層級對上工作:一個搜尋索引、一位遮蔽審查者,以及一趟 PDF/A 預檢,想從同一頁裡拿走的東西各不相同,而伸手拿錯呼叫,不是白費力氣就是產出您沒法信任的結果
文字擷取的各個層級各自承諾了什麼
GetPageText 收一個 0 到 8 的選項值,而那個數字挑的是引擎,不是格式。0 到 2 走的是輕量的一趟掃描,拿來做快速預覽很夠用。3 到 8 則繞道版面感知引擎,它依字形實際落在頁面上的位置重建行與間距。在那個範圍內,各種變化都有差別:4 與 6 把輸出切成單字,5 與 6 發出逐字形的寬度,而 7 傳回純文字,並刻意丟掉字型、色彩與區塊中繼資料。要餵搜尋索引就用選項 7,因為索引要的是單字,別無其他
沒有任何選項設定救得了一份打從一開始就沒帶著那份資訊的文件。PDF 把字元代碼對映到字形外形,而唯一能把那些代碼對映回可讀文字的,是字型的 ToUnicode CMap(ISO 32000-1 §9.10)。當一套子集化字型出貨時沒帶著它,每一個擷取器都卡住。這套函式庫、檢視器裡的複製貼上、競爭對手的工具包:全部都只剩下從字形名稱瞎猜,或是什麼都不傳回。實務上的回應是偵測,不是逞英雄。把該頁評為低信心並送去 OCR,因為無聲地把垃圾拿去索引,比承認自己讀不了還糟
對於平面選項照顧不到的情況,也就是自訂斷詞、內容串流鑑識、依您自家規則搭起來的文字漏斗,解碼器在下一層就有。TPDFExtractor 是建構在某一頁的資源字典與字型集合之上的。它的 ExtractTextW 方法把原始的內容串流文字操作再送回同一套字型機制去還原 Unicode,而它的 OnFindObject 事件則在每個物件串流經過時把它交到您手上。多數程式碼永遠不需要挖這麼深。真的需要的那些應用程式,會慶幸這一層是公開的、而不是被埋起來的
帶座標的區塊:搜尋命中與遮蔽審查的單位
純文字告訴您這一頁說了什麼。遲早,一項產品還會需要知道它在哪裡說,這樣才能標亮一個搜尋命中、替一個遮蔽候選畫個框,或是把註釋錨到正確的位置上。ExtractPageTextBlocks 傳回一個指向文字區段清單的控制代碼,而每一段都帶著它的文字、它的邊界框,以及它所用的字型名稱與大小:
var
Pdf: TPDFlib;
Blocks, I: Integer;
begin
Pdf := TPDFlib.Create;
try
if Pdf.LoadFromFile('contract.pdf', '') <> 1 then
raise Exception.Create('load failed');
Pdf.SelectPage(1);
Blocks := Pdf.ExtractPageTextBlocks(0);
for I := 0 to Pdf.GetTextBlockCount(Blocks) - 1 do
Writeln(Format('%s [%s %.1f pt at %.0f,%.0f]',
[Pdf.GetTextBlockText(Blocks, I),
Pdf.GetTextBlockFontName(Blocks, I),
Pdf.GetTextBlockFontSize(Blocks, I),
Pdf.GetTextBlockBound(Blocks, I, 0),
Pdf.GetTextBlockBound(Blocks, I, 1)]));
Pdf.ReleaseTextBlocks(Blocks);
finally
Pdf.Free;
end;
end;
這個領域裡有一個細節,絆倒整合的次數比其他任何細節都多。SetTextExtractionArea、SetTextExtractionWordGap 與 SetTextExtractionOptions 是會留著的文件層級狀態,不是您逐次呼叫傳進去的引數。為某一項功能設定了區域限制,比方說只讀頁首那一條帶狀區域來把文件分類,它就會無聲地截斷同一個控制代碼上之後的每一次擷取,包括您稍後才伸手去拿的版面感知 GetPageText 層級。要嘛在各個邏輯任務之間重設擷取狀態,要嘛給每個任務各自的文件控制代碼
字距門檻是對付第一堆失敗、也就是黏在一起的字的那根槓桿。SetTextExtractionWordGap 告訴版面引擎,以頁面自身的字形間距為量尺,多少水平空間才把一個字跟下一個字分開。密集的表格要的間隙比排得鬆散的行銷頁面小,所以逐文件類別調校過的門檻勝過單一的全域常數。它跟其他擷取狀態一樣留在文件上,所以請打算刻意去設定它,而不是設一次就忘了
影像:原始串流,不是螢幕截圖
把影像從 PDF 裡弄出來的錯誤做法,是把頁面算繪出來再裁切。那會把像素重新取樣、把任何旋轉烤進去,並丟掉原本是什麼的一切資訊。GetPageImageList 則是列舉該頁所參照的實際影像資源,而每一個項目都交回它的屬性與它原始、未經擾動的資料:
var
ImgList, I: Integer;
begin
Pdf.SelectPage(1);
ImgList := Pdf.GetPageImageList(0);
for I := 0 to Pdf.GetImageListCount(ImgList) - 1 do
begin
Writeln(Pdf.GetImageListItemFormatDesc(ImgList, I, 0));
Pdf.SaveImageListItemDataToFile(ImgList, I, 0,
Format('page1-img%.2d.bin', [I]));
end;
Pdf.ReleaseImageList(ImgList);
end;
在您對某個項目做任何假設之前,先查 GetImageListItemFormatDesc,因為一頁所參照的,很少是每一張看得見的影像各對應一張整整齊齊的圖。柔邊遮罩會以自己獨立的項目現身。同一個 XObject 常常橫跨許多頁重複出現,所以在您封存一份「全部影像」的匯出之前,請依內容雜湊去重複,否則您會把同一個標誌寫上一百次。CMYK 的 JPEG 需要在下游套用色彩管理,否則在那些把色版照單全收的檢視器裡會算繪成反相。當您要的是整份文件的清冊而不是一次一頁時,FindImages 搭配 SetFindImagesMode 可以一趟掃完整個檔案
有一條界線值得在任何人動筆寫驗收準則之前先跟利害關係人講清楚:影像擷取只傳回點陣資源。以向量路徑畫出來的標誌或圖表,在資源意義上並不是影像,永遠不會出現在任何影像清單裡,不管它在螢幕上讀起來多麼像一張圖。當需求真的是要把那張圖表交付成一個檔案時,誠實的做法是把該頁面區域算繪成點陣圖,而那是另一種操作、有著另一種保真度。這兩種輸出不該在沒有標示誰是誰的情況下擺進同一個匯出資料夾
字型:一個稽核面,不是一項匯出功能
字型 API 回答的是關於字型的問題。它不會把字型檔本身交給您,而這個區別形塑了您能在它之上蓋出什麼。在 FindFonts 掃過文件之後,列舉依 ID 走訪各套字型,而屬性呼叫回報的是當下選取中的那一套:
var
I: Integer;
begin
Pdf.FindFonts;
for I := 1 to Pdf.FontCount do // 字型索引從 1 開始,不是 0
if Pdf.SelectFont(Pdf.GetFontID(I)) = 1 then
Writeln(Format('%s type=%d embedded=%d subset=%d',
[Pdf.FontName, Pdf.FontType,
Pdf.GetFontIsEmbedded, Pdf.GetFontIsSubsetted]));
end;
請盯緊迴圈的邊界。字型索引從 1 跑到 FontCount,而幾段之前的文字區塊與影像清單索引則是從零開始。把其中一種慣例帶進另一種,您就會得到一個差一錯誤,不是跳過第一套字型就是跑出界,而且它會通過隨手做的測試,因為多數文件都有好幾套字型,錯的那一套看起來依然說得過去。範圍也要講清楚。這套 API 沒有位元組層級的字型匯出。沒有任何呼叫會把內嵌的字型程式當成 TTF 或 OTF 檔傳回來,列舉加上中繼資料檢視就是全部的預期模型。那個模型仍然涵蓋了生產工作真正會對字型提出的要求:依名稱樣式偵測子集、在封存轉換之前做嵌入稽核(未嵌入的字型是 PDF/A 的硬性阻擋,Delphi 中的 PDF/A 與 PDF/UA 預檢對此有所著墨),以及在擷取信心下滑時做編碼診斷。這條界線畫在這裡還有一個授權上的理由。一份子集字型程式是受授權保護的材料,而且它少了大部分字形,本來就沒法當成可安裝的字型用。把它當成稽核用的中繼資料而不是可擷取的資產,才是您站得住腳的立場
最後那個呼叫在分流時很出力。對每一套字型跑 GetFontEncoding,把它跟子集旗標並排來讀,您就能在拉出任何一個字元之前預測擷取品質。一頁上的字型如果全都是子集化且帶非標準編碼,光看就知道是 OCR 的候選,這讓批次管線能正確地把它導向,而不必先在它身上白白浪費一趟失敗的擷取
不載入文件的大規模擷取
在批次管線裡,只為了讀一頁就把整份文件載進來是浪費 I/O,而且跨越一整批語料後會迅速累加。單次呼叫的變體 ExtractFilePageText 與 ExtractFilePageTextBlocks 直接收檔名、密碼與頁碼,並跳過完整載入。對 GB 級的檔案,還有更低的一檔。直接存取路徑以串流方式讀 xref 來開啟檔案,所以 DAOpenFileReadOnly 後面接 DAExtractPageText,只會碰到那一頁實際需要的物件。它帶著一個值得記進腦子的慣例轉換:DA 函式以 PageRef 來定址頁面,那是一個您從 DAFindPage 拿到的物件參照控制代碼,絕不是原始頁碼。在該放控制代碼的地方傳了數字進去,這次呼叫會對錯的物件動手而不引發任何錯誤,那是最難除錯的一種錯誤。直接存取工具箱的其餘部分陳列在 大型 PDF 的合併、分割與直接存取
如果說有哪一個習慣,把撐得過真實語料的擷取程式碼跟一跛一跛的程式碼分開來,那就是把頁面當成不可信任的輸入,而不是一份乾淨的資料來源。跟檢視器所算繪的東西對不上的文字,幾乎總是編碼問題,可能是一個連字塌縮成單一字形,也可能是子集字型少了它的 ToUnicode 項目,而修法是量測信心並把壞頁面轉去 OCR,不是跟位元組硬拚。字型 API 依設計永遠不會產出 TTF 或 OTF,所以請圍繞稽核問題來建構字型工作流程。而那些會留存的擷取狀態,尤其是區域矩形,是您在一個文件控制代碼的整個生命期都要負責的設定,不是呼叫一次之後就忘掉的參數。把這三個反射練對,API 的其餘部分就會乖乖聽話
評估版建置、示範專案與完整的擷取 API 參考在 losLab PDF Library for Delphi 產品頁