技術文章

PDFium Emoji 與 CJK 字元在 Delphi 中讓 WideChar 出錯

從 PDF 取出 emoji 或日本戶籍姓名作為文字時,字元原本應在的位置可能會顯示方框、問號,或完全沒有內容。通常原因就在 PDFium Component 的 Character[] 屬性:它透過 FPDFText_GetUnicode 讀取每個字形,而該函式會以無號 32 位元值傳回完整 Unicode 碼點,接著卻將它以單一 16 位元 WideChar 提供給 Delphi。任何超過 U+FFFF 的碼點都無法完整通過這條路徑,而且你查看轉譯頁面時不會發現損壞,因為 PDFium 的轉譯與文字擷取使用不同的程式碼路徑——文件可以完美顯示 emoji,卻在你迴圈讀取 Character[] 並組成字串的瞬間交出亂碼

基本多文種平面,以及 WideChar 為何停在 U+FFFF

Delphi 的 WideChar 是只能容納一個 UTF-16 程式碼單元的 16 位元型別。Unicode 的基本多文種平面範圍是 U+0000 到 U+FFFF,正好可以完整放入其中,因此拉丁文、西里爾文、希臘文,以及常見的 CJK 統一表意文字區塊,都能透過單一 WideChar 往返而不出問題。在實際文件中,有兩類字元經常落在這個範圍之外:emoji,其中許多位於從 U+1F600 開始的表情符號區塊;以及來自 CJK 統一表意文字擴展 B 的罕見 CJK 表意文字,範圍是 U+20000 到 U+2A6DF,保留給較少見的中日韓文字,其中包含許多人名與地名。UTF-16 以代理對處理超過 U+FFFF 的內容——兩個 16 位元程式碼單元,一個位於 $D800 到 $DBFF 的高代理項,接著一個位於 $DC00 到 $DFFF 的低代理項,兩者合起來編碼一個碼點——而這組配對背後的計算方式固定,足以直接用 Pascal 示範

function ToSurrogatePair(CodePoint: LongWord; out Hi, Lo: WideChar): Boolean;
var
  V: LongWord;
begin
  Result := CodePoint > $FFFF;
  if Result then
  begin
    V := CodePoint - $10000;
    Hi := WideChar($D800 + (V shr 10));
    Lo := WideChar($DC00 + (V and $3FF));
  end;
end;

將 U+1F600,也就是咧嘴笑表情符號,傳入這個函式後,結果會是 $D83D 的高代理項與 $DE00 的低代理項,是兩個 16 位元值,而不是一個值。兩半各自都沒有意義;若字串中的 $D83D 後面沒有 $DE00,便會成為懸空代理項,而大多數遇到它的文字處理程式碼不是丟棄它、替換成替代字形,就是引發錯誤

為什麼 FPDFText_GetUnicode 會傳回 Character[] 無法容納的值

FPDFText_GetUnicode 會傳回完整 32 位元值的 LongWord,因為 PDF 文字編碼本來就為每個字形攜帶完整的 Unicode 純量值。PDF 的 ToUnicode CMap 會將字元碼對應到 Unicode 文字;當字形代表通常所稱的星芒平面字元——任何超過基本多文種平面的內容——該對應是完整碼點,而不是 16 位元片段。PDFium 在內部將它解碼回純量值,並透過 FPDFText_GetUnicode 經由 DLL 邊界傳回,而這正是 32 位元值必須轉成 Delphi 屬性可交給程式碼之內容的地方

最直觀的實作是 WideChar(FPDFText_GetUnicode(TextPage, Index)),但這也是錯誤的作法。將 32 位元值硬轉成 16 位元型別只會保留低 16 位元,並靜默丟掉其餘部分,不會擲出例外,也不會進行範圍檢查。對 U+1F600 而言,這表示保留 $F600,並失去原值曾超過 U+FFFF 的資訊,產生的程式碼單元甚至不是有效的懸空代理項,而只是恰好共用相同低位元的另一個基本多文種平面字元。將數千個這類值串接成字串後,下游程式碼便無法分辨損壞的字元與合法字元

Character[] 與 Charcode[] 現在會為星芒平面碼點傳回什麼

當底層碼點超過 U+FFFF 時,PDFium Component 的 Character[]Charcode[] 屬性會傳回 U+FFFD,也就是 Unicode 替代字元,而不是靜默截斷它。這項防護直接位於 Character[] 背後的屬性存取子中

function TPdf.GetCharacter(Index: Integer): WideChar;
var
  Code: LongWord;
begin
  LoadTextPage;
  Code := FPDFText_GetUnicode(FTextPage, Index);
  if Code > $FFFF then
    Result := #$FFFD          // astral-plane code point: cannot fit in one WideChar
  else
    Result := WideChar(Code);
end;

傳回 U+FFFD 而不是截斷片段,是刻意採取的窄幅修正,而不是重新設計。Character[]Charcode[]TPdfTPdfView 上都宣告為 WideChar,將回傳型別擴大以承載完整碼點,會破壞所有預期每個索引的一個字形代表一個 16 位元值的既有呼叫端。U+FFFD 正是 Unicode 標準為這種情況指定的佔位符,因此檢查它的呼叫端可以得到明確且有文件定義的訊號,不會再取得靜默錯誤的資料。有一個值得注意的邊界情況:U+FFFD 本身也是合法字元,因此在原本就含有真正替代字元字形的罕見文件中,單靠值無法區分該索引是原本的替代字元,還是被截斷的星芒字元

如何在 Delphi 中正確擷取 emoji 與 CJK 擴展 B 文字

只要實際文字內容重要,就應該呼叫 Text,不要逐一走訪 Character[],因為 Text 會透過 FPDFText_GetText 讀取,為範圍內每個星芒平面字元以正確的代理對傳回完整 WString,而不是每個索引傳回一個固定寬度值。Pdf.Text(0, MaxInt) 或簡寫形式 Pdf.Text 可以一次正確擷取整頁,Pdf.Text(StartIndex, Count) 則以相同方式擷取較小範圍。當你只需要某個索引的位置、字型或旗標資料,而且不會直接處理碼點時,Character[] 仍然很有用——CharacterOrigin[]FontSize[]CharacterMapError[] 不在意底層字形是否位於星芒平面

function ExtractLineSafely(Pdf: TPdf): WString;
var
  I: Integer;
begin
  Result := '';
  for I := 0 to Pdf.CharacterCount - 1 do
    if not (Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I]) then
      Result := Result + Pdf.Text(I, 1);   // full code point, never a truncated WideChar
end;

這個迴圈中略過自動產生與未對映字元的檢查,和使用 PDFium Component 從 PDF 文件擷取文字所描述的純文字擷取模式相同;唯一的變化是最後一行,它以對 Text 的單一索引呼叫取代直接附加 Character[I],讓星芒字元以完整代理對抵達,而不是以替代佔位符抵達

真正會出問題的地方:聊天匯出、姓名與嵌入式 CJK 字型

只要 PDF 擷取非正式通訊內容,就可能出現 emoji:匯出的聊天記錄、應用程式商店評論匯出內容、為合規封存而儲存成 PDF 的票務系統轉錄。CJK 擴展 B 出現在範圍較窄但風險更高的地方,也就是人名與地名,因為日本戶籍、中國戶口登記文件與臺灣身分證明文件,都是含有未納入常見 CJK 區塊字元的典型來源。從掃描的政府文件擷取姓名時,薪資或身分驗證流程正是最容易讓靜默損壞的字元變成比對失敗,而不只是外觀瑕疵的工作負載

罕見 CJK 表意文字也往往同時帶來字型問題,而不只是編碼問題,因為字型必須先包含 U+20000 範圍碼點的字形,內容才可能轉譯,而少數已安裝的系統字型具備這些字形。若你已經像使用 PDFium Component 讀取 PDF 字型屬性所描述的方式逐字元走訪 FontIsEmbedded[],就應該在相同索引一起檢查這兩個問題:若某索引從 Character[] 傳回 U+FFFD,並且回報字型未嵌入,那就是既無法正確擷取也無法正確列印該字元的文件,修正點應在 PDF 的產生方式上游,而不是你的擷取程式碼中

本文所述的 Character[]Charcode[]Text 屬性,是適用於 Delphi 與 C++Builder 的標準 PDFium Component 的一部分