技術記事

DelphiのHotPDFでフォントサブセットをディスクにキャッシュ

同じテンプレートから同じTrueTypeフェイスでレンダリングし、顧客の姓だけが異なる2つの請求書は、フォントサブセットキャッシュに2つの別ファイルを残します。姓には最初の請求書が一度も使わなかった大文字のQと小文字のzが含まれるため、保持するグリフの集合が変わり、それに応じてサブセットとキャッシュキーも変わります。永続サブセットキャッシュが報いるのは、ドキュメントのグリフレパートリーが繰り返されるときです。公開されているカウンタを見れば、自分のワークロードがそうかどうかは1バッチの中で判別できます

HotPDF の 4 つのフォントサブセットキャッシュキーの図。フォント内容、グリフ集合、サブセットモード、スキーマバージョン。1 つの名前だけ異なる 2 つの請求書が異なるエントリへ着地
顧客名 1 つだけが違う 2 つの請求書は 2 つのキャッシュエントリーです。キャッシュが回収に上がるのは、ドキュメントがグリフのレパートリーを共有するときだけです

HotPDFでディスクキャッシュを有効にする

HotPDFのサブセットキャッシュは、フォルダを指定するまでオフのままです。THotPDFの2つのプロパティがこれを制御します。FontSubsetCacheFolderがディレクトリを指定し、FontSubsetCacheMaxBytesが保持量の上限を定めます。デフォルトは268435456バイト(256 MiB)です。キャッシュが有効なのは、フォルダが空でなく、予算がゼロより大きい間だけです。フォルダを代入すると、ディレクトリがなければ作成し、そこに既にあるものを即座に予算内へ切り詰めます

サブセット化がキャッシュに値するのは、実際にコストのかかる作業だからです。TrueTypeサブセットの構築は、複合グリフのクロージャーをたどり、locaとglyfを書き直し、グリフを索引付けするテーブルを組み立て直し、PDFがアドレス指定できるCIDマッピングを生成することを意味します。1つのドキュメントならコストはノイズに埋もれます。同じ3つのフェイスでループ内に何千件もの明細書をレンダリングするレポートサーバーでは、同じサブセットが何度も何度も組み立てられます。キャッシュが返してくれるのは、まさにこのCPU時間です

var
  Pdf: THotPDF;
  Info: THPDFFontSubsetCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.EnableFontSubsetting := True;
    // ASCIIだけのパスにする。プロパティはAnsiString型
    Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
    Pdf.FontSubsetCacheMaxBytes := 64 * 1024 * 1024;   // デフォルトの256 MiBの代わりに64 MiB
    // ... バッチ内の各ドキュメントをBeginDoc / 描画 / EndDoc ...
    Info := Pdf.GetFontSubsetCacheInfo;
    // LogFmtは独自のロギングルーチン
    LogFmt('subset cache: %d hits, %d misses, %d bytes in %d files',
      [Info.HitCount, Info.MissCount, Info.CurrentBytes, Info.FileCount]);
  finally
    Pdf.Free;
  end;
end;

キャッシュは両方のサブセット化経路の背後にあります。TrueTypeとOpenType-CFFフォントを対象にするEnableFontSubsettingでサブセット化されるフェイスと、RegisterUnicodeTTFで登録されたフェイスの両方です。描画コードの他の部分は何も変わりません。フォルダのパスはAnsiStringとして宣言されているため、システムコードページ外の文字を含むディレクトリ名はトラブルの種です。ProgramDataの下やサービスのデータディレクトリにある素直なASCIIパスにすれば、問題ごと回避できます

キャッシュキーの中身

保存済みサブセットを再利用できるかどうかは、4つの入力で決まります。ソースフォントの正確なバイト列、ドキュメントが保持するグリフの集合、サブセットモード、そしてキャッシュ形式のバージョンです。各キーはフォントバイト列のSHA-256ハッシュと、使用グリフIDのビットマップのSHA-256ハッシュから組み立てられ、v1-を前置きにし、スパースなサブセットには-s、コンパクトなものには-cを後ろに付けます。エントリは<key>.hpdfsubsetという名前でディスクに並ぶので、ディレクトリ一覧を見ればバッチがどのモードを生成しているかひと目で分かります

フォントをファイル名ではなく中身でハッシュすることには、実用的な帰結があります。Arialの更新や社内フォントの新しいビルドへの差し替えは、キーを自動的に新しくし、古いエントリは追い出されるまで単純にヒットしなくなるだけです。フォント更新の後に正しい出力を得るためにキャッシュをクリアする必要はありません。クリアするのは、空き領域を少し早めに取り戻すためだけです

サブセットモードは、人が忘れがちな入力です。CompactFontSubsettingは保持グリフを密な範囲に番号付けし直し、明示的な/CIDToGIDMap(ISO 32000-1 §9.7.4.2)を書き出します。PDFAComplianceが設定されている場合、登録フェイスは常にコンパクトなサブセットを受け取ります。CompressDocumentも、ドキュメントの期間中はコンパクトサブセットを有効にします。つまり、同じ明細書を普通のPDFとして1回、PDF/Aとして1回書き出すサービスは、同一のグリフに対して2組のエントリを埋めていきます。1つは-sで終わり、もう1つは-cで終わります。OpenType-CFFフェイスは常にスパースの経路をたどります

ヒット率を左右するのはグリフの集合です。固定テンプレートからの明細書、可変データがほぼ数値のフォーム、1つの製品データベースから組み立てたカタログ。こうしたものはドキュメントごとに同じグリフを再利用します。数万グリフのCJKフォントから、ドキュメントごとに異なる部分を引き出すワークロードはほぼミスします。ほぼすべてのドキュメントでビットマップが新しくなるからです。ワークロードの種類を判断する前に、代表的なバッチを1回走らせ、カウンタを読んでください

キャッシュが何かしているとどうやって知るのか

GetFontSubsetCacheInfoは9つのフィールドを持つTHPDFFontSubsetCacheInfoレコードを返します。HitCountとMissCountの対比がこの問いに直接答えます。WriteCountとEvictionCountは、エントリが再利用されるほど長く生きるのか、それとも厳しすぎる予算に押し出されるのかを示します。CurrentBytesとFileCountは、フォルダの現在の状態をそのまま記述します

HotPDF の GetFontSubsetCacheInfo 9 カウンターのパネル。ヒット率、生存、節約に分類し、変更すべき設定を指し示す 3 つの読み取り値を併記
HitCount と MissCount の比率が、キャッシュが役に立っているかどうかに答え、その他のカウンターが、立っていないならその理由を語ります

監視に組み込む際に効いてくる詳細が2つあります。7つのイベントカウンタはTHotPDFインスタンスに属し、コンポーネントの生成時にゼロから始まります。ドキュメントごとにコンポーネントを生成するサービスでは、1回の読み取りに1ドキュメント分の活動しか映りません。対照的にCurrentBytesとFileCountは、呼び出した瞬間にフォルダを列挙して得られる値です。そのフォルダを使うすべてのプロセスの状態を反映する一方、呼び出しごとにディレクトリスキャンのコストが掛かります。レコードはバッチの最後に1回だけ読み、ドキュメントごとのループには持ち込まないことです

残る3つのカウンタは、アラートを上げる価値のあるものです。CorruptCountは、読み込み時の検証に失敗して削除されたエントリを数えます。これらは1件ずつミスとしても数えられます。停電の後に数件あるのは想定内ですが、絶え間なく増え続けるならストレージを疑います。RejectedCountは、キャッシュが保存を拒否したサブセットの数です。典型的な原因は、1つのサブセットが予算全体より大きいことです。WriteFailureCountは、完了しなかった書き込みの数です。実務ではほぼ必ず、サービスアカウントが書き込めないフォルダが原因になります。3つともドキュメント生成を中断させません。だから書き込み権限に問題があるキャッシュは、キャッシュなしとまったく同じPDFを生成し続けます。速くなり得ないだけです

// SubsetCacheNeedsAttentionは自分で追加するヘルパー。
// 閾値は例にすぎない。バッチサイズに合わせて調整すること
function SubsetCacheNeedsAttention(const Info: THPDFFontSubsetCacheInfo;
  out Reason: string): Boolean;
begin
  Reason := '';
  if Info.WriteFailureCount > 0 then
    Reason := 'cache folder is not writable for this account'
  else if Info.RejectedCount > Info.WriteCount then
    Reason := 'subsets exceed FontSubsetCacheMaxBytes, raise the budget'
  else if (Info.EvictionCount > Info.WriteCount div 2) and (Info.HitCount < Info.MissCount) then
    Reason := 'entries are evicted before they are reused'
  else if Info.CorruptCount > 10 then
    Reason := 'repeated damaged entries, check the storage';
  Result := Reason <> '';
end;

予算、追い出し、そしてフォルダを空にするスイッチ

追い出しは、ファイルの更新時刻によるLRU方式です。読み込みが成功するたびにエントリのタイムスタンプが更新され、保存のたびに切り詰めが実行され、フォルダが予算に収まるまで古いファイルから削られていきます。実行時にFontSubsetCacheMaxBytesを下げると、次の書き込みを待たずにその場で切り詰めます。ディスク容量のアラートに反応するサービスは、判断したその瞬間にキャッシュを縮められます

この即時性には鋭い面もあります。フォルダが設定された状態で予算をゼロにすると、ゼロまで切り詰められ、その中の.hpdfsubsetファイルがすべて削除されます。一方、FontSubsetCacheFolderに空文字列を設定すると、キャッシュがオフになり、ファイルは元の場所にそのまま残ります。どちらのスイッチも次のドキュメントからキャッシュを無効化しますが、1週間分の温まったエントリを残すのは片方だけです。トラブルシューティングでは、フォルダプロパティを空にし、予算には手を触れません

// 診断実行のためにキャッシュを一時停止し、保存済みエントリはすべて残す
Pdf.FontSubsetCacheFolder := '';

// 危険:フォルダ設定中に予算をゼロにすると、フォルダは空に切り詰められる
// Pdf.FontSubsetCacheMaxBytes := 0;

// 意図的なリセット:*.hpdfsubsetと残存テンポラリファイルを削除する。
// イベントカウンタの値は保持される
Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
Pdf.ClearFontSubsetCache;

キャッシュを一時停止した状態での診断実行が、キャッシュ要因を排除する最もきれいな方法です。/BaseFontの6文字のサブセットタグは保持グリフ集合から導出されるため、同じグリフはキャッシュの有無にかかわらず同じタグを生み、埋め込まれたフォントプログラムは一致するはずです。ファイル全体のハッシュは作成日時とドキュメントIDのせいで依然として異なります。比較するのはファイルではなく、フォントストリームです

壊れた、あるいは途中まで書かれたエントリはどうなるのか

壊れたエントリは削除され、サブセットは通常どおり構築されます。エントリの何かがPDFストリームに届く前に、です。読み込み時にHotPDFは、ファイルシグネチャ、形式バージョン、サブセットモードフラグ、ソースフォントに対するグリフ数、宣言された長さと実際のファイルサイズ、そして保存されたフォントプログラムのsfntヘッダを検査します。不一致があればエントリを捨て、CorruptCountを増やし、メモリ内サブセット化へフォールバックします。壊れたフォントプログラムが顧客のPDFに入り込むと、その障害は何週間も後に、誰か他の人のマシンのビューアで、原因から遠く離れて表面化します。この検証は、まさにそれを防ぐために存在します

HotPDF の破損キャッシュエントリ処理のフローチャート。使用前に検証、PDF ストリーム以前に拒否と削除、メモリ内サブセット化へフォールバック
壊れたエントリーはフェイルオープンします。ドキュメントは引き続き生成され、PDF/A 出力では CID 再マッピングを保持しているためキャッシュが使われます

途中まで書かれたエントリは、そもそも現れません。保存はまず、プロセスIDとコンポーネントインスタンスを名前に持つ一時ファイルへの書き込みから始まり、その後、置換とフラッシュを1回のリネームで行い、最終名の上へ移動します。書き込みの途中でクラッシュすると、迷子の.tmp-ファイルが残りますが、それはClearFontSubsetCacheが回収します。本当のエントリは、完成した旧バージョンか完成した新バージョンのどちらかです。コンパクトエントリはフォントプログラムと並んで旧から新へのグリフマップも保存するため、キャッシュされたコンパクトサブセットは同じ/CIDToGIDMapを再構築でき、PDF/A出力はキャッシュをバイパスせずに使えます

フォルダはどこに置くべきか

場所を決めるのは3つの条件です。サービスアカウントがそこに書き込めること、フォルダがローカルストレージ上にあること、そしてデプロイステップがそこを消し去らないことです。ネットワーク共有はすべての参照をラウンドトリップに変え、リネームのセマンティクスをファイルサーバーの手に委ねます。インストーラーが再作成するアプリケーションディレクトリ内のフォルダは、更新のたびにキャッシュがコールドスタートになることを意味し、性能を最も注意深く見ているその日に、静かに効果を消し去ります

複数のコンポーネントや複数のプロセスが、1つのローカルフォルダを共有できます。一時的な名前が衝突することはなく、最終的なリネームはアトミックで、競合に負けても損害はミスか、次のドキュメントでやり直す書き込み程度です。1台のマシンでは共有がより良いデフォルトです。どのインスタンスも他のインスタンスのためにキャッシュを温めるからです。目に見える副作用が1つあります。各インスタンスのカウンタは自分のトラフィックしか記述しない一方、CurrentBytesとFileCountは全員の分を記述すること、そしてWriteFailureCountが時々刻むのは、2つのプロセスが同じエントリを同時に置き換えたせいかもしれないことです。読み取り専用のフォルダでも既存のエントリは提供できます。ただしタイムスタンプを更新したり新しいエントリを追加したりはできません

キャッシュが正しいレバーでないとき

キャッシュが減らすのは繰り返される作業です。バッチの最初のドキュメントには何もしてくれず、グリフ集合が二度と繰り返さないワークロードにはほとんど何もしません。出力が、予測できないテキストにわたって使われる1つの大きなCJKフォントに支配されているなら、どのグリフが各サブセットに引き込まれるのか、その理由を見ます。これはフォントサブセットクロージャーとシェーピンググリフのノートが扱っています。CPUではなくファイルサイズが目的なら、CompressDocumentとコンパクトフォントサブセットの記事が、コンパクトモードが埋め込みフォントの何を変えるかを説明します。バッチがフォントとは無関係な理由で遅いと判明したら、フォントと画像を伴うレポート出力の解説が残りの時間の行き先を示し、EndDocのフォントサブセット順序の記事が、ドキュメントの仕上げ段階でいつサブセットが組み立てられるかを扱います

サブセットキャッシュはコンポーネントの中に組み込まれているため、Delphiや C++Builder のレポートサーバーは、フォルダパスと予算を設定するだけで使えます。デプロイも監視も必要な追加サービスはありません。フォントの埋め込み、サブセット化、その他のPDF生成機能の一覧は、HotPDF Delphiコンポーネントページに掲載されています