HotPDF Delphi Component 在 THotPDF.ExtractLoadedPageText 裡從字形幾何重建字間空格與換行,不靠空格字元。字形的自身寬度之後的間隙超過文字高度的 0.15,就插入一個空格;文字原點沿書寫方向移動超過文字高度的一半,才開新行。從 v2.768.3 起,頁面文字也涵蓋透過 Form XObjects 畫出的文字,並排除可見裁切區之外的字形。這篇剩下的部分解釋每條規則為什麼長成這個樣子——因為每一條都取代過一條更簡單的規則,而那條簡單規則在真實文件上產出的是看似合理卻錯誤的結果
把 PDF 文字餵進搜尋索引的人,對這些症狀不會陌生。封面頁抽出來是 PDFReferenceManualNovember4,1998,一份報稅表被拆成 156 行,斜向浮水印一行一個字元,裁切過的打樣以誰也看不到的印版標記行開頭。這些檔案沒有一個是壞的——每一個都用了一種完全合法的文字擺放方式,天真的擷取器恰好讀錯
抽出的 PDF 文字為什麼會丟掉字間空格?
抽出的文字會丟空格,是因為 PDF 從不被要求含有空格。產生端可以用空格字元分詞,但也完全可以用 TJ 陣列裡的數字挪筆(ISO 32000-1 §9.4.3),或用一個新的 Td(§9.4.2)——TeX 輸出、許多 Distiller 檔案與大多數兩端對齊的版面正是這麼幹。v2.766.76 之前,HPDFAssemblePageText 只看垂直移動,靠定位做出的分詞就這麼蒸發了。組裝器現在沿前一個字形的書寫方向,量測從該字形自身寬度末端到當前字形原點的距離,距離超過當前字形框高度(使用者空間裡從上升部到下降部)的 0.15 就插入一個空格。任一側本來就是空白時不加,兩個 CJK 字元之間也不加——兩端對齊會把漢字拉開,那個拉開並不意味著詞邊界。字形記錄暴露同一套幾何,某個檔案讓您困惑時,可以自己重演這個判定
uses
SysUtils, HPDFDoc, HPDFContentStream;
procedure DumpWordGaps(Pdf: THotPDF; PageIndex: Integer);
var
Glyphs: THPDFGlyphArray;
I: Integer;
Height, Gap: Double;
begin
if not Pdf.ExtractLoadedPageGlyphs(PageIndex, Glyphs) then
Exit;
for I := 1 to High(Glyphs) do
begin
// 字形框從上升部到下降部的高度,使用者空間
Height := Sqrt(Sqr(Glyphs[I].QuadX[3] - Glyphs[I].QuadX[0]) +
Sqr(Glyphs[I].QuadY[3] - Glyphs[I].QuadY[0]));
// 水平文字:與前一個字形自身寬度末端的間隙
Gap := Glyphs[I].BaselineStartX - Glyphs[I - 1].GlyphEndX;
if (Height > 0) and (Gap > 0.15 * Height) then
Writeln(Format('U+%.4x gap %.2f height %.2f: space',
[Glyphs[I].Unicode, Gap, Height]));
end;
end;
為什麼從字形自身寬度量起,而不是從筆的位置?
HotPDF 從 GlyphEndX / GlyphEndY 量字間間隙,因為字形之後的筆位置已經含有不是間隙的間距。ISO 32000-1 §9.4.4 定義水平位移為字形寬度乘字號、加字距 Tc、加字間距 Tw,全部再乘 Tz。BaselineEndX / BaselineEndY 裝的是完整位移,GlyphEndX / GlyphEndY 只裝字型前進量與 Tz。差別對「用負 Tc 收緊字距、再在每個字形後用 TJ 調整把空間還回來」的產生端至關重要:從筆位置量,還回去的部分看起來就像間隙——中文詞「95后」被抽成「9 5 后」。閾值綁字形框高度而非 Tf 字號,理由類似:Word 匯出常寫 1 Tf、把真正字號放在縮放過的 Tm 裡,Tfs 說 1、文字卻有 10 點高,綁 Tfs 的規則會把同一頁的兩種拼寫區別對待
這條規則有誠實的邊界。用極鬆字距排的標題——Tc 一項就在字母間撐出超過文字高度 0.15 的空隙——每個字母之間都抽出一個空格,那是頁面看起來的樣子,卻多半不是您想索引用的。同一條基線上不按順序畫出的片段會產生負間隙、不加空格地黏在一起。兩種情況在正文裡都不常見,而測試語料上這一改動讓與參考擷取器的詞彙比對在 28 頁上變多、沒有任何一頁變少
HotPDF 什麼時候在抽出文字裡開新行?
從 v2.766.79 起,當從前一個字形原點到當前原點的移動、投影到前一個書寫方向法線之後,超過兩個字形中較大框高的一半,就開新行。舊規則拿原始 Y 移動跟 Tfs 的一半比,兩個方向都會失敗。1 Tf 加縮放的 Tm 時閾值縮到半個單位,於是被 0.4 的文字抬升頂上去的上標、或平凡的基線抖動都會斷行。這條規則也完全無視 X,於是旋轉 Tm 下的文字每個字形都往下走一步,一行一個字。投影到方向法線讓旋轉串的行為與水平串一致;取兩個高度的較大者,讓共享基線的大號範例詞與它的小號說明留在同一行。前述報稅表的行數從 156 降到 97。書寫模式 1(§9.7.4.3)的直排文字走另一條路:那些字形按欄分組、從右到左、從上到下閱讀,每次換欄一個換行
ExtractLoadedPageText 收哪些文字、丟哪些文字?
ExtractLoadedPageText 回傳檢視器顯示的文字。從 v2.766.80 起,它只以可見字形為準,丟掉框中心落在 GetLoadedPageVisibleBox(裁到 MediaBox 的 CropBox,§14.11.2)之外的每一個字形。印版標記行與其他排在裁切區外的印刷記號就此消失。ExtractLoadedPageGlyphs 則刻意繼續回傳頁面內容串流的每個字形,需要那些材料時仍然找得到。這個過濾是框測試,不是可見度測試:被剪裁路徑藏住、以白色畫出或被影像蓋住的文字照樣抽出
var
Pdf: THotPDF;
Glyphs: THPDFGlyphArray;
PageText: UnicodeString;
L, B, R, T: Single;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('trimmed-proof.pdf');
if Pdf.GetLoadedPageVisibleBox(0, L, B, R, T) then
Writeln(Format('Visible box: %.1f %.1f %.1f %.1f', [L, B, R, T]));
// 頁面內容串流的每個字形,印版標記行也算
if Pdf.ExtractLoadedPageGlyphs(0, Glyphs) then
Writeln(Length(Glyphs), ' glyphs in the page content stream');
// 只有頁面顯示的內容,拼接進 Form XObject 文字
if Pdf.ExtractLoadedPageText(0, PageText) then
Writeln(PageText);
finally
Pdf.Free;
end;
end;
從 v2.768.3 起,透過 Form XObjects 畫出的文字屬於頁面文字。頁首、印章與浮水印常常住在 form 裡,修復之前有些標準文件丟掉三到三成半的字元。THotPDF.InterpretContentWithForms 記下每個 Do 與當下的 CTM,以 form 的 /Matrix 乘上該 CTM 解譯 form(§8.10.1),把 form 的字形拼接進 Do 所在位置,並遞迴進巢狀 form。自帶 /Resources 的 form 缺席時,借用畫它的串流的資源,§7.8.3 允許這麼做。form 字形帶 TokenIndex = -1,ExtractLoadedPageGlyphs 仍只回傳頁面串流字形,因為搜尋、替換與遮蓋是透過 TokenIndex 把修改寫回去的,form 字形混進來就會改錯位元組。兩個簡化值得知道:form 文字不被裁到 form 的 /BBox;遞迴在 12 層停止,而不是靠環偵測,所以畫自己的畸形 form 會重複它的文字直到撞上那個上限
為什麼 Q 之後的文字會解碼成亂碼?
v2.766.73 之前,Q 之後的文字可能解碼錯誤,因為擷取器在 q 上只保存了 CTM。文字狀態參數——字型、字號、Tc、Tw、Tz、TL、渲染模式與抬升——屬於圖形狀態(§9.3.1),所以 Q 必須把它們與堆疊上的一切一起還原(§8.4.2)。某份業界報告在 q … Q 裡選了雙位元組的 Identity-H 字型,接著顯示沒有自己 Tf 的單位元組 WinAnsi 文字。擷取器留著內層字型,把目錄頁的前導點與「Adobe」當雙位元組碼讀,頁面字元因此少了 15%。直譯器的 q/Q 堆疊現在保存完整文字狀態。這裡描述的擷取規則對每一頁都成立,所以整份文件一次呼叫就能進檔
var
Output: TFileStream;
Pages: Integer;
begin
Output := TFileStream.Create('report.txt', fmCreate);
try
// 空範圍 = 全部頁面;頁間用換頁符;帶 UTF-8 BOM
Pages := Pdf.ExtractLoadedPagesTextToStream(Output, '', #12, True);
Writeln(Pages, ' pages extracted');
finally
Output.Free;
end;
end;
HotPDF 的文字 API 該用哪個?
ExtractLoadedPageText 保持內容串流順序,這對搜尋與索引是正確預設;它底下的解碼鏈見用 HotPDF 從已載入 PDF 抽取文字。撰寫順序要緊的標籤化文件,結構順序文字擷取改走結構樹而不是從幾何猜;鎖在表格裡的資料,跨頁的型別化表格擷取回傳儲存格而不是行。完整 API 參考與試用下載在HotPDF Delphi PDF Component 產品頁