絵文字や日本の戸籍名をPDFからテキストとして引き抜くと、その文字があるべき場所に箱、疑問符、あるいは何もないものが表示される。PDFiumコンポーネントのCharacter[]プロパティが通常その原因である:それは各グリフをFPDFText_GetUnicode経由で読み取り、これは完全なUnicodeコードポイントを符号なし32ビット値として返すが、それをDelphiには単一の16ビットWideCharとして公開する。U+FFFFを超えるどんなコードポイントも一体でその移動を成し遂げることはできない。そしてこの破損は、レンダリングされたページを見ている間には決して現れない。なぜならレンダリングとテキスト抽出はPDFium内部で別個のコードパスを通るからだ——文書はその絵文字を完璧に表示しながらも、あなたがループでCharacter[]を読んでそこから文字列を構築した瞬間にガラクタを渡すことがありうる
基本多言語面、そしてなぜWideCharはU+FFFFで止まるのか
DelphiのWideCharは、1つのUTF-16コード単位しか保持できない16ビット型である。UnicodeのBasic Multilingual Plane、すなわちU+0000からU+FFFFまでの範囲は、正確にその中に収まる。これが、ラテン文字、キリル文字、ギリシャ文字、そして一般的なCJK統合漢字ブロックがすべて単一のWideCharを通じて事故なく往復する理由である。実際の文書では、2つの文字群が日常的にその範囲の外に落ちる:絵文字(その多くはU+1F600から始まるEmoticonsブロックにある)、そしてCJK統合漢字拡張Bからの稀なCJK表意文字(あまり一般的でない中国語・日本語・韓国語の文字用に確保されたU+20000からU+2A6DFまでの範囲であり、多くの人名や地名を含む)である。UTF-16はU+FFFFを超えるあらゆるものをサロゲートペアで扱う——2つの16ビットコード単位、$D800から$DBFFの範囲にある上位サロゲートとそれに続く$DC00から$DFFFの下位サロゲートが一緒になって1つのコードポイントを符号化する——そしてそのペアリングの裏にある計算は、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、すなわち1つではなく2つの16ビット値になる。どちらの半分も単独では何も意味しない;$DE00を後ろに持たない孤立した$D83Dはぶら下がったサロゲートであり、それに出会うほとんどのテキスト処理コードは、それを落とすか、置換グリフに差し替えるか、エラーを発生させるかのいずれかを行う
なぜFPDFText_GetUnicodeはCharacter[]が保持できない値を返すのか
FPDFText_GetUnicodeはLongWord、すなわち完全な32ビット値を返す。なぜなら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[]は今アストラル面のコードポイントに対して何を返すのか
PDFiumコンポーネントのCharacter[]とCharcode[]プロパティは、基礎となるコードポイントがU+FFFFを超えるときはいつでも、静かにそれを切り捨てる代わりに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[]はTPdfとTPdfViewの両方でWideChar型として宣言されており、その戻り値の型を拡げて完全なコードポイントを運べるようにすると、1インデックス当たり1グリフが1つの16ビット値を意味すると期待する既存のすべての呼び出し元を壊してしまう。U+FFFDは、まさにこの状況のためにUnicode標準自身が指定するプレースホルダーであり、それをチェックする呼び出し元は静かに間違ったデータではなく定義済みの文書化されたシグナルを受け取る。知っておく価値のある一つの境界ケース:U+FFFDはそれ自体正当な文字でもある。そのため、すでに本物の置換文字グリフを含む稀な文書では、そのインデックスは値だけでは切り捨てられたアストラル文字と区別がつかない
Delphiで絵文字とCJK拡張Bのテキストを正しく抽出するにはどうすればよいか
実際のテキスト内容が重要な場合はいつでも、Character[]を歩くのではなくTextを呼ぶこと。なぜならTextはFPDFText_GetTextを通じて読み、インデックスごとの固定幅の値ではなく、範囲内のすべてのアストラル面文字について適切なサロゲートペアを持つ完全なWStringを返すからだ。Pdf.Text(0, MaxInt)、あるいは短縮形のPdf.Textは、1回の呼び出しでページ全体を正しく抽出し、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;
このループにあるgenerated・unmapped文字をスキップするチェックは、PDFiumコンポーネントによるPDF文書からのテキスト抽出で使われているプレーンテキスト抽出と同じパターンである;変更点は最後の行だけであり、直接的なCharacter[I]の追加を、アストラル文字を置換用プレースホルダーではなく完全なサロゲートペアとして到着させるTextへの1インデックス呼び出しと交換している
これが実際に噛みつくところ:チャットのエクスポート、人名、埋め込みCJKフォント
絵文字は、PDFが非公式なコミュニケーションを捉えるあらゆる場所に現れる:エクスポートされたチャットログ、アプリストアのレビューダンプ、コンプライアンスアーカイブ用にPDFへ保存されたチケットシステムの記録である。CJK拡張Bは、より狭いがより重要度の高い場所、すなわち人名や地名に現れる。なぜなら日本の戸籍、中国の戸口登記記録、台湾の身分証明書類は、一般的なCJKブロックには一度も入らなかった文字の古典的な出所だからだ。スキャンされた政府の書類から名前を抽出する給与計算や本人確認のパイプラインは、静かに破損した文字が装飾的な不具合ではなく照合の失敗になってしまう、まさにその種のワークロードである
稀なCJK表意文字は、エンコーディングの問題だけでなくフォントの問題も伴いがちである。なぜなら何かがレンダリングされる前に、フォントはU+20000範囲のコードポイント用のグリフを運んでいなければならず、インストール済みのシステムフォントでそれを持つものはほとんどないからだ。PDFiumコンポーネントによるPDFフォントプロパティの読み取りで説明されている方法ですでに文字ごとにFontIsEmbedded[]を歩いている人は誰でも、同じインデックスについて両方の問題を一緒にチェックすべきだ:Character[]からU+FFFDを返し、かつ非埋め込みフォントを報告するインデックスは、その文字を正しく抽出することも印刷することもない文書であり、その修正はあなたの抽出コードではなくそのPDFがどう生成されたかという上流に属する
ここで説明したCharacter[]、Charcode[]、Textプロパティは、DelphiおよびC++Builder向け標準PDFiumコンポーネントの一部である