面向 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,读取器的优先级规则偏爱哪条,哪条就成了提取出的文本
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 字体嵌入里字形级写入背后的同一个入口
// 摘自 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
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 产品页面