技術記事

OTF フォントで CJK グリフが化けるときは CFF charset を疑え

.otf ファイルから Noto Sans SC で組んだ中国語の請求書を mutool で開くと、顧客名があるべき位置に無関係な漢字が出てくることがあります。同じページでも TrueType フォントなら正しく描けます。原因はフォントの CFF charset です。PDFlibPas は埋め込みコピーの charset を identity に書き換えるので、glyph-index コードがそのまま狙いのグリフに届きます

フォントによってこの問題が出るか出ないかは、作られ方次第です。CIDFontType2 フォントとして埋め込まれた TrueType プログラムには CIDToGIDMap として Identity が与えられ、content stream のコードはそのまま狙いのグリフに着地します。CFF アウトラインを持つ大半の欧文 OpenType フォントは name-keyed で、name-keyed の CFF プログラムは入ってきた CID をそのまま glyph index として使います。ISO 32000-1 の §9.7.4.2 ですら「多くのフォントでは CID と GID が一致する」と但し書きを添えるほどです。ただし全部ではありません。CID-keyed フォントとして作られた大型 CJK ファミリー(Noto Sans CJK とその Source Han シリーズなど)は独自の CID 番号を持っていて、glyph index をその番号空間へ直送する writer は、一致する CID を持つどんなグリフでも引っ張ってきます

CID-keyed CFF プログラムは CID をどう扱うか

ISO 32000-1 の §9.7.4.2 は、Top DICT を見て埋め込み CFF プログラムを 2 つのケースに振り分けます。Top DICT が CIDFont オペレータを使う——実際には ROS(オペレータ 12 30:registry、ordering、supplement)で始まる——なら、リーダーは content stream から CID を取り出し、その CID を charset エントリに持つグリフを探し、それから CharStrings INDEX へアウトラインを取りに行きます。CIDFont オペレータがなければ、CID がそのまま glyph index で、charset は出番なし。したがって §9.7.4.2 の文章どおりに従うリーダーは、charset が identity でない限り、どのビューアに切り替えても glyph-index コードに誤ったグリフを描くことになります。Adobe Technical Note #5176 で定義される charset 自体は、3 つのレイアウトと 2 つの特殊ケースから成ります:

PDFlibPas による Delphi での CJK フォント埋め込み。ISO 32000-1 §9.7.4.2 に基づく CID-keyed CFF charset の参照は、埋め込みコピーの charset が identity でない限り glyph-index コードに誤ったグリフを返す
glyph-index コードが CID-keyed CFF フォントで狙いのアウトラインに届くのは、charset がすべてのグリフを同じ番号の CID へ写しているときだけです
  • format 0 はグリフごとに 2 バイトの SID または CID を glyph 順に 1 つずつ格納します
  • format 1 は範囲で格納します。先頭の CID に、それに続く連番 CID のグリフ数を 1 バイトで置いたものです
  • format 2 は同じ範囲レイアウトでカウントだけ 2 バイトになり、長い連なりを持つフォントに向いています
  • グリフ 0 は .notdef で、どの format にもエントリを持ちません。0、1、2 の charset オフセットはデータを指す代わりに定義済み charset の 1 つを名指します

まず手持ちのフォントがどの種類かを見分けるところから始めましょう。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 を織り込まずに glyph index を content stream に書き込む writer はどれも、このフォントで誤ったグリフを描きます。name-keyed という結果、あるいは charset がたまたま identity になっている CID-keyed フォントなら、同じ writer で正しく描けます。製品に入れた 1 つ目の CJK フォントは動くのに 2 つ目が動かない、というのはこうして起こります。ループが比較するのは各範囲の先頭 CID だけです。それで足ります。範囲は連番 CID を持つ連続グリフを覆うので、自分の glyph index から始まった範囲は最後までその上を歩きます

glyph index はフォント辞書の全体を巡る

PDF writer の手元に CID が揃っていることはまれです。cmap テーブルも shaping エンジンも glyph index を返してくるので、素直な設計では、それを 2 バイトの Identity-H コードとして書き込み、残りのすべてをその番号に紐付けます。/W の幅配列、PDF/A が求める /CIDSet ビットマップ、抽出で原文を復元できるようにする ToUnicode CMap がその対象です。TrueType には逃げ道があります。Table 117 は CIDToGIDMap を用意していますが、対象は Type 2 CIDFont だけ。CFF プログラムは CIDFontType0 フォントに住んでいて、PDF 側から CID を付け替える手段を持ちません。これは、TrueType で使われる手——フォント終端の先に再利用グリフへ 2 つ目の「エイリアス」CID を与え、使うごとに ToUnicode で別テキストを貼る——までを塞ぎます。CFF フォントではそのエイリアスが選ぶグリフはなく、mutool は描けないと報告します。U+4E00 と U+2F00(KANGXI RADICAL ONE)を 1 つのアウトラインに束ねるフォントはよくあるケースです。2 つを別々の文字として抽出したいなら、該当箇所を /ActualText(ISO 32000-1 §14.9.4)付きの marked-content スパンで包みましょう。リーダーはフォントの写像の代わりにこれを使います

charset をその場で書き換える

glyph-index コードと CID-keyed プログラムを和解させる道は 2 つあります。1 つは書き込む前にすべての glyph index をフォントの CID へ翻訳する方法で、content stream のコード、/W のキー、/CIDSet のビット位置、ToUnicode の元コード、保存時の全サブセットに手が及びます。もう 1 つは、食い違っている唯一の構造である charset を変更し、name-keyed プログラムと同じ応えをフォントが glyph index に返すようにする方法です。安全なのは 2 つ目です。shaper、幅テーブル、サブセット化、ToUnicode 構築はどれも glyph 空間で働き続け、CID を理解しなければならないコードは、フォントの埋め込みコピーに 1 回だけ走る短いルーチン 1 本に減ります。PDFlibPas はこの道を選んでいます

書き換えは charset の format とバイト長を保ったまま行われます。format 0 では各エントリをそのグリフ自身の glyph index で上書きし、format 1 と 2 では各範囲の先頭 CID だけが変わってカウントはそのまま。伸びる要素も縮む要素もありません。CharStrings、Private DICT、FDArray、FDSelect への Top DICT オフセットは何も再エンコードせずに有効なままですし、glyph index ごとにヒンティング辞書を割り当てる FDSelect は、全グリフを元の FD に留めます。name-keyed プログラム、定義済み charset、CFF2 テーブル(charset を持たない)、パースできないプログラムは触られません。この書き換えを走らせるのは 3 つのコードパスです:

PDFlibPas が CID-keyed CFF charset をその場で identity に書き換える様子。format 0、1、2 のバイト長を保つので Top DICT オフセットは有効のまま、AddTrueTypeFontFromFile、サブセット、DrawOpenTypeText のどれにも共通する
PDFlibPas が書き換えるのは埋め込み CFF コピーの charset 内の CID だけで、format と長さはそのまま、動く Top DICT オフセットは 1 つもありません
  • .otf に対する AddTrueTypeFontFromFile は、素の CFF テーブルをサブタイプ CIDFontType0C の FontFile3 として埋め込み、charset は書き換え済みにします
  • 保存時のサブセットは元の glyph index を保ちつつ元の CID も抱え込むので、その charset は 2 度目の書き換えを受けます
  • DrawOpenTypeText と DrawShapedText の背後にある shaping 用コンパニオンは 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 を読みます。それは content stream のコードをキーにしていて、フォントがそのコードにどのアウトラインを当てるかは知らないので、誤った漢字を表示しているページでも正しい文字列が抽出されます。mutool draw -r 150 -o page%d.png noto-sc.pdf がページの実際の姿を見せてくれます。この DrawText パスなら、poppler の pdffonts noto-sc.pdf がフォントを CID Type 0C と一覧するはずです。期待される結果は、2 行とも 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 で組むか、shaping が必要な箇所には同ファミリーの 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 に収めますが、shaping 系の呼び出しは 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 と glyph index と content stream コードは 1 つの番号に束ねられ、幅もそれに従います。ISO 32000-1 の §9.7.4.3 は /W を CID そのもので索引付けします。開始 CID に幅の配列を続ける形か、最初の CID、最後の CID、共有幅 1 つの形のグループで、simple font が使う種類の /FirstChar オフセットはありません。配列が置いていった CID は /DW を引き、Table 117 は /DW の既定値を 1000 に定めています。0 では困ります。リスト外の CID に 0 でフォールバックするリーダーは、漢字の 1 行を 1 点に折り畳みます。AddTrueTypeFontFromFile で読み込んだ .otf には、PDFlibPas が CID 0 から始めて全グリフの送り幅を並べる /W 配列を、明示的な /DW 1000 と並べて書き込みます。読み手側では、文字化けして抽出される CJK テキストの不具合はたいてい同じパイプラインの codespace 側で起き、幅、CharSet、CIDSet の PDF/UA フォント監査がこれらの配列を埋め込みプログラムと突き合わせてくれます

ToUnicode には自分専用の番号規則があります。bfrange エントリが保存するのは宛先文字列の最初の 1 個だけで、§9.10.3 は範囲の長さによって最終バイトが 255 を超えないことを求めます。リーダーはその最終バイトだけを 1 ずつ進めて後続の宛先を生成するからです。連続するグリフが U+4EFE、U+4EFF、U+4F00、U+4F01 へ写るなら 1 つの範囲に見えます。そうではありません。3 番目をリーダーは U+4E00 と解読します。PDFlibPas の writer が隣接する glyph-to-Unicode の組を 1 つの bfrange に統合するのは、元コードと宛先が 256 の 1 ブロック内に収まるときだけで、それ以外は連なりを分割します。CJK 縦書きは同じコンパニオンフォントを通るので、縦書きの日本語・中国語テキストも追加呼び出しなしに identity charset とこの ToUnicode 範囲を受け継ぎます

InspectOtfCharset が CID-keyed かつ非 identity の charset と報告したフェイスはすべて、glyph-index コードとフォントが食い違うフェイスです。Delphi 用 PDFlibPas から埋め込まれれば、アウトラインもオフセットも charset 長もそのまま何も変わらず、変わるのはあの 1 つのテーブルの中の CID、sfnt 全体を保持するときはそのチェックサムだけです