技術記事

DelphiでのSeparationおよびDeviceNスポットカラーのレンダリング

HotPDF は、HPDFResolveColorSpace を介して色空間を解決し、HPDFEvalTintTransform を介して色濃度変換(Tint Transform)関数を計算し、代替色空間(Alternate Color Space)経由で結果を画面表示用のRGBに変換することで、読み込まれたPDFページ上の Separation および DeviceN スポットカラー(特色)をレンダリングします。バージョン 2.375.0 以降、そのパイプラインは Type 4 PostScript 計算式を含む4つすべてのPDF関数タイプをパースするため、Pantoneインクで作成された印刷用のデータもダミーではなく本来の色で正しく描画されます。この記事では、このパイプラインの動作仕様と、開発過程で発生した不具合の事例について解説します。

この機能が必要となる背景は常に同じです。顧客が印刷所からPDFを受け取ったとします。そのファイルの見出しは名前付きのスポットカラーで指定され、パッケージのアートワークには2つのインクを混合する DeviceN カラーが使われ、本文は通常の黒で書かれています。Acrobatで開くときれいに表示されます。しかし、開発したDelphiアプリケーションで開くと、見出しが黒で描画されたり、あるいは描画自体がスキップされたりして、顧客はファイルではなくご自身のアプリケーションのバグとして報告してきます。ファイルに問題はありません。レンダラーが、そのファイルで使用されている色空間を処理できていないだけです。

PDFビューアでスポットカラーが黒でレンダリングされる理由

スポットカラーが黒で描画されたり消滅したりするのは、レンダラーがデバイス依存のカラー設定オペレーター(rggk)のみを処理し、汎用的なオペレーターを無視している場合に発生します。ISO 32000-1 §8.6 では、色空間を次の3つのグループに分類しています。「デバイス依存色空間」(DeviceGray、DeviceRGB、DeviceCMYK)、「CIEベース色空間」(CalGray、CalRGB、Lab、ICCBased)、および「特殊色空間」(Indexed、Separation、DeviceN、Pattern)。デバイス依存グループ以外の色空間は、すべて汎用オペレーターを使用して選択されます。cs および CS がページのリソース辞書から名前で色空間を選択し、その後に scSCscn、および SCN が各成分の値を供給します。これらのオペレーターを無視するレンダラーは、直近に設定されたカラーをそのまま使用し続けるため、例えばスポットカラーの見出しから始まるページでは、初期状態の DeviceGray の黒のまま描画されてしまいます。

HotPDFは、バージョン 2.333.0 においてページレンダラーに汎用オペレーターセットを追加し、統一された解決パスを導入しました。これにより、名前単体、インライン配列、間接参照のいずれの形式であっても、すべての /ColorSpace リソースエントリが単一の THPDFColorSpace レコードにパースされ、すべての塗りつぶしおよび線描画のカラー要求が単一の HPDFResolveColor 呼び出しに集約されます。サポートされている色空間の分類は、次の列挙型で確認できます。

type
  THPDFColorSpaceFamily = (csfDeviceGray, csfDeviceRGB, csfDeviceCMYK,
                           csfIndexed, csfCalGray, csfCalRGB, csfLab,
                           csfICCBased, csfSeparation, csfDeviceN,
                           csfUnsupported);

function HPDFResolveColorSpace(Obj: THPDFObject): THPDFColorSpace;

function HPDFResolveColor(const CS: THPDFColorSpace;
  const Comps: THPDFColorComps; CompCount: Integer): THPDFRenderColor;

ここでの設計上の判断として、csfUnsupported を単なるエラーではなく独立した区分として定義しています。解釈できない色空間を検出した場合は、処理を中断して描画を失敗させるのではなく、定義されたデフォルトの代替色にフォールバックして描画を継続します。これは主要なビューアの動作仕様と一致しており、特定の特殊な描画指定のせいでドキュメント全体が真っ白になるのを防ぎます。

色濃度変換によってインク値が本来の色に変換される仕組み

Separation 色空間は、「インクの名前」、「代替色空間」、および「色濃度変換関数」の3つの情報を保持します。例えば [/Separation /PANTONE485 /DeviceCMYK f] という配列は、「コンテンツストリームに 0.8 scn と書かれている場合、濃度値 0.8 を関数 f に渡し、出力された4つのCMYK値を適用して描画する」ことを意味します。DeviceN はこれをさらに一般化し、N個のインク入力を受け取るN入力関数を使用します。インクの名前自体は画面上での表示の参考情報に過ぎず、色濃度変換関数こそがレンダリングの動作仕様そのものであるため、色空間を解釈できても関数をパースできなければ、実用的なレンダリングは実行できません。

HPDFEvalTintTransform がこの処理を実行する関数エンジンです。バージョン 2.334.0 で導入され、Type 2 指数関数(Domain と Range によるクランプ処理付きの C0 + x^N * (C1 - C0))、Type 3 補間関数(Bounds で選択されたサブ関数の再帰処理と Encode の再マッピング)、および 8ビット、16ビット、32ビットのサンプル値を持つ Type 0 サンプル関数を計算します。Type 0 については、作成側の視点からType 0 カラー LUT(検索テーブル)の構築に関する記事で解説されています。レンダリング側はこれと同じデータ構造を逆向きにたどり、デコードされたサンプルバイトから各成分の値を復元します。

Type 4 PostScript 計算式関数への対応は、最も難易度の高い部分でした。バージョン 2.375.0 までは中性的なダミー色にフォールバックさせていましたが、2.375.0 以降、HPDFEvalPostScriptCalculator が ISO 32000-1 §7.10.5 で定義されているオペレーターセット(四則演算、比較演算、論理およびビット演算、roll を含むスタック操作、および if/ifelse 分岐条件)を、制限付きのオペランドスタック上で完全に実行できるようになりました。ここの処理規則は見た目以上に厳密です。PostScript の round は最も近い整数値に向かって丸めるため、Delphiの偶数丸め(Banker's Rounding)を実行する Round は使用できません。また、三角関数オペレーターは度(Degree)単位で動作し、atan は [0, 360) の範囲の値を返します。さらに exp は自然対数の底の指数関数ではなく、2つのオペランドを受け取るべき乗演算です。これと同じ評価エンジンは関数ベースのグラデーション塗りつぶしにも使用されているため、同じリリースにおいてグラデーションの軸状・放射状シェーディング描画における計算式駆動のカラーランプ対応も強化されました。

// Evaluate a Separation/DeviceN tint transform (Type 0/2/3/4).
function HPDFEvalTintTransform(FuncObj: THPDFObject;
  const Inputs: THPDFColorComps; InputCount: Integer;
  out AltComps: THPDFColorComps): Boolean;

// Type 4 PostScript calculator, ISO 32000-1 7.10.5 operator set.
function HPDFEvalPostScriptCalculator(const Prog: TBytes;
  const Inputs: THPDFColorComps; InputCount: Integer;
  var Outputs: THPDFColorComps; OutCount: Integer): Boolean;

精度に関する補足として、倍精度浮動小数点(Double)で計算するソフトウェア計算エンジンは、印刷用のRIP(Raster Image Processor)と1ビット単位で完全に一致するわけではなく、Range境界でのクランプ処理によって他の実装と最下位ビット(LSB)レベルで異なる値になる場合があります。画面表示や回帰テストの描画検証においては問題になりませんが、もし色管理された厳密な校正システム(Proofing System)を構築する場合は、この変換は第一段階に過ぎず、下流に本物のCMM(Color Management Module)を適用する必要があります。

ICCエンジンを使用しないCalGray、CalRGB、Lab、およびICCBasedの処理

CIEベースの色空間グループは、同じリゾルバーの別の分岐をたどります。HotPDFは、逆変換関数における 6/29 分割点(Breakpoint)の立方処理を含め、標準的な Lab から XYZ、さらに sRGB への変換式を適用して Lab 値を変換します。また、チャンネルごとのガンマ値と 3x3 の線形行列を持つ CalRGB、および単一のガンマ値を持つ CalGray を処理します。パフォーマンスに関連する詳細として、白色点(White Point)の処理が挙げられます。XYZからsRGBへの変換行列は、色空間内で宣言された白色点に対してブラッドフォード(Bradford)アダプテーションが適用され、解決済みの THPDFColorSpace レコードにキャッシュされるため、どのような光源が宣言されていても、ピクセルごとの処理は単純な 3x3 行列の乗算だけに抑えられます。

一方、ICCBased 色空間については、あえて実用性を重視した簡略処理を採用しています。PDF仕様書は、すべての ICCBased ストリームに対して /Alternate 色空間の宣言(または成分数 /N に基づくデフォルト色空間の推測)を要求しており、これによりカラーマネジメントエンジンを持たないビューアでも適切にレンダリングできるようになっています。HotPDFはこの代替色空間を介して ICCBased を解決します。代替色空間の指定が存在しない場合は、/N の値が 1, 3, 4 であることに基づいてそれぞれ DeviceGray, DeviceRGB, DeviceCMYK と推測し、カラープロファイルのバイナリデータのパース処理を完全にスキップします。これにより、外部ライブラリ(lcmsなど)への依存関係を排除し、プロファイル読み込みのコストをゼロに抑えることができますが、引き換えに色彩値の正確性は制限されます。プロファイルデータが代替色空間と大きく異なる場合、代替色空間の側で描画されます。画面表示やサムネイル生成においては、多くの軽量ビューアが採用している妥協点と同じであり、ご自身のドキュメントでも同様の仕様として定義してください。

名前付き色空間の検索がすべて失敗していたバグ事例

バージョン 2.333.0 の実装において、42回ものリリースで検出されなかった不具合がありました。cs および CS ハンドラが、先頭にスラッシュを付けた状態のオペランド(例: /CS0)を使用して、スラッシュを除いたキー(例: CS0)で登録されているリソース辞書を検索していたため、名前付き色空間の検索が常に失敗し、プログラムは警告なしにデフォルトの DeviceGray にフォールバックしていました。このバグの表示結果は、非常に気づきにくいものでした。Separation による 1 scn の塗りつぶしが DeviceGray の 1.0(白色)に代替され、白い紙に白で描画されたため、描画エラーとして画面キャプチャを撮って報告されることがなかったためです。バージョン 2.375.0 において、オペランドからリソースを検索するすべての箇所に共通の名前正規化処理を追加し、このバグを修正しました。

この調査の過程で、さらに関連する2つのバグが発見されました。第一に、配列型オブジェクトへの間接参照が未解決のまま返される不具合がありました。レンダラーのドキュメントアクセス処理は、ストリームと辞書のみを対象とする解決機能しか保持していなかったため、/CS0 5 0 R が独立した [/Separation ...] 配列を指している場合に素の参照データとして返され、色空間がサポート外として解析されていました。第二に、HPDFReadNumericArray が厳密な長さチェックを強制し、PDF配列が指定のバッファサイズ以上であることを要求していたため、Type 2 関数の /C0 および /C1 を4成分のバッファに読み取る処理において、1成分や3成分の代替色空間である場合に配列読み込みが失敗し、配列データがゼロクリアされたまま処理されていました。このため、CMYK以外の指数関数による色濃度変換は、すべて黒で描画されていました。バージョン 2.376.0 において、バッファに収まる範囲でデータを読み込む HPDFReadNumericArrayUpTo を導入し、これを修正しました。ここから得られる教訓は、可変長の数値配列を保持するPDFのキーを読み取る場合は、指定サイズと完全に一致することを要求するのではなく、存在するデータを可能な限り読み取る実装にすべきであるということです。そうしないと、正しい構造のファイルが不正にゼロクリアされてしまいます。

誤検知を防ぎつつスポットカラー描画をテストする方法

ここで、これほどの不具合がありながらなぜ既存のテストスイートをパスし続けていたのか、という点が問題になります。初期の回帰テスト SeparationRendersDistinguishable は、単に「レンダリング結果のビットマップが完全に真っ黒ではないこと」のみを検証していました。スポットカラーを DeviceGray に誤って代替していたパイプラインは、灰色や白色のピクセルを出力していたため、この「真っ黒ではない」というアサーションをすり抜けてしまっていたのです。「空白ではない」、「真っ黒ではない」、「ハッシュ値がゼロではない」といった曖昧なアサーションは、不具合がある状態でもピクセル自体は出力されるため、レンダラーの成否を正しく判定できません。

これらの不具合を確実に検出するためのテスト手法は、期待される色を直接判定することです。特定の色に解決される最小構成のPDFファイルをテストデータとして作成し、レンダリング後にその色のピクセル数をカウントします。検証のためのビットマップレンダリングには、ページレンダリングガイドで説明されている RenderLoadedPageToBitmap を使用します。

var
  Pdf: THotPDF;
  Bmp: TBitmap;
  X, Y, RedHits: Integer;
  Px: TColor;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('spot-red-fixture.pdf', '') > 0 then
    begin
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 96);
      try
        RedHits := 0;
        for Y := 0 to Bmp.Height - 1 do
          for X := 0 to Bmp.Width - 1 do
          begin
            Px := Bmp.Canvas.Pixels[X, Y];
            if (GetRValue(Px) > 180) and (GetGValue(Px) < 100) and
               (GetBValue(Px) < 100) then
              Inc(RedHits);
          end;
        // Lock the expected ink: demand a real area of red-dominant
        // pixels, never settle for "not all black".
        Assert(RedHits > 500);
      finally
        Bmp.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

テストデータ(フィクスチャ)の設計も、アサーションの記述と同じくらい重要です。Separation 塗りつぶしのみを含む数百バイト程度のシンプルなPDFファイルを用意すれば、期待される出力色が明確になり、バグの検出が容易になります。実運用ファイルを使用したテストは多くのコードを走査できますが、どの段階で処理が失敗したかを特定することは困難です。現在、私たちはレンダリングの動作確認テストにおける最低基準として、特定の色のピクセル数をカウントする手法を採用しています。これこそが、上記のバグを発見して修正に導いた有効なアサーション手法です。

スポットカラーおよびCIE色空間のレンダリング機能は、計算エンジン、グラデーション描画、および上記のビットマップ出力処理とともに、DelphiおよびC++Builder向けの HotPDF Component の既存ドキュメント操作パイプラインに含まれています。