技术文章

ToUnicode 修复:Delphi 里的 NBSP 与软连字符 PDF 提取

面向 Delphi 的 PDFium Component 把 TPdf.AddText 用到的系统字体按 Unicode 码点为键嵌入成 CID 字体,于是每个 CID 恰好带一条 ToUnicode 映射。正是这一点让提取出的空格不再变回 U+00A0(不换行空格)、连字符不再变回 U+00AD(软连字符),活的文档和存盘的文件都一样

这个症状很恶心,因为它不可见。搜索索引找不到「two-x」,因为存下的字符串里混着软连字符;CSV 导出的分列结果变了;diff 工具标出在所有查看器里看起来一模一样的行。渲染出来的页面毫无问题;出问题的是字形背后的 Unicode

为什么提取出的空格变回 U+00A0?

提取出的空格变成 U+00A0,是因为 PDFium 在 FPDFText_LoadFont 里生成的 ToUnicode CMap 以字形为键,而一个字形可以从两个码点到达。在 Arial 里,glyph 3 同时服务 U+0020 和 U+00A0,连字符字形同时服务 U+002D 和 U+00AD。于是生成的 CMap 把同一个 CID 映射了两次,一次通过 bfchar 条目、一次通过数组形式的 bfrange,读取器的优先级规则偏爱哪条,哪条就成了提取出的文本

一个 Arial 字形为什么在 Delphi 里弄坏 PDF 文本提取示意图:U+0020 和 U+00A0 都到达 glyph 3,U+002D 和 U+00AD 都到达连字符字形,生成的 ToUnicode CMap 通过 bfchar 条目和数组 bfrange 把 CID 0003 映射两次,读取器的优先级规则决定提取哪个码点
「低者胜」的优先级多年来让空格保持朴素,直到上游改成「后者胜」,AddText 写的每个空格都提取成 NBSP、每个连字符都提取成软连字符
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

很长一段时间里这个矛盾无害,因为 PDFium 的读取器让最低的映射赢。上游一处改动把读取器换成了后写者赢,从那个构建起,AddText 写出的每个空格都提取成 NBSP、每个连字符都提取成软连字符。注意这些配对里的模式:0x20/0xA0 和 0x2D/0xAD 只差最高位,这正是一个把 Latin-1 相似字符送进同一轮廓的 cmap 会给你的东西。如果你的提取代码昨天还好好的、今天开始在隐形字符上翻车,把码点 dump 出来,别信调试器的显示;提取文本的基础见在 Delphi 里用 PDFium 从 PDF 文档提取文本

uses
  SysUtils, PDFium;

const
  // 空格/U+00A0 与连字符/U+00AD 共用一个 Arial 字形,
  // 希腊 Omega(U+03A9)与 Ohm 符号(U+2126)也一样
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // 活的、未保存的文档
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // 完整保存并重新加载之后
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

为什么保存后修补 CMap 不够

修补存好的文件只救得了那个文件,而且前提是修补保持 CMap 结构逐字节原样。第一个修法是 FPdfCompress 单元里的 RepairSubsetToUnicodeCMaps,它在每次非增量的 TPdf.SaveAs 之后运行,逐个解决冲突的 CID:bfchar 条目赢,只差最高位的一对解析成较小的 base-Latin 码点,其他情况保留第一条映射

有意思的是那个否定结果。把冲突的 CMap 干净地重建一遍——不管用起始码形式还是数组形式——看着都是显然的做法,而 PDFium 把每个重建的 CMap 都直接拒了,退回 Identity。原生读取器唯一接受的输出,是对冲突十六进制值做等长原位替换,块布局和 CID 覆盖不动。第二个教训更不起眼:我们当时的笔记把内存中的情况归咎于活文档根本没有 ToUnicode 流。直接调 DLL 推翻了这个说法——活文档带着同样含糊的流,这意味着真正的修法必须在 PDFium 生成 CMap 之前就位。修复例程留在库里,作为对其他 PDFium 系工具产出的 PDF 的防御

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // 只做等长编辑;没有可修复冲突的文件,
      // 以及交叉引用流或对象流文件,原样复制
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

按码点而不是按字形为字体建键

治本的办法是干脆不再让 PDFium 生成 CMap。TPdf.LoadCachedFont 现在把系统字体字节交给 TPdf.LoadUnicodeKeyedCidFont,后者读字体自己的 sfnt cmap 表,优先 format 12 子表、退回 format 4。码点返回时已排序去重,CID k+1 分给第 k 个码点,CID 0 留作 .notdef。一个显式 CIDToGIDMap 把每个 CID 指到它的字形,所以 U+0020 和 U+00A0 拿到两个不同的 CID、画同一个轮廓,而 ToUnicode CMap 把每个 CID 只映射到一个码点。字体随后经 FPDFText_LoadCidType2Font 加载,也就是带显式 CID-to-GID 映射的 CID Type 2 字体嵌入里字形级写入背后的同一个入口

PDFium Component 的按码点建键修法示意图:LoadUnicodeKeyedCidFont 读字体的 sfnt cmap,把 CID k+1 分给每个排序后的码点、CID 0 留作 notdef,接一个显式 CIDToGIDMap 让 U+0020 和 U+00A0 保持不同的 CID,BuildUnicodeKeyedCidCMap 让每个 CID 恰好对应一个码点
NBSP、软连字符和 Ohm 符号于是在任一优先级规则下都原样存活,活文档和任何保存之后都一样,CMap 修复再无可修之物
// 摘自 TPdf.LoadUnicodeKeyedCidFont(有精简)
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // 一个 CID,一个码点
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

之后 FPDFText_SetText 写字符串时,反向查找每个字符都落在一个 CID 上,所以 NBSP、软连字符和 Ohm 符号在任一优先级规则下都原样存活,内存里和任何保存之后都一样。因为存出的文件带的是组件自己的 ToUnicode 流,而不是引擎生成的,RepairSubsetToUnicodeCMaps 在里面找不到任何要修的东西

为什么一个 bfrange 条目能抹掉整个块?

一条 CID 区间跨过 xxFF 边界的 bfrange 会让 PDFium 丢弃它所在的整个块。ISO 32000-1 §9.10.3 只允许目标值的最后一个字节在范围内变化,但 CID 一侧有自己的陷阱:PDFium 的 HandleBeginBFRange 把高 CID 推导成 (low and $FFFFFF00) or (high and $FF)。于是从 CID 00FE 到 0101 的区间被读成 00FE 到 0001,low 大于 high,整个块被标为无效。失败是无声的:SetText 成功,页面渲染完美,提取却给那个块里的每个字符都返回 U+0000

PDF CMap 解析里无声的 bfrange 陷阱示意图:从 00FE 到 0101 的 CID 区间跨过 xxFF 边界,HandleBeginBFRange 把高 CID 推导成 0001,low 大于 high 让整个块无效,SetText 与渲染仍然成功,提取却给块里每个字符返回 U+0000
BuildUnicodeKeyedCidCMap 靠在低字节到 FF 之前结束每个区间来避开陷阱,把块保持在 100 条目上限之内,并把增补平面码点写成单独的 bfchar 条目

BuildUnicodeKeyedCidCMap 在码点或 CID 的低字节到达 FF 之前就结束区间,让每个块都守在 CMap 语法的 100 条目上限之内,并把增补平面码点写成单独的 bfchar 条目、目标为 UTF-16 代理对,因为在区间内递增一个代理对没有定义过的含义;代理对那部分故事见Delphi 里的 emoji、CJK 与代理对处理。纯 bfchar 的 CMap 能完全绕开边界问题,代价是体积翻好几倍

按码点建键的字体不覆盖什么?

按码点建键的路径覆盖每个暴露 Unicode cmap 子表的字体,其余退回旧的按字形建键行为。依赖它之前值得知道的边界:

  • 只有 (3,0) cmap 的 Symbol 字体,以及 CID 路径加载失败的任何字体,照旧走 FPDFText_LoadFont,所以两个码点共享一个字形的情况在那里仍可能提取出歧义
  • 没有 format 12 子表时映射限于 BMP,条目数封顶 65535,让每个 CID 都装得进零以上的两个字节
  • 增量保存(saIncremental)按设计跳过 RepairSubsetToUnicodeCMaps,因为增量修订必须保持只追加;组件自己写的文本用了按码点建键的字体,这件事就无关紧要了
  • TrueType Collections 要多留个心眼:GDI 的 GetFontData 返回整个 .ttc,而 FPDFText_LoadCidType2Font 没有 face index 参数,所以从 simsun.ttc 请求 NSimSun 过去嵌入并渲染的是 SimSun(face 0)。组件现在把家族名对到 name 表(nameID 1 和 16)上,在解析 cmap 之前把请求的 face 抽成独立 sfnt;解析失败时集合字节原样通过,行为退回 face 0

文本写入、字体嵌入和提取在 Delphi、C++Builder 和 Lazarus 之间共享同一个页面模型,完整 API 见 PDFium Component for Delphi 产品页面