提取一个页面的文字是問题中簡單的那一半。当使用者在搜寻方塊中输入一个詞,并期望查看器能跳到该处且在周围畫上一个黃色方塊时,你便需要扁平文字字串无法給你的东西:每个命中项目所在的页面,以及它在 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 的差異之处
第二個参数是从 TPDFlibSearchOptions 和 soCaseSensitive 中抽取的 soWholeWord 集合。空集合 [] 是常見的情況:不区分大小写的子字串搜寻。加上 soCaseSensitive 以使 Indemnity 和 indemnity 有所区別,加上 soWholeWord 以防止 sign 在 signature 内部符合,或者两者結合使用
全字拼写 (whole-word) 匹配需要一个对單詞边界是什么的定义,而这里的规则值得明白陳述,因为它在设計上是以 ASCII 为中心的。当一个字符是 ASCII 字母、ASCII 数字或底线时,它就算作單字的一部分:也就是大家从識別字规则中熟知的 [A-Za-z0-9_] 类別。只有当匹配项目紧接在它前后的字符不是單字字符时(或者匹配项目位於块的边緣时),该匹配才符合全字拼写的资格
对於非拉丁文字而言,其結果是你在发布多語言搜寻方塊之前应该要知道的。因为漢字、假名及其他非 ASCII 字母皆落在该类別之外,所以它们旁边的每个边界都会被读成非單字边緣。实际上,这意味著在 CJK 文字上進行全字拼写搜寻的行为,会表现得彷彿每个位置都是一个有效的單字边界,因此该旗标在那里实际上退化成了子字串搜寻。这是一个記录在案的限制,而不是一个 bug,而且它与该功能所仿效的行为是一致的。如果你的語料庫主要是 CJK,全字模式将无法給你一个专门的斷詞器所能提供的分詞效果;请围繞它進行計畫,而不是依賴它
有一个实作注释解释了在其他地方出现的某类微妙失敗:不区分大小写的比較是对 UpperCase 使用 WideString,而不是 AnsiUpperCase。Ansi 變体会传回一个 AnsiString,那将无法与该路径其餘部分所使用的 WideString 对齊,而混合这两者会产生型別不符,更糟的是,会对使用中字码页之外的字符产生有損的摺疊 (lossy folding)。全程都是 Unicode 進,Unicode 出
整個函式庫共用同一个页面范围解析器
第三個参数是一个页面范围字串,例如 "1,3,5-9"。它的解析方式没有什么自訂之处:支援 PLParsePageRangeList 以及页面复制常式的同一个 PrintPages 在这里也負責处理它,所以一个能正確列印的范围就能正確地被搜寻。一个空范围字串是“每一页”的哨兵值,在这种情況下,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 函式庫的一部分提供