技術文章

修正 PDF 擷取文字中假粗體造成的重複字

HotPDF Delphi Component 自 v2.768.15 起,會移除假粗體與疊印標誌留在 PDF 擷取文字裡的重複字元。當產生端把同一個字形畫兩次、只差零點幾個點時,ExtractLoadedPageText、ExtractLoadedPageTextLayout 與 ExtractLoadedPageTextColumns 現在只讀到該字元一次,「Bold words」不會再變成「Bold wordsBold words」

這是那種看起來像解碼失敗、其實不是的 bug。每個字形都解碼正確;文字真的在頁面上出現兩次。修復必須判斷兩個完全合法的字形哪個是複本,而且不能吃掉合法的重複字母——例如「fill」裡的雙 l,或表格裡一模一樣的儲存格。本文其餘部分說明 HotPDF 用的規則、規則背後的門檻、它刻意與其他擷取工具不同的地方,以及同一批次版本裡修掉的另一個不相干、同樣會切掉行尾文字的擷取 bug

為什麼假粗體會讓 PDF 擷取文字出現重複?

假粗體會讓擷取文字翻倍,是因為產生端把字串畫了兩次,而兩次都是貨真價實的顯示文字操作。字型家族沒有粗體字面、或製作工具懶得管時,許多產生器就用 Tj 或 TJ 把同一字串顯示兩次來仿粗體:先把文字位置往右挪零點幾個點,再顯示一次。螢幕上兩份複本融成較粗的筆畫;content stream 裡卻是兩段完整的字元碼,而 ISO 32000-1 §9.4 沒有給擷取器任何「這一份是裝飾」的旗標

疊印標誌從另一個角度撞上同一個問題。標誌文字可能先以填色畫一次,再以描邊或稍微偏移的陰影畫一次,用的卻是同一個字型、同一批字元。按繪製順序走訪 content stream 的天真擷取器,會先輸出完整的第一段、再輸出完整的第二段,這就是結果讀成「Bold wordsBold words」而不是「BBoolldd」的原因。v2.768.15 之前,HotPDF 的文字組裝兩份都留,而重複破壞的恰恰是大多數呼叫者真正在乎的事:跨頁面文字搜尋「Bold words」可能失敗,因為兩段之間的字詞邊界沒了

真正的文字算繪模式 2(先填後描,ISO 32000-1 §9.3.6)沒有這個問題,因為它是一次顯示文字操作、產出一組字形。只有產生端把文字發兩次時,重複才會出現

HotPDF 怎麼判斷哪個字形是複本?

HotPDF 把兩個字形視為同一個畫出的字元,條件是:Unicode 值相同、書寫方向相同、大小相差在 5% 以內、而且幾乎疊在一起——沿基線方向相距不超過字號的 0.1,跨基線方向不超過字號的 0.2。每一組這樣的字形對,先畫的保留、後畫的丟棄。這些是 v2.768.15 實作裡的值,每一個都有它的理由

  • Unicode 相同、方向相同。只有完全相同的字元才可能是複本,而且橫排字形絕不會拿去跟直排(WMode 1)字形比較
  • 大小相差 5% 以內。剛好疊在一起的大標題字母與小圖說字母不是同一段文字,大小不符就直接淘汰這一對
  • 沿基線為字號的 0.1。假粗體的偏移量是零點幾個點,只有一般字號的百分之幾。兩個真正相鄰的字母,就算 kerning 緊到不行的「ll」,也隔著大約一整個前進寬度,比這個容差大了一個數量級
  • 跨基線為字號的 0.2。陰影與描邊那一遍常常不只往右偏、也往下偏,所以垂直方向的容差放得比較寬,但仍遠小於到下一行的距離
  • 先畫者勝出。保留較早的字形,就保住了第一段的閱讀順序,留下來的文字仍照產生端書寫的方式流動

距離是在字形自己的基線座標框裡量測的,不是原始的頁面 X 與 Y,所以旋轉文字與斜基線文字用的是與一般橫排行相同的容差。所用的大小是字形畫在頁面上的實際大小,由它的 quad 推得,不是交給 Tf 的大小運算元;這對把 Tf 設成 1、全靠文字矩陣縮放的文件至關重要,那種文件若用原始字型大小,每個容差都會失效

空白永遠不是候選。到 U+0020 為止的碼位、不換行空格 U+00A0 與表意空格 U+3000 一律跳過,因為空白字形沒有可複製的可見墨跡,丟掉一個還會把字詞黏在一起

讓檢查在稠密頁面上依然夠快

HotPDF 的重複檢查大約跑在 n log n 時間,不是 n 平方,因為它先排序、只在小視窗內比較字形。每個字形跟其他每個都比一遍固然正確,卻沒法用:一頁密密麻麻的小字很容易掛上幾萬個字形,而這個檢查在每一次文字擷取呼叫都會跑

內部常式用穩定的合併排序,把非空白字形按 Unicode 值、再按原點 X、再按繪製順序排好。排完之後,某個字形的每個潛在複本都緊跟在它後面,所以掃描只在 Unicode 值相同、且 X 差落在字號的 0.25 以內時向前走。0.25 這個視窗刻意比沿基線的 0.1 容差寬,這樣稍微斜的基線上的一對,仍會在精確的基線框測試登場之前落進視窗裡。有個微妙情況:如果從字形 A 出發的掃描撞見一個畫得比 A 早的相符字形,A 才是複本,於是丟棄 A、掃描停止,讓較早的字形繼續跟更後面的東西比。靠這條規則,「先畫者勝出」對成對與三連擊都成立

比較的是 Unicode 值,不是字型或字元碼。兩個經由不同字型映射到同一 Unicode 的字形——例如填色那一遍用一個內嵌子集、描邊那一遍用另一個——仍會被認成同一個字。反面是:碼解出 Unicode 0 的字形沒有可靠的東西可比,這也是要多留意未解析計數的另一個理由,詳見用 HotPDF 從已載入 PDF 擷取文字

哪些 HotPDF 擷取呼叫會移除重複字形?

HotPDF 裡所有產生文字的呼叫都會移除重複,原始字形呼叫則不會。去重住在 THotPDF.ExtractVisibleLoadedPageGlyphs 裡——這一步也順手丟掉頁面可見框之外的字形——純文字、欄、版面與語意文字擷取全都從它那裡拿字形。THotPDF.ExtractLoadedPageGlyphs 仍回傳 content stream 畫出的每一個字形、疊印也算,因為做高亮、遮蔽或鑑識工具的呼叫者需要「畫了什麼」的完整紀錄

對一般文字工作,呼叫端程式碼什麼都不用改。v2.768.15 之前會擷出重複文字的同一個迴圈,現在每個字詞只擷出一次

var
  Pdf: THotPDF;
  PageCount, I: Integer;
  PageText: UnicodeString;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('brochure.pdf');
    for I := 0 to PageCount - 1 do
      if Pdf.ExtractLoadedPageText(I, PageText) then
        if Pos('Bold words', PageText) > 0 then
          Writeln('Found on page ', I + 1);
  finally
    Pdf.Free;
  end;
end;

版面與欄輸出同樣受益,因為重複的一段過去會把一行剩下的部分在字元網格上往旁邊推。ExtractLoadedPageTextColumns 保留它可選的間距倍率;重複過濾在欄切分之前跑,所以兩者不會互相影響

var
  Grid, Columns: UnicodeString;
begin
  if Pdf.ExtractLoadedPageTextLayout(0, Grid) then
    TFile.WriteAllText('page1-layout.txt', Grid, TEncoding.UTF8);
  // 間距超過字形前進寬中位數 3 倍之處切分欄
  if Pdf.ExtractLoadedPageTextColumns(0, Columns, 3.0) then
    TFile.WriteAllText('page1-columns.txt', Columns, TEncoding.UTF8);
end;

想知道一頁到底有沒有疊印文字,就把兩個字形呼叫的結果比一比。注意兩者的差也會把落在裁切框之外的字形算進去,所以請把它當成重複的上界,而不是精確數字

var
  AllGlyphs, Visible: THPDFGlyphArray;
begin
  if Pdf.ExtractLoadedPageGlyphs(0, AllGlyphs) and
     Pdf.ExtractVisibleLoadedPageGlyphs(0, Visible) then
    Writeln(Format('%d painted, %d read as text (%d overprinted or off-page)',
      [Length(AllGlyphs), Length(Visible),
       Length(AllGlyphs) - Length(Visible)]));
end;

把這些字形變成文字的字間距與斷行規則不受這個過濾器影響;細節見HotPDF 如何在頁面文字中放置字間空格與斷行

這條規則刻意與其他工具不同之處

有些其他擷取工具會保留的完全重複,HotPDF 丟掉,這個差異是刻意的。v2.768.15 的變更對照參考語料驗證時,有一個頁尾在完全相同的位置畫了兩次,HotPDF 擷出一次,某個參考擷取器擷出兩次。畫在相同字形之上的字形,作為獨立字元是不可見的,所以只讀一次才是頁面實際呈現的樣子

誠實的限制在另一頭。第二遍沿基線偏移超過字號 0.1 的假粗體抓不到,用明顯不同大小畫的陰影遍也抓不到。這種版面實務上少見,因為偏移一大到那個程度,視覺效果就不像粗體了;但如果您看到裝飾性標題出現重複文字,先比對 ExtractLoadedPageGlyphs 給的字形 X 值,再下過濾器壞了的結論。幾何上不疊在一起的重複,過濾器也管不著,例如在頁面另一處隱藏圖層裡重複出現的書眉;那是內容層面的問題,不是疊印

CID 字型文字的行尾為什麼被切掉了?

HotPDF v2.768.12 修掉的另一個獨立 bug,會把 CID keyed(Type 0)字型排版文字的行尾丟掉,前提是它的 /W 寬度陣列沒按 CID 排序。ISO 32000-1 §9.7.4.3 定義了 /W 陣列格式,卻不要求條目按 CID 遞增排列,有些產生器就照字形在文件裡第一次被用到的順序寫

文字擷取解碼器用二元搜尋查寬度,而二元搜尋默默假設輸入已排序。在未排序的陣列上,大多數查詢落空、退回預設寬度 /DW——1000 單位,一整個 em。於是這種字型裡的拉丁文字被排得好像每個字母都是全形,行遠遠跑出頁面右緣,落在可見框之外的字形被丟棄,行尾就從擷取文字與搜尋裡消失了。什麼都沒崩、什麼都沒回報,這正是它難抓的原因

HotPDF 現在讀完 /W 條目就用穩定合併排序排好,所以兩個條目重疊時,先寫入的仍然勝出,與陣列按檔案順序讀起來的結果一致。頁面算繪器從來沒受影響,因為它自己保留一份寬度資料、線性掃描,垂直寬度陣列 /W2 也是線性掃描。頁面看起來完美、擷取卻一塌糊塗時,值得想起這條分工:PDF 函式庫裡的算繪與擷取,共用的程式碼往往比您以為的少得多

FAQ

HotPDF 會移除「fill」裡雙 l 這類重複字母嗎?

不會。兩個真正相鄰的字母大約隔一個前進寬度,而 HotPDF 只合併沿基線 0.1 以內、跨基線 0.2 以內的相同字元,所以合法的重複都保留

想要每一個字形、連疊印的重複也在內,該怎麼做?

呼叫 THotPDF.ExtractLoadedPageGlyphs,它按繪製順序回傳頁面畫出的每一個字形。THotPDF.ExtractVisibleLoadedPageGlyphs 與所有文字擷取呼叫回傳的則是去重之後的可見集合

哪個 HotPDF 版本修掉了假粗體造成的重複文字?

HotPDF v2.768.15 為文字、版面、欄與語意文字擷取加入了重複字形移除,v2.768.12 則修好了 /W 陣列未排序的 CID 字型行尾缺失

兩項修復都已隨目前的 HotPDF Delphi PDF component 出貨;這個元件能在 Delphi 與 C++Builder 裡從已載入的 PDF 擷取文字,同樣也具備 PDF 建立、編輯與算繪功能