提取一個頁面的文字是問題中簡單的那一半。當使用者在搜尋方塊中輸入一個詞,並期望檢視器能跳到該處且在周圍畫上一個黃色方塊時,您便需要扁平文字字串無法給您的東西:每個命中項目所在的頁面,以及它在 PDF 座標中所佔據的矩形。一個跨頁面串接起來的字串已經失去了那份幾何資訊。您可以找到該子字串,但您無法指向它
PDFlibPas 是一個適用於 Delphi 與 C++Builder 的原生 Object Pascal PDF 函式庫,而從 v3.78.0 開始,它精準回答了那個問題。三個查詢 API 建立在現有的文字區塊提取器之上:SearchText 走訪一個頁面範圍並傳回每個命中項目及其頁面與軸對齊 (axis-aligned) 矩形,EnumPageElements 列出一頁上的所有內容(文字區塊與嵌入圖片皆然),而 GetTextInAreaEx 則回報一個區域內每個區塊的矩形,而不是將它們展平為字串清單。這些都不會碰觸寫入路徑;它們是在函式庫已經擁有的機制上純粹的讀取端新增功能
為什麼幾何資訊存活於文字區塊清單,而不是漏斗中
直覺反應是去重複使用 GetPageText 在內部運作時所用的任何東西。那條路徑會經過一個短暫的提取「漏斗 (funnel)」,它產生頁面字串後,在呼叫傳回前就會將自身釋放。當您拿到結果時,每個區塊的座標早已不復存在。它們從來就不屬於您
座標確實存活在一個不同的結構中。ExtractPageTextBlocks(3) 傳回一個文字區塊清單的控制代碼,其每個項目都攜帶一個由八個雙倍精度浮點數 (eight-double) 組成的邊界四邊形、一個字型名稱、一個字型大小,以及該區塊的文字。那個控制代碼是提取後幾何資訊被保留的唯一位置,這也是為什麼每一個新的查詢 API 都是建立在它之上,而不是在漏斗上。重複使用該區塊清單,意味著搜尋、列舉以及區域查詢全都共用一次提取通道,以及對一個區塊位於何處的一致定義
因此 SearchText 的形式就遵循了這個限制。對於範圍內的每一頁,它提取區塊清單,用 GetTextBlockText 讀取每個區塊的文字,針對查詢進行測試,而對於相符的區塊,它將四邊形縮減為一個矩形。它傳回的命中結果是一個小巧的紀錄 (record):
type
TPDFlibSearchHit = record
Page: Integer; // 1-based page of the match
Left, Top, Right, Bottom: Double; // axis-aligned hit rectangle
MatchText: WideString; // the block text that contained the query
end;
邊界陣列是 X/Y 交錯的,而不是四個角落
這是一個會先咬人的細節。GetTextBlockBound(ListID, Index, BoundIndex) 接受一個 1 到 8 的 BoundIndex,而這八個值並非您可能會猜想的那樣將兩個欄位分組在一起的「角落 1、角落 2、角落 3、角落 4」。它們是 X, Y, X, Y, X, Y, X, Y:奇數索引是 X 座標,偶數索引是 Y 座標,總共四個點。如果您用錯誤的配對方式去讀取它們,您的矩形將毫無意義
之所以會有個四邊形而不是一個純矩形,原因在於旋轉。一個以某個角度設定的文字區塊,會有一個貨真價實的四點邊界多邊形,而這八個雙倍精度浮點數忠實地描述了它。對於反白顯示和跳轉的使用情境,您幾乎總是想要一個直立的方塊,因此該函式庫藉由掃描四個點的 X 與 Y 的最大值和最小值,將四邊形縮減為一個軸對齊矩形。旋轉的文字會收縮成包圍著它的直立方塊,這正是反白疊加圖層所需要的:
var
Pdf: TPDFlib;
Hits: array[0..255] of TPDFlibSearchHit;
Found, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('contract.pdf', '');
// Search pages 1 to 10, case-insensitive, substring match.
Found := Pdf.SearchText('indemnity', [], '1-10', Hits);
for I := 0 to Found - 1 do
if I <= High(Hits) then
WriteLn(Format('p%d: [%.1f %.1f %.1f %.1f] %s',
[Hits[I].Page, Hits[I].Left, Hits[I].Top,
Hits[I].Right, Hits[I].Bottom, Hits[I].MatchText]));
finally
Pdf.Free;
end;
end;
請注意,該矩形是採用原點位於頁面左下角的 PDF 使用者空間點,這與您傳遞給繪圖和註解呼叫的座標系統相同。這是刻意設計的:您從搜尋命中結果拿到的矩形,就是您可以直接交給反白註解或「捲動至此」命令的矩形,而無需轉換任何東西
大小寫區分、全字拼寫,以及 CJK 的差異之處
第二個參數是從 soCaseSensitive 和 soWholeWord 中抽取的 TPDFlibSearchOptions 集合。空集合 [] 是常見的情況:不區分大小寫的子字串搜尋。加上 soCaseSensitive 以使 Indemnity 和 indemnity 有所區別,加上 soWholeWord 以防止 sign 在 signature 內部符合,或者兩者結合使用
全字拼寫 (whole-word) 匹配需要一個對單詞邊界是什麼的定義,而這裡的規則值得明白陳述,因為它在設計上是以 ASCII 為中心的。當一個字元是 ASCII 字母、ASCII 數字或底線時,它就算作單字的一部分:也就是大家從識別字規則中熟知的 [A-Za-z0-9_] 類別。只有當匹配項目緊接在它前後的字元不是單字字元時(或者匹配項目位於區塊的邊緣時),該匹配才符合全字拼寫的資格
對於非拉丁文字而言,其結果是您在發布多語言搜尋方塊之前應該要知道的。因為漢字、假名及其他非 ASCII 字母皆落在該類別之外,所以它們旁邊的每個邊界都會被讀成非單字邊緣。實際上,這意味著在 CJK 文字上進行全字拼寫搜尋的行為,會表現得彷彿每個位置都是一個有效的單字邊界,因此該旗標在那裡實際上退化成了子字串搜尋。這是一個記錄在案的限制,而不是一個 bug,而且它與該功能所仿效的行為是一致的。如果您的語料庫主要是 CJK,全字模式將無法給您一個專門的斷詞器所能提供的分詞效果;請圍繞它進行計畫,而不是依賴它
有一個實作註解解釋了在其他地方出現的某類微妙失敗:不區分大小寫的比較是對 WideString 使用 UpperCase,而不是 AnsiUpperCase。Ansi 變體會傳回一個 AnsiString,那將無法與該路徑其餘部分所使用的 WideString 對齊,而混合這兩者會產生型別不符,更糟的是,會對使用中字碼頁之外的字元產生有損的摺疊 (lossy folding)。全程都是 Unicode 進,Unicode 出
整個函式庫共用同一個頁面範圍解析器
第三個參數是一個頁面範圍字串,例如 "1,3,5-9"。它的解析方式沒有什麼自訂之處:支援 PrintPages 以及頁面複製常式的同一個 PLParsePageRangeList 在這裡也負責處理它,所以一個能正確列印的範圍就能正確地被搜尋。一個空範圍字串是「每一頁」的哨兵值,在這種情況下,SearchText 會自行建置完整的清單
範圍影響成本。在一份一千頁的文件中搜尋十頁的片段,會提取十頁的區塊,而不是一千頁,因為迴圈只選擇並提取範圍所命名的頁面。當您已經知道某個條款位於附錄中,請在範圍中說明並跳過檔案的其餘部分
在內部,搜尋和列舉在迭代時都會改變已選擇的頁面,所以每一個都會在進入時儲存呼叫者已選擇的頁面,並在 finally 區塊中將其還原。在建立頁面的中途呼叫 SearchText,當呼叫傳回時,您的選擇恰好就在您離開它的地方。那種儲存與還原的契約,是那種您只有在它缺失時才會注意到的東西,這也正是它存在的原因
列舉整個頁面:一個清單中的文字與圖片
搜尋回答了「這個字在哪裡」。內省的另一半是「這一頁上到底有什麼」,而這就是 EnumPageElements。它傳回一個統一的清單,其中每個元素要麼是文字區塊,要麼是嵌入的圖片,由 Kind 欄位來區分:
type
TPDFlibPageElementKind = (ekText, ekImage);
TPDFlibPageElement = record
Kind: TPDFlibPageElementKind;
Page: Integer;
Left, Top, Right, Bottom: Double;
Text: WideString; // ekText
FontName: WideString; // ekText
FontSize: Double; // ekText
ImageID: Integer; // ekImage; usable with SelectImage / GetImageID
end;
文字元素來自相同的 ExtractPageTextBlocks 通道,因此每個元素抵達時,其矩形、字型名稱和大小都已經填好。圖片元素則透過 FindImages 和 GetImageID 來自頁面的嵌入圖片清單;它們所攜帶的 ImageID 是您餵給 SelectImage 以進一步檢查圖片的控制代碼。這兩種元素落在同一個陣列中,因此只要對頁面走訪一次,就能看見上面的所有東西
var
Pdf: TPDFlib;
Elems: array[0..511] of TPDFlibPageElement;
Total, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('report.pdf', '');
Total := Pdf.EnumPageElements(1, Elems);
for I := 0 to Total - 1 do
if I <= High(Elems) then
if Elems[I].Kind = ekText then
WriteLn(Format('text %s/%.1f "%s"',
[Elems[I].FontName, Elems[I].FontSize, Elems[I].Text]))
else
WriteLn(Format('image id=%d', [Elems[I].ImageID]));
finally
Pdf.Free;
end;
end;
這裡有一個遵循函式庫其餘部分的計數慣例,而且您必須遵守,否則您將讀取到未初始化的記憶體。傳回值是元素的總數,它可以大於您傳入的陣列。該函式只會填滿能容納的槽位數量,並繼續計算剩餘的數量,這與簽章列舉的運作方式完全相同。所以防護措施永遠是一樣的:將您的迴圈箝制在傳回計數與 High(array) 兩者中較小的值,絕對不要盲目地迭代到該計數。上面展示出 I <= High(...) 檢查就是這個原因。如果傳回值超過了您的緩衝區,請調整一個更大的陣列並再次呼叫
如果您曾經使用過該函式庫較低階的文字區塊呼叫,這就是覆蓋在它們之上、有型別且能感知幾何的抽象層;底層的提取機制與使用 PDFlibPas 進行 Delphi PDF 文字、圖片及字型提取中所描述的相同。而當目標不是「這段文字在哪裡」,而是「這份文件為了無障礙技術而做了何種結構設計」時,平行的讀取端故事則是標記的 PDF (tagged-PDF) 結構樹,它揭露了邏輯上的閱讀順序,而不是實體的區塊版面配置
當您已經知道去哪裡找時的區域查詢
有時候您根本沒有搜尋詞;您有的是一個矩形。一份表單範本總是把發票號碼放在右上角,或者一份掃描的版面配置為一張表格保留了一個固定的條帶。GetTextInAreaEx 就是為該情況提供服務。它是 GetTextInArea 帶有邊界版本的對應項目:舊的呼叫會傳回一個區域內字串的扁平清單,而新的呼叫則會在傳回每個保留區塊文字的同時,一併傳回它的矩形,因此您不僅能得知方塊裡有什麼,還能知道每一行位在裡面的哪裡
var
Pdf: TPDFlib;
Hits: array[0..63] of TPDFlibSearchHit;
Found, I: Integer;
begin
Pdf := TPDFlib.Create(nil);
try
Pdf.LoadFromFile('invoice.pdf', '');
Pdf.SelectPage(1);
// Left, Top, Width, Height in PDF points on the selected page.
Found := Pdf.GetTextInAreaEx(360, 720, 180, 60, Hits);
for I := 0 to Found - 1 do
if I <= High(Hits) then
WriteLn(Hits[I].MatchText);
finally
Pdf.Free;
end;
end;
有兩件事要理清。GetTextInAreaEx 在目前選擇的頁面上運作,所以請先呼叫 SelectPage;與 SearchText 不同的是,它不接受範圍。而且一個區塊只有在它與查詢矩形相交 (intersect) 時才會被保留,不僅僅是當它完全包含在內時,所以橫跨邊界的行依然會被傳回。對於手繪的選取方塊來說,這通常是您想要的,但如果您需要嚴格的包含關係,您可以自己過濾傳回的矩形,因為您現在已經擁有它們了
投入使用
貫穿這三個呼叫的主線是,幾何資訊不再是您事後才重建的東西。一個搜尋命中項目知道它所在的頁面和它的方塊。一個頁面元素知道它的矩形,以及如果是文字的話,它的字型。一個區域查詢會回報每一行落在哪裡。這就足以建立一個真正的尋找並反白功能、一個點擊跳轉的索引,或是能感知版面配置的提取器,而無需退回到公開 API 之下,也無需手工重建文字提取管線
這些查詢 API 與它們所建構在其上的完整文字區塊提取層,以及適用於 Delphi 和 C++Builder 之讀取端內省介面的其餘部分,都一起作為 PDFlibPas Delphi PDF 函式庫的一部分提供