技術文章

Delphi 中的 Unicode 安全 PDF 文字搜尋:NFC 與 NFD

PDF Library for Delphi 能以正規等價(而非以字元編碼單元)比對文字,因此以預組合字元輸入的查詢,能找到以基礎字母加組合符號形式儲存的內容,反之亦然。有兩個搜尋選項控制這項行為:soCanonicalEquivalent 在比對過程中啟用 Unicode 正規化,soGraphemeClusters 則把每個命中結果與每一步萬用字元比對,都限制在完整的字位叢集上

這修正的是文件搜尋中最常被回報、也最少被理解的錯誤之一。使用者搜尋一個名字,沒有結果,把名字從文件中複製出來,貼進搜尋框,就找到了。表面上沒有任何東西壞掉:這兩個字串看起來一模一樣,印出來也一模一樣,比對起來卻不相等,因為一個是 U+00E9,另一個則是 U+0065 後接 U+0301

為什麼同一個詞比對起來會不相等?

Unicode 允許同一個抽象字元有多種編碼方式。帶變音符號的拉丁字母,同時存在預組合碼點與基礎字母加組合符號序列兩種形式。諺文音節同時存在預組合音節與拆解字母兩種形式。一份 PDF 究竟含有哪一種,取決於產生器、平台,有時還取決於字型,而這一切對搜尋的人來說完全不可見

單純的大小寫折疊之所以無法解決這個問題,原因是結構性的,而非偶然的。大小寫折疊與變音符號折疊在字元編碼單元層級是一對一的:折疊後的字串長度與原始字串相同,因此折疊後文字中的命中位置,就是原始文字中的命中位置。正規化並不是一對一的。一個預組合字元會變成兩到三個編碼單元,一個拆解序列會收合回一個,經過這種轉換之後,位置就不再與你擷取出的文字對齊了

讓命中座標持續指向原始文字

這一部分決定了正規化後的搜尋是可用的、還是只是正確而已。正規化過程產生的每一個編碼單元,都會記錄產生它的原始 UTF-16 文字的起訖位置。遞迴分解會繼承其父項的來源範圍,組合則會合併其輸入的範圍,找到命中結果時,函式庫會掃描對應區間,找出最小的起點與最大的終點

其效果是,MatchStartMatchLength、上下文字串以及兩個取代進入點,全都持續指向原始擷取出的文字,而不是正規化後的中介文字。若沒有這個對應機制,一次正規化後的搜尋,可能只能告訴你命中結果存在,卻無法可靠地告訴你它在哪裡,這會讓反白標示出錯、讓遮蔽處理變得危險

正規化器本身是自成一體的:包含來自 Unicode 15.1 的緊湊型正規分解、組合與正規組合類別資料表,而諺文則以演算法規則處理,不靠資料表項目。沒有任何東西是從外部資料檔載入的,也不會呼叫任何平台正規化 API,因此一個 Windows 服務、一個 Linux 常駐程式與一個 FPC 建置版本,對同一份輸入都會產生完全相同的結果

以正規等價方式搜尋

選項是一個集合,因此正規等價可以與既有行為(如全字比對、萬用字元與不分變音符號折疊)自由組合:

uses
  PDFlibrary;

var
  Lib: TPDFlib;
  Hits: array of TPDFlibSearchHit;
  Found, I: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contracts.pdf', '');
    SetLength(Hits, 500);

    Found := Lib.SearchText('Bäcker', [soCanonicalEquivalent, soWholeWord],
      '', Hits);                       // 空頁面範圍代表整份文件

    for I := 0 to Found - 1 do
      Log(Format('page %d: "%s" at %d (%d chars)',
        [Hits[I].Page, Hits[I].MatchText, Hits[I].MatchStart,
         Hits[I].MatchLength]));
  finally
    Lib.Free;
  end;
end;

正規化是選用而非預設,是有原因的。建置 NFD 文字及其位置對應,需要消耗運算資源,而多數只含 ASCII 字元的文件搜尋根本不需要它。使用這個選項時,每個文字區塊會快取兩種轉換後的形式,一種去除組合符號,一種保留,因此對同一區塊的一批查詢只需正規化一次,而不是每次查詢都做一次。大小寫折疊則繼續走成本較低的一對一路徑,不受影響

沒有字位叢集邊界時會出什麼問題?

編碼單元不是字元,字元也不是使用者感知的東西。一個國旗表情符號是兩個區域指示符碼點。一個家庭表情符號是好幾個以零寬連接符串起的碼點。一個印度文字連字是一個輔音、一個抑音符與另一個輔音。一個帶兩個疊加變音符號的字母是三個碼點。在這些序列中間比對或裁切,會產生一個渲染出來變成亂碼的片段

soGraphemeClusters 會把每個命中結果(無論是逐字比對還是萬用字元)的兩端,都限制在完整的擴充字位叢集邊界上。切分邏輯實作的是擴充規則:CR 與 LF 配對、控制字元、諺文音節類別、Extend 與 SpacingMark、Prepend、表情符號 ZWJ 序列、區域指示符配對,以及印度文字連字的斷點規則。邊界絕不會產生在代理對中間,光是這一點就消除了一整類在基本多文種平面以外的內容上出現的損毀結果

這個選項也管控萬用字元的消耗方式,這正是天真的實作仍然會裁切錯誤的地方。單字元萬用字元會恰好前進一個完整的叢集,而執行序萬用字元的回溯,也只會在叢集邊界之間移動:

// 若不使用 soGraphemeClusters,"?" 可能消耗掉半個叢集,
// 回傳一個結尾懸著一個組合符號的命中結果
Found := Lib.SearchText('c?té',
  [soWildcards, soCanonicalEquivalent, soGraphemeClusters], '', Hits);

// 同樣的邊界也保護取代作業,讓遮蔽處理與內容改寫
// 絕不會切開表情符號或帶變音符號的字母
Replaced := Lib.SearchAndReplaceText('naïve', 'plain',
  [soCanonicalEquivalent, soGraphemeClusters], '1-20');

為真實工作負載選擇選項

三種組合涵蓋了多數情境。對內部文件搜尋框而言,soCanonicalEquivalent 加上 soDiacriticInsensitive,能提供使用者期待的寬容行為,同時比對兩種編碼形式,也比對帶變音符號與不帶變音符號的拼法。對法務或合規搜尋而言,誤判有成本,請使用 soCanonicalEquivalent 搭配 soCaseSensitivesoWholeWord,並關閉變音符號折疊,讓等價比對保持精確且不受編碼形式影響

對任何會修改文件的操作,請一律加上 soGraphemeClusters,不要有例外。一次回傳範圍稍有誤差的搜尋,頂多只是誤導讀者;但一次使用同樣錯誤範圍的取代或遮蔽操作,卻會把錯誤直接寫進檔案裡。移除範圍算錯的後果,說明於 真正的遮蔽處理與內容移除 一文

當吞吐量是考量重點時,優先使用批次進入點。SearchTextBatch 會在每一頁的文字區塊仍常駐於記憶體時,執行每個非空查詢,避免每個查詢都重新擷取頁面,並重複利用已快取的正規化結果,串流變體則不需要呼叫端配置的緩衝區就能發出命中結果。底層的擷取模型說明於 文字搜尋與頁面元素列舉 一文

這項功能並非可有可無的文字系統

對韓文而言,正規等價比對是「找得到名字」與「找不到名字」之間的差別,因為預組合音節與拆解字母在真實文件中都很常見。對越南文而言,疊加變音符號讓組合形式完全取決於產生者。對印度文字而言,連字的處理方式決定了命中邊界是否落在合理的位置。對日文與中文而言,搜尋這一側相對單純,但版面配置這一側就不然了,說明於 日文與中文的直式書寫 一文

經驗法則很簡單:只要語料中包含英文以外的任何語言,就打開正規等價,並先實測其成本,再決定是否過高。在多數文件集中它並不高,而不打開它的代價,是一個會悄悄在使用者最在意能否找到的那些名字上失效的搜尋功能

Unicode 感知搜尋、擷取、遮蔽處理與文字改寫,在 Delphi、C++Builder 與 Free Pascal 中共用同一套引擎;完整功能清單列於 PDF Library for Delphi 頁面