技术文章

PDFlibPas 的 CID-keyed CFF 字体:Charset 要对上字形索引

用 .otf 文件里的 Noto Sans SC 排中文发票,mutool 打开时客户名的位置可能跳出不相干的汉字,而换用 TrueType 字体渲染同一页却完全正常。根源在字体的 CFF charset;PDFlibPas 会把嵌入副本的 charset 重写为 identity,让字形索引编码命中正确的字形

一个字体是否踩坑,取决于它是怎么构建的。TrueType 程序以 CIDFontType2 字体嵌入时拿到 Identity 的 CIDToGIDMap,内容流里的编码直接落在目标字形上。多数带 CFF 轮廓的拉丁 OpenType 字体是 name-keyed 的,而 name-keyed 的 CFF 程序把传入的 CID 直接当字形索引用。ISO 32000-1 甚至在 §9.7.4.2 里提了一句:许多字体里 CID 与 GID 一致。但不是所有字体。按 CID-keyed 方式构建的大型 CJK 字族——Noto Sans CJK 和同源的 Source Han 系列都在其中——自带一套 CID 编号,写入程序若把字形索引直接塞进这套编号,得到的只是恰好持有该 CID 的那个字形

CID-keyed CFF 程序拿到 CID 之后做什么

ISO 32000-1 §9.7.4.2 按看 Top DICT 的结果把嵌入的 CFF 程序分成两类。Top DICT 若用了 CIDFont 算子——实际上就是以 ROS(算子 12 30:registry、ordering、supplement)开头——阅读器从内容流取出 CID,找到 charset 条目携带该 CID 的字形,然后才从 CharStrings INDEX 取轮廓。没有 CIDFont 算子时,CID 就是字形索引,charset 不参与任何事。任何按 §9.7.4.2 字面执行的阅读器,只要 charset 不是 identity,面对字形索引编码就会画错字形,换哪个查看器都一样。charset 本身由 Adobe Technical Note #5176 定义,有三种布局加两种特例:

Delphi 中 PDFlibPas 嵌入 CJK 字体:ISO 32000-1 §9.7.4.2 下的 CID-keyed CFF charset 查找在嵌入 charset 不是 identity 时为字形索引编码画出错误字形
只有当 charset 把每个字形都映射到同号 CID 时,字形索引编码才能在 CID-keyed CFF 字体里命中目标轮廓
  • Format 0 为每个字形存一个 2 字节 SID 或 CID,按字形顺序排列
  • Format 1 存区间:一个起始 CID 后跟 1 字节计数,表示其后携带连续 CID 的字形个数
  • Format 2 是同样的区间布局,但计数为 2 字节,适合连续长段多的字体
  • 字形 0 是 .notdef,任何格式里都没有它的条目;charset 偏移 0、1 或 2 指的是预定义 charset,而不是指向数据

所以第一步是搞清你手里是哪类字体。PDF 侧的 Identity-H 只说明 2 字节编码等于 CID,CID 如何命中字形完全由字体程序内部决定。下面的函数从 .otf 里读出 CFF 表,遍历 Top DICT 找 ROS,再检查 charset 是否把每个字形都映射到同号 CID。它只需要 RTL

uses
  System.SysUtils, System.IOUtils;

type
  TCFFCharsetReport = record
    IsCFF, CIDKeyed, IdentityCharset: Boolean;
    CharsetFormat, GlyphCount: Integer;
  end;

{$RANGECHECKS ON}  // 文件被截断时抛 ERangeError,而不是误读
function InspectOtfCharset(const FileName: string): TCFFCharsetReport;
var
  B: TBytes;
  NumTables, I, Cff, P, Dict, DictEnd, Unused1, Unused2: Integer;
  Op, Operand, CharsetOfs, CharStringsOfs, G, Left: Integer;

  function BE(Pos, Count: Integer): Integer;
  var
    K: Integer;
  begin
    Result := 0;
    for K := 0 to Count - 1 do
      Result := (Result shl 8) or B[Pos + K];
  end;

  // 返回 Pos 处 INDEX 的结尾,并框出它第一个对象的范围
  function SkipIndex(Pos: Integer; out ObjStart, ObjEnd: Integer): Integer;
  var
    Count, OffSize, Base: Integer;
  begin
    Count := BE(Pos, 2);
    if Count = 0 then
    begin
      ObjStart := Pos + 2;
      ObjEnd := ObjStart;
      Exit(Pos + 2);
    end;
    OffSize := B[Pos + 2];
    Base := Pos + 2 + (Count + 1) * OffSize;  // 偏移从这里起按 1 计
    ObjStart := Base + BE(Pos + 3, OffSize);
    ObjEnd := Base + BE(Pos + 3 + OffSize, OffSize);
    Result := Base + BE(Pos + 3 + Count * OffSize, OffSize);
  end;

begin
  Result := Default(TCFFCharsetReport);
  Result.CharsetFormat := -1;
  B := TFile.ReadAllBytes(FileName);
  if (Length(B) < 12) or (BE(0, 4) <> $4F54544F) then  // 'OTTO'
    Exit;
  Cff := -1;
  NumTables := BE(4, 2);
  for I := 0 to NumTables - 1 do
    if BE(12 + I * 16, 4) = $43464620 then               // 'CFF '
      Cff := BE(12 + I * 16 + 8, 4);
  if Cff < 0 then
    Exit;
  Result.IsCFF := True;
  P := SkipIndex(Cff + B[Cff + 2], Unused1, Unused2);    // Name INDEX
  SkipIndex(P, Dict, DictEnd);                           // Top DICT INDEX
  Operand := 0;
  CharsetOfs := 0;
  CharStringsOfs := 0;
  P := Dict;
  while P < DictEnd do
  begin
    Op := B[P];
    case Op of
      12:
        begin
          if B[P + 1] = 30 then
            Result.CIDKeyed := True;                     // ROS
          Inc(P, 2);
        end;
      0..11, 13..21:
        begin
          if Op = 15 then
            CharsetOfs := Operand
          else if Op = 17 then
            CharStringsOfs := Operand;
          Inc(P);
        end;
      28: begin Operand := SmallInt(BE(P + 1, 2)); Inc(P, 3); end;
      29: begin Operand := BE(P + 1, 4); Inc(P, 5); end;
      30:
        begin                                            // 实数
          repeat
            Inc(P);
          until ((B[P] and $F) = $F) or ((B[P] shr 4) = $F);
          Inc(P);
        end;
      32..246: begin Operand := Op - 139; Inc(P); end;
      247..250: begin Operand := (Op - 247) * 256 + B[P + 1] + 108; Inc(P, 2); end;
      251..254: begin Operand := -(Op - 251) * 256 - B[P + 1] - 108; Inc(P, 2); end;
    else
      Exit;                                              // 保留字节
    end;
  end;
  if (not Result.CIDKeyed) or (CharsetOfs <= 2) or (CharStringsOfs <= 0) then
    Exit;
  Result.GlyphCount := BE(Cff + CharStringsOfs, 2);
  P := Cff + CharsetOfs;
  Result.CharsetFormat := B[P];
  if Result.CharsetFormat > 2 then
    Exit;
  Inc(P);
  Result.IdentityCharset := True;
  G := 1;                                                // .notdef 没有条目
  while G < Result.GlyphCount do
  begin
    if BE(P, 2) <> G then
      Result.IdentityCharset := False;
    case Result.CharsetFormat of
      0: Left := 0;
      1: Left := B[P + 2];
    else
      Left := BE(P + 2, 2);
    end;
    Inc(P, Result.CharsetFormat + 2);
    Inc(G, Left + 1);
  end;
end;

报告给出 CIDKeyed = True 且 IdentityCharset = False,描述的正是本文讨论的这类字体:任何不处理 charset 就把字形索引写进内容流的写入程序,用它都会画错字形。name-keyed 的结果,或者 charset 恰好已是 identity 的 CID-keyed 字体,在同一写入程序下都能正确渲染。这就是同一产品里这个 CJK 字体正常、下一个却出错的原因。循环只比较每个区间的第一个 CID,这就够了:区间覆盖的是字形连续、CID 也连续的一段,只要起点的字形索引与 CID 相同,到结尾都相同

字形索引贯穿整个字体字典

PDF 写入程序手里很少有现成的 CID。cmap 表和整形引擎返回的都是字形索引,所以顺理成章的设计是把索引写成 2 字节的 Identity-H 编码,其余一切都以它为准:/W 宽度数组、PDF/A 要求的 /CIDSet 位图,以及让文本抽取还原源文本的 ToUnicode CMap。TrueType 有一条逃生通道:Table 117 提供 CIDToGIDMap,但只适用于 Type 2 CIDFont;CFF 程序住在 CIDFontType0 字体里,PDF 侧没有任何办法重定向 CID。这也堵死了一些写入程序在 TrueType 上用的技巧——给复用的字形在字体末尾之外再发一个「别名」CID,让 ToUnicode 给每次使用挂上不同文本。在 CFF 字体里这个别名选不到任何字形,mutool 会报告无法渲染它。把 U+4E00 和 U+2F00(KANGXI RADICAL ONE)映射到同一轮廓的字体是最常见的情形。如果文档需要这两处抽取出不同的文本,把该处包进带 /ActualText 的 marked-content span(ISO 32000-1 §14.9.4),阅读器会用它取代字体的映射

就地重写 charset

让字形索引编码与 CID-keyed 程序对上,有两条路。一条是在写入前把每个字形索引翻译成字体的 CID,这会牵动内容流编码、/W 的键、/CIDSet 的位、ToUnicode 的源编码以及每次保存时的子集。另一条是改掉唯一不对齐的那个结构——charset,让字体像 name-keyed 程序一样响应字形索引。第二条路更稳:整形器、宽度表、子集化和 ToUnicode 生成器都继续在字形空间工作,必须理解 CID 的只剩一小段例程,在嵌入的字体副本上跑一次。PDFlibPas 走的就是这条路

重写保持 charset 的格式与字节长度不变。Format 0 里每个条目被覆写为它自己的字形索引;Format 1 和 2 只改每个区间的第一个 CID,计数字段原样保留。什么都不膨胀、什么都不缩短。Top DICT 指向 CharStrings、Private DICT、FDArray 和 FDSelect 的偏移无需重新编码就保持有效,而按字形索引分配 hinting 字典的 FDSelect 让每个字形留在原 FD 里。name-keyed 程序、预定义 charset、CFF2 表(它们没有 charset)以及无法解析的程序一概不动。有三条代码路径执行这次重写:

PDFlibPas 就地把 CID-keyed CFF charset 重写为 identity,保持 Format 0、1、2 的字节长度,使 Top DICT 偏移对 AddTrueTypeFontFromFile、子集与 DrawOpenTypeText 保持有效
PDFlibPas 只覆写嵌入 CFF 副本 charset 内部的 CID,格式与长度不变,任何 Top DICT 偏移都不必移动
  • AddTrueTypeFontFromFile 遇到 .otf 时把裸 CFF 表按 CIDFontType0C 子类型嵌成 FontFile3,charset 已重写完毕
  • 保存时的子集保留原始字形索引,也保留源 CID,因此它的 charset 要再重写一次
  • DrawOpenTypeText 与 DrawShapedText 背后的整形伴生字体保留整个 sfnt;重写之后 CFF 表校验和重新计算,head 表的 checkSumAdjustment 变为 0xB1B0AFBA 减去文件之和
  • 这个调整值只对单字体有定义,因此从字体集合里取出的字面只重算表校验和
uses
  SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // CFF 类型的 .otf 以 FontFile3 /CIDFontType0C 嵌入;
    // CID-keyed 时嵌入副本会拿到 identity charset
    FontID := Lib.AddTrueTypeFontFromFile('C:\Fonts\NotoSansSC-Regular.otf');
    if FontID = 0 then
      raise Exception.Create('the font file could not be read or parsed');
    Lib.SetTextSize(18);
    Lib.DrawText(56, 760, '季度报表:华东区销售汇总');
    Lib.DrawText(56, 730, '发票号码 2026-11-0187,金额 ¥12,480.00');
    if Lib.SaveToFile('noto-sc.pdf') <> 1 then
      raise Exception.CreateFmt('save failed, error %d', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

用渲染来检查输出,文本抽取看不出问题。pdftotext 和所有其他抽取器读的都是 ToUnicode,而 ToUnicode 以内容流编码为键,完全不关心字体为这个编码挑了哪个轮廓,所以显示错字的页面照样抽出正确的字符串。mutool draw -r 150 -o page%d.png noto-sc.pdf 能看到页面的真实长相,这条 DrawText 路径下 poppler 的 pdffonts noto-sc.pdf 应列出字体为 CID Type 0C。预期结果:两行文字都以 Noto Sans SC 渲染出源文本指定的字形,Ghostscript 下的输出相同

哪种嵌入形式需要 PDF 1.6?

OpenType 那一种。ISO 32000-1 的 Table 126 把 CIDFontType0C 子类型的 FontFile3 流列为 PDF 1.3 特性,而 OpenType 子类型——连同 CFF 表的整个 sfnt——最早出现在 PDF 1.6。新建的 PDFlibPas 文档从 1.5 起步,裸 CFF 的 DrawText 路径留在这个版本内没问题。对 CFF 字面第一次调用 DrawOpenTypeText、DrawShapedText 或竖排文字,就会创建 OpenType 伴生字体并把文档当场提到 1.6,GetPDFVersion 立即可见。版本锁会改变这一切:LockSaveVersion 把文档钉在更低版本时,提版被拒绝,SaveToFile 返回 0、LastErrorCode 为 602,不会有文件宣称一个它的字体达不到的版本。当交付规范把版本固定在 1.6 以下——建立在 PDF 1.4 之上的 PDF/A-1 是最常见情形——CJK 文本就用 .otf 走 DrawText,需要整形的场合改用该字族的 TrueType 版本

PDFlibPas CFF 字体嵌入的 PDF 版本流转:DrawText 让 FontFile3 CIDFontType0C 停在 PDF 1.5,DrawOpenTypeText 与 DrawShapedText 把版本提到 PDF 1.6,LockSaveVersion 则让 SaveToFile 以错误 602 失败
DrawText 把 CFF 字面保持为裸 FontFile3 CIDFontType0C 留在 PDF 1.5 内,整形调用则切换到需要 PDF 1.6 的 OpenType 伴生字体,与更低的 LockSaveVersion 锁相冲突
var
  Lib: TPDFlib;
  FontID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    FontID := Lib.AddTrueTypeFontFromFile('NotoSansSC-Regular.otf');
    Writeln('before: ', Lib.GetPDFVersion);      // 预期输出:1.5
    if Lib.DrawOpenTypeText(56, 760, FontID, 18, '《年度报告》第三章', 0) = 1 then
      Writeln('after: ', Lib.GetPDFVersion);     // 预期输出:1.6
    Lib.SaveToFile('shaped.pdf');  // pdffonts 预期输出:CID Type 0C (OT)
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    Lib.LockSaveVersion('1.5');
    FontID := Lib.AddTrueTypeFontFromFile('NotoSansSC-Regular.otf');
    Lib.DrawOpenTypeText(56, 760, FontID, 18, '《年度报告》第三章', 0);
    if Lib.SaveToFile('locked.pdf') = 0 then
      Writeln('save refused, error ', Lib.LastErrorCode);  // 预期输出:602
  finally
    Lib.Free;
  end;
end;

宽度与 ToUnicode 以同一套数字为键

charset 一旦变成 identity,CID、字形索引和内容流编码就是同一个数字,宽度跟着它走。ISO 32000-1 §9.7.4.3 直接以 CID 索引 /W,分组形式要么是起始 CID 加宽度数组,要么是首 CID、末 CID 加一个共享宽度,没有简单字体那种 /FirstChar 偏移。数组没列到的 CID 取 /DW,Table 117 把 /DW 默认值定为 1000。零是错的——对未列出的 CID 回落到 0 的阅读器,会把一整行汉字叠在一处。对经 AddTrueTypeFontFromFile 载入的 .otf,PDFlibPas 写出的 /W 数组从 CID 0 起列出每个字形的步进宽度,旁边是显式的 /DW 1000。阅读侧那头,抽出来全是乱码的 CJK 文本通常是同一条管线的 codespace 半边出了错,而 检查宽度、CharSet 与 CIDSet 的 PDF/UA 字体审计会拿这些数组对照嵌入的字体程序

ToUnicode 有自己的编号规则。bfrange 条目只存第一个目标字符串,§9.10.3 要求它的最后一个字节不能被区间长度推过 255,因为阅读器生成后续目标时只递增最后那个字节。连续字形映射到 U+4EFE、U+4EFF、U+4F00、U+4F01 看着像一个区间,其实不是:阅读器会把第三个解成 U+4E00。PDFlibPas 写入程序只在源编码与目标编码都落在同一个 256 块内时,才把相邻的字形到 Unicode 对合并成一个 bfrange,否则拆开。竖排 CJK 走同样的伴生字体,所以竖排日文与中文文本无需额外调用就继承 identity charset 和这些 ToUnicode 区间

InspectOtfCharset 报告为 CID-keyed 且 charset 非 identity 的每个字面,都是字形索引编码与字体本身对不上的那类。经 PDFlibPas for Delphi 嵌入后,它的轮廓、偏移和 charset 长度原封不动,变的只是那一张表里的 CID;保留整个 sfnt 时再加上各校验和