技术文章

PDFium Emoji 和 CJK 字符会破坏 Delphi 中的 WideChar

从 PDF 中提取 Emoji 或日本户籍中的姓名作为文本时,字符本应出现的位置可能变成方框、问号,甚至完全消失。问题通常出在 PDFium Component 的 Character[] 属性:它通过 FPDFText_GetUnicode 读取每个字形,该函数返回完整的 Unicode 代码点(无符号 32 位值),而属性随后将其作为单个 16 位 WideChar 暴露给 Delphi。任何超过 U+FFFF 的代码点都无法完整通过这条路径,而渲染页面时却不会显示这种损坏,因为 PDFium 使用不同的代码路径进行渲染和文本提取 — 文档可以完美显示 Emoji,但你在循环中读取 Character[] 并据此构造字符串时,仍可能拿到乱码

基本多文种平面以及 WideChar 为何止于 U+FFFF

Delphi 的 WideChar 是一种 16 位类型,只能容纳一个 UTF-16 代码单元。Unicode 的基本多文种平面范围是 U+0000 到 U+FFFF,恰好可以完整放入其中,因此拉丁文、西里尔文、希腊文以及常用 CJK 统一表意文字区块都能通过单个 WideChar 正常往返。实际文档中有两类字符经常超出这个范围:许多 Emoji 位于从 U+1F600 开始的表情符号区块,罕见 CJK 表意文字则来自 CJK 统一表意文字扩展 B,范围为 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;

将咧嘴笑脸 Emoji U+1F600 传入该函数时,结果是高代理项 $D83D 和低代理项 $DE00,也就是两个 16 位值,而不是一个值。任何一半单独存在都没有意义;如果字符串中只有 $D83D 而后面没有 $DE00,它就是一个悬空代理项,大多数遇到它的文本处理代码会将其丢弃、替换为替代字形或抛出错误

为什么 FPDFText_GetUnicode 返回的值无法由 Character[] 容纳

FPDFText_GetUnicode 返回完整的 32 位 LongWord 值,因为 PDF 文本编码已经为每个字形携带了完整的 Unicode 标量值。PDF 的 ToUnicode CMap 将字符代码映射到 Unicode 文本,而当字形表示通常所说的天文平面字符,也就是任何超出基本多文种平面的字符时,该映射保存的是完整代码点,而不是 16 位片段。PDFium 在内部将其解码回标量值,再通过 DLL 边界由 FPDFText_GetUnicode 返回,而这正是 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[] 背后的属性 getter 中

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 文档提取文本 中介绍的普通文本提取模式相同,唯一变化是最后一行:它不再直接追加 Character[I],而是调用一次单索引的 Text,这样天文平面字符会以完整代理对到达,而不是变成替代占位符

真正容易遇到问题的场景:聊天导出、姓名和嵌入式 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