💡 完全な動作例は GitHub で入手可能です:
sign-pdf-in-linux-container-fonts-dotnet

古い方法は苦痛だった

サービスは請求書に署名します。300 種類のフォントがインストールされたノートパソコン上で動作し、レビューを通過し、金曜日にコンテナ化されます。月曜日にクラスターで最初のジョブが Sign document error: Font Arial was not found で非ゼロ終了し、誰も mcr.microsoft.com/dotnet/runtime:8.0 イメージに実際にどのフォントが含まれているかを尋ねる前に、朝からスタックトレースを読むことになります。

答えは「なし」です。この記事のサンプルが実行されているイメージにはフォントファイルが 0 個です。

他のランタイムがどのように比較されるかを知っておく価値があります。失敗はランタイムごとに異なるからです。eclipse-temurin:17-jre は 8 つの DejaVu ファイルを、node:18-bookworm は 6 つを AWT 用にバンドルしているため、JVM と Node のイメージはラテン文字の署名は静かに行われ、日本語や中国語の文字列が来たときにだけ失敗します。python:3.11-slim は .NET ランタイムイメージと同様にフォントが 0 個で、最初の署名で失敗します。どのイメージでも CJK は無料で手に入りません。

コンテナでのフォント提供は、Linux イメージ上で GroupDocs.Signature for .NET を使用してテキスト署名を機能させるステップです。ライブラリは欠落したファミリを代替しないため、インストールされていないフォント名を指定するとエラーが発生し、ドキュメントは書き込まれません。本記事ではフォントなしイメージと修正済みイメージを並べて比較し、何が変わったかを示し、開発マシンでも同じコードが動作し続けるランタイム解決について説明します。

より良い方法がある

2 つの条件が必要です。イメージに少なくとも 1 つのフォントが必要で、コードはどのフォントかを前提としないようにする必要があります。

最初の条件は Dockerfile のレイヤーです。2 番目は解決ステップです:Arial をハードコーディングする代わりに、ライブラリに実際に使用できる候補ファミリのうちどれかを問い合わせ、最初に動作したものを保持します。その結果、スリムコンテナ、Windows、CI のいずれでもコードは変更せずに実行でき、環境をチェックしていない前提で何も主張しません。

機能しないことの一例として、フォントを未設定のままにする方法があります。これは最初に試すことが多いため、はっきり言っておきます:SignatureFont を省略すると、GroupDocs.Signature はデフォルトの Times New Roman を要求しますが、フォントなしイメージにもそれがありません。呼び出しは同様に失敗します。

新しい方法:2つのイメージ、1つの違い

ステップ1 - イメージに何があるか確認する

署名を行う前にフォントファイルを一覧表示します。数がゼロであることは曖昧な例外を診断に変えます。ゼロフォントと誤ったファミリ名は異なる修正が必要です:

string[] roots =
{
    "/usr/share/fonts",
    "/usr/local/share/fonts",
    Path.Combine(home, ".fonts"),
    Path.Combine(home, ".local/share/fonts"),
    Environment.GetFolderPath(Environment.SpecialFolder.Fonts),
    "/System/Library/Fonts",
    "/Library/Fonts",
};

欠如しているものに注意してください:System.Drawing。System.Drawing.Common は .NET 7 以降 Windows のみで利用可能で、Linux では例外をスローします。そのため、System.Drawing に依存したフォントコードはコンテナ内で別の理由で失敗します。

ステップ2 - フォントレイヤーを追加する

4 つのパッケージ、1 つの RUN で失敗が消えます:

RUN apt-get update && apt-get install -y --no-install-recommends \
        fontconfig \
        fonts-dejavu-core \
        fonts-liberation \
        fonts-noto-cjk \
    && fc-cache -f \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

fontconfig はリゾルバで、デバッグ用に fc-list を提供します。fonts-dejavu-core はラテン、ギリシャ、キリル文字の最小セットです。fonts-liberation は Arial、Times New Roman、Courier New のメトリック互換代替を提供し、Windows で作成されたドキュメントが実際に参照するものです。fonts-noto-cjk は中国語、日本語、韓国語をカバーします。

ステップ3 - ファミリ名を指定する代わりに解決する

ポータブルなフォント選択方法は、各候補に対して使い捨て署名を試み、例外が出なかった最初のものを保持することです:

foreach (string candidate in candidates)
{
    if (TryFamily(sourcePath, candidate).Ok)
    {
        return candidate;
    }
}

return null;

ファイル名での検出は魅力的なショートカットですが間違いです。Debian の fonts-noto-cjk は NotoSansCJK-Regular.ttc をインストールしますが、ファミリ名は Noto Sans CJK JP です。ファイル名マッチでは実際に存在するフォントを見逃し、SignatureFont に渡したときに解決できないファミリを主張してしまいます。

ステップ4 - 解決したものに署名し、署名したものを検証する

解決されたラテン系ファミリは必須です。解決された CJK 系ファミリはオプションで、存在しない場合はスキップされ、クラッシュは起きません:

var options = new List<SignOptions>
{
    BuildTextOptions(LatinText, latinFamily, top: 50),
};

if (cjkFamily is not null)
{
    options.Add(BuildTextOptions(CjkText, cjkFamily, top: 120));
}

SignResult result = signature.Sign(outputPath, options);

その後、ファイルを再度読み取ります。CJK フォントがないと CJK は空白のボックスとして描画され、何も例外が出ません:

var options = new TextSearchOptions { AllPages = true };
List<TextSignature> found = signature.Search<TextSignature>(options);

並列比較:前後

Dockerfile.nofonts Dockerfile
イメージ内のフォントファイル 0 DejaVu, Liberation, Noto CJK
ラテン文字テキスト署名 fails, exit 3 written and recovered on read-back
CJKテキスト署名 fails written and recovered
表示されたエラー Font <name> was not found none
コードの違い none - same binary none - same binary

最後の行がポイントです。2 回の実行間でアプリケーションは何も変わっていません。サンプルリポジトリは両方の Dockerfile を同梱しているため、比較は 2 回の docker build コマンドで行われ、信頼に頼る必要はありません。フォントなしバリアントもリポジトリに残しておくと便利です。これは、誰かが 6 ヶ月後にベースイメージを変更したときに、署名が静かに消える失敗を最速で再現できる方法です。

なぜすべてのフォントをインストールしないのか?

イメージサイズは実際の制約であり、上記の 4 つのパッケージだけでほとんどのドキュメントが使用するスクリプトをカバーしています。fonts-dejavu-core だけでラテン、ギリシャ、キリル文字の署名は十分です。ドキュメントが Windows のファミリ名で参照する場合は Liberation が必要です。Noto CJK は実際に大きく、東アジア文字を署名する場合にのみ必要です。ドキュメントが必要とするフォントだけをインストールし、読み戻しで検証してください。

実例:バッチ署名ワーカー

キュー ワーカーは夜間に数千件の PDF に署名します。起動時に解決を行うことで、使用するファミリ名を 1 行でログに出し、何も解決できなければキューに触れる前に終了します。これにより、フォント問題が失敗したジョブの連続から、1 行の理由で起動を拒否するコンテナへと変わります。

プローブコストは起動時に無視できるほど小さく、ドキュメントごとに繰り返すには大きすぎます。各プローブは実際の署名を書き込む一時ファイルなので、ラテンリストは最大 4 回、CJK リストは最大 8 回のプローブで、すべて 1 ページの PDF に対して行われます。1 回解決して 2 つのファミリ名をキャッシュすれば、ドキュメントごとのパスは以前と全く同じです:オプションを構築し、Sign を呼び出し、結果数を読み取ります。

私はこのバージョンで午後を失いました。フォントディレクトリを走査し、NotoSansCJK-Regular.ttc を見つけ、CJK が利用可能と報告したものの、そのファイル名から導出したすべてのファミリ名で失敗したのです。実際の署名でプローブする方がシンプルで正確でした。

コンテナで他に問題になることは?

もう一つ、フォントとは無関係な問題があります:InvariantGlobalization=true。これは .NET イメージから ICU をトリムする際の標準的なアドバイスですが、GroupDocs.Signature では最初の new Signature(...) が CultureNotFoundException: ... en-US is an invalid culture identifier をスローします。SignatureSettings が CultureInfo("en-US") を構築するためです。グローバリゼーションは有効にしておき、ICU をイメージに残してください。プラットフォームのサポートを確認するには、system requirements ページをご覧ください。

結論

ローカルでは動作し Docker では失敗する署名サービスは、ほぼ間違いなくフォントが欠如しています。解決策は 4 パッケージのレイヤーと、ファミリを前提せずに解決するコードです。サンプルから両方のイメージをビルドし、並べて実行し、[fonts] 行を確認してください。比較はこの 1 つの比較で完結します。

追加リソース