一份 PDF 文字頁只公開字元與框,從來沒有行。PDFium Component 透過把垂直中心落在種子字元高度一半以內的字元框聚類起來,從點擊的字元往外掃描,直到超出容差為止,藉此建構一個視覺行。檢視器裡的每條選取路徑都呼叫那同一個輔助函式,所以滑鼠、鍵盤與程式碼結果一致
把你帶來找這個的症狀,具體而不討喜。使用者在一份雙欄報告的段落上三擊,卻選到了半頁。或者他們在一個表格儲存格上三擊,選取範圍卻吞掉了整列,連同頁尾的頁碼。檢視器沒有壞;它正在問一個檔案答不出來的問題。一份 PDF 裡沒有可供選取的「行」,任何假裝有的實作都是在猜。這篇文章談的是如何把這個猜測變得刻意、變得一致。如果你真正需要的是把文字從文件裡拉出來,請看用 PDFium 從 PDF 文件擷取文字;如果你在排版文字、需要寬度,請看文字量測與自動換行。這裡談的範圍比較窄:決定一個視覺行從哪裡開始、到哪裡結束,並精確選取那個範圍
為何一份 PDF 文字頁沒有行物件?
因為一個 PDF 內容串流描述的是繪製,不是結構。ISO 32000-1 §9.4 把文字物件定義成一對 BT / ET,包含定位與顯示運算子。§9.4.2 的定位運算子(Td、TD、Tm、T*)在頁面上移動一個文字矩陣,§9.4.3 的顯示運算子(Tj、TJ、'、")則在該矩陣當下指向的位置畫出字符。這個模型裡沒有任何東西說「這串字符是一行」。行是人類在繪製完成之後看到的東西
產生器會用你控制不了的方式讓情況更糟。一個左右對齊的段落,可能以每行一個 TJ 陣列輸出,也可能以每字一個 Tj、前面帶一個明確的 Tm 輸出,或以帶著字距調整的單一顯示操作攜帶間距。一個雙欄版面,可能從左欄由上到下輸出,再輸出右欄,也可能在產生器按自己內部物件清單以不同順序走訪時交錯輸出兩者。PDFium 交給你的字符序列跟著內容串流走,內容串流則跟著產生應用程式想做什麼走。所以你真正拿到的兩個函式是 FPDFText_CountChars,回報這一頁有多少字元,以及 FPDFText_GetCharBox,回傳頁面空間中某個字元的邊界框。這就是全部的原始詞彙。在它之上的一切,單字、行、段落、欄,都是你對幾何做的推論
為何用 CR 與 LF 偵測是錯誤的測試?
因為你會拿來測試的那些字元並不可靠地存在,而在它們存在的時候,也不可靠地屬於你。PDFium 會把合成字元注入文字頁,好讓擷取出來的文字可讀:兩段視覺上分開的部分之間放一個空格,下一段從新基線開始的地方放一個 CR 或 LF。FPDFText_IsGenerated 存在的目的正是讓你能把這些跟來自檔案本身的字元分開,PDFium Component 把它公開成 CharacterGenerated 屬性
照這些字元切分,你就繼承了 PDFium 在合成它們時做的每一個判斷。一個折行段落裡的硬性換行,和一個軟性自動換行,合成之後看起來一模一樣。一個產生器逐格輸出的表格列,可能在最後一格與下一列的第一格之間完全沒有斷行,因為基線恰好靠得夠近。與此同時,一個標題後面接著不同字級的內文,卻可能得到兩個斷行,而人類看到的只有一個。這些合成字元,是為了整頁擷取而做的一種渲染便利手段;它們不是一個行模型,而且它們正好會在選取最要緊的那些文件上劣化
依垂直中心聚類字元框
可靠的訊號是幾何。把使用者點擊的字元當成種子,計算它的框的垂直中心,往兩個方向往外走,只要相鄰框的垂直中心維持在容差範圍內,就繼續走。PDFium Component 用種子框高度的一半作為那個容差,並設一個 0.5 頁面單位的下限,好讓退化的框(一個句點、一個細空格、一個高度近乎零的字符)不會把容差壓縮到什麼都沒有,讓行在一個字元之後就被切斷
function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
out StartIndex, Count: Integer): Boolean;
var
Lo, Hi, Total: Integer;
SeedBox, Box: TPdfRectangle;
SeedYMid, BoxYMid, HalfH: Double;
begin
Result := False;
StartIndex := -1;
Count := 0;
Total := FPDFText_CountChars(TxtPage);
if (CharIndex < 0) or (CharIndex >= Total) then
Exit;
if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
SeedBox.Bottom, SeedBox.Top) = 0 then
Exit;
SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
if HalfH < 0.5 then // floor for degenerate boxes
HalfH := 0.5;
Lo := CharIndex;
Hi := CharIndex;
while Lo > 0 do
begin
if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
Box.Bottom, Box.Top) = 0 then
Break;
BoxYMid := (Box.Top + Box.Bottom) / 2;
if Abs(BoxYMid - SeedYMid) > HalfH then
Break;
Dec(Lo);
end;
while Hi < Total - 1 do
begin
if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
Box.Bottom, Box.Top) = 0 then
Break;
BoxYMid := (Box.Top + Box.Bottom) / 2;
if Abs(BoxYMid - SeedYMid) > HalfH then
Break;
Inc(Hi);
end;
StartIndex := Lo;
Count := Hi - Lo + 1;
Result := True;
end;
那段迴圈裡有三個細節值得一提。容差是從種子推導出來的,而不是一個常數,所以一個 24pt 的標題得到一個寬頻帶,一個 7pt 的註腳文字得到一個窄頻帶,兩者都不會偷走對方的字符。比較用的是垂直中心,而不是基線或框頂,這讓上標、一個內嵌的不同字級片段,或一個混合字型的句子,能與它的鄰居維持在同一行。而一次失敗的 FPDFText_GetCharBox 會終止掃描,而不是被跳過,因為一個沒有可取得幾何資訊的字元,不能提供任何一方向的證據,繼續掃過它,只會讓這次走訪憑著更遠一個字元的力量,跳過一個真正的邊界
為何每條選取路徑都必須共用同一個輔助函式?
因為三條各自實作「這一行」的程式路徑會產生分歧,而且會悄悄分歧。在 PDFium Component 裡,三擊展開、Shift+Home、Shift+End,以及公開的 SelectLineAt 方法,全都透過同一個 LineRangeAt 呼叫來解析它們的邊界。三擊從選取錨點播種;shift 鍵從選取游標播種,只移動那一端;SelectLineAt 從呼叫端提供的字元索引播種,把結果交給 SelectTextRange,也就是滑鼠路徑使用的同一個範圍驗證器。若改成把邏輯重複實作,失敗不會是一次當機,而是一次緩慢的漂移。有人為了修一份行距很緊的報告,調整了三擊的容差,結果 Shift+End 在同一個段落上停得比三擊短一個字元。使用者用滑鼠選了一行,用鍵盤延伸它,卻看著選取範圍縮水。因為 SelectLineAt 餵給的是一般的選取管線,程式化選取也就與滑鼠輸入是否啟用保持獨立,並且照樣免費得到範圍驗證、重繪與 OnSelectionChange 通知
// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
CharIndex: Integer;
begin
CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
if CharIndex < 0 then
Exit;
if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
Memo1.Lines.Add(PdfView1.SelectedText);
end;
注意 CharacterIndexAtPos 上的容差引數。命中測試有自己的鬆緊度,以頁面單位表示,這是與行容差不同的獨立考量。一次點擊若落在兩行之間的行距上,會解析成範圍內最近的字元;行掃描接著會從那個結果的字元開始跑。把一個過於寬鬆的命中容差餵進種子,是選到使用者根本沒指向的那一行的簡單方式之一
兩種索引空間:字元索引與文字索引
一旦拿到一個範圍,要抗拒把它當成字串偏移量使用的衝動。FPDFText_GetText 回傳頁面文字,是一個 UTF-16 緩衝區,但它的索引與 FPDFText_GetCharBox 及 FPDFText_CountChars 使用的字元索引,不是同一個索引空間。前面提到的合成字元,會佔用沒有可用幾何資訊的字元槽位,同時存在於文字緩衝區裡,這兩套編號會隨著頁面推進而逐漸分歧。橋接它們的是 FPDFText_GetTextIndexFromCharIndex 與 FPDFText_GetCharIndexFromTextIndex,PDFium Component 把它們包裝成 CharacterIndexToTextIndex 與 TextIndexToCharacterIndex
var
TextStart, TextEnd: Integer;
begin
// char-index range from LineRangeAt -> offsets into the page text buffer
TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
TextEnd := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
if (TextStart >= 0) and (TextEnd >= TextStart) then
Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;
最咬人的方向是反過來那個。一個實作在擷取出來的字串上做搜尋,給你的是文字索引,若把它們直接傳給一個框或選取 API,會悄悄定址到錯誤的字元,而且誤差會隨著頁面往下走越變越大。在任何幾何相關的東西碰這個數字之前,先用 TextIndexToCharacterIndex 轉換它。代理對(surrogate pairs)在這之上又加了一個獨立的偏移量問題,涵蓋於表情符號、中日韓文字與代理對一文
這套啟發式在哪裡會失靈
要對自己誠實面對這些限制,因為它們是真實存在、而且可能遇到的。旋轉文字是最清楚的案例:一個字元框在頁面空間裡是一個軸對齊矩形,所以對旋轉 90 度的文字而言,一個視覺行的框,其垂直中心會散布在整個頁面上,掃描幾乎馬上就會停下。你得到的是一個過短的選取,而不是一個錯誤的選取,這是比較好的失效方式,但它依然是一種失敗。垂直書寫模式出於同樣的理由,行為也一樣。雙欄版面在兩欄垂直方向彼此有落差時能運作,在沒有落差時就會失效。如果兩欄共用同一套基線網格,右欄的字元就會落在左欄那一行的容差範圍內,掃描會直直穿過欄間空白跑過去,因為在純幾何上,那裡什麼都沒有能讓它停下。要偵測到這一點,需要在垂直聚類之上再加一個水平間距測試,而挑選這個間距門檻,本身就是關於你願意在哪些文件上出錯的一個判斷。混合字級是種子相對容差處理得很好的案例:一段內嵌在 11pt 內文裡的 8pt 程式碼片段,中心依然落在頻帶裡,下一條基線上的一個 24pt 標題則不會把內文行拉進自己
這裡描述的行選取語意,隨 Delphi 與 C++Builder 版的 PDFium Component 一併出貨,連同範例中用到的命中測試、選取範圍與文字索引 API;產品頁面收錄文字頁與選取模型的完整參考