技術記事

Delphi と PDFium で axial/radial の PDF グラデーション

PDFium Component は、TPdf.SaveAsShadingCreated を通じて Delphi で axial と radial の PDF グラデーションを作ります。各グラデーションを TPdfShadingCreate レコード(タイプ、カラースペース、座標、色またはストップ、ターゲットページ)で記述すると、このメソッドは検証済みのコピーを書き出します。そこではすべての塗りが本物の ISO 32000-1 シェーディング辞書であり、ページの /Resources /Shading 辞書に登録され、sh 演算子で塗られるため、PDFium がネイティブに描画します

必要になるのはレポートや証明書ジェネレータです。デザイナーが青から白への柔らかい見出し帯や、印章の背後の radial の光のモックアップを渡してくるのに、テキスト、パス、画像をカバーするページオブジェクト API にはそれ用のものがありません。わずかに色の違う 100 本の細い矩形でグラデーションを偽装もできますが、結果は拡大するとバンディングが見え、コンテンツストリームを膨らませ、印刷もひどくなります。PDF には 1.3 以来スムーズなシェーディングがあります。仕事は、レンダラが実際に描いてくれる形で書くことにあります

PDFium Component はグラデーションを別の保存として書く理由

TPdf.SaveAsShadingCreated がページオブジェクト呼び出しではなく保存操作なのは、PDFium の公開 ABI にシェーディング辞書を作ったり sh 演算子を出したりする関数がないからです。PDFium Component でゼロから PDF を作るときに使うページオブジェクト層は、パス、テキスト、画像を作り、そこで止まります。シェーディングはもう 1 段下、オブジェクトグラフとコンテンツストリーム構文の世界に住んでいます

そこでメソッドは段階的に動きます。PDFium がまず開いている文書をネイティブに保存し、通常の API で行ったすべての編集を保持します。次に PDFium Component がそのステージングされた出力を書き直します。シェーディングごとに 1 つの間接オブジェクトを割り当て、各ターゲットページにリソースエントリを足し、ページの /Contents に新しいコンテンツストリームを追記し、新しいクロスリファレンステーブルを書きます。何かがあなたのストリームやファイルに届く前に、結果は厳密なクロスリファレンスチェックで再ロードされ、その再ロードが失敗すればメソッドはレポートにメッセージを添えて False を返します。ファイルオーバーロードはまず一時ファイルに書くため、失敗した保存が既存のターゲットを潰すことはありません

2 色の axial グラデーションを作る

axial シェーディング(ISO 32000-1 §8.7.4.5.3、Type 2)は x0 y0 から x1 y1 への線に沿って色を混ぜるため、Coords は 4 つの値を取ります。radial シェーディング(§8.7.4.5.4、Type 3)は 2 つの円の間で混ぜ、6 つ取ります。x0 y0 r0 x1 y1 r1 です。素の StartColor と EndColor では、PDFium Component は /N 1 付きの Type 2 指数関数(§7.10.3)、つまり直線的な線形補間を出力し、/Extend [true true] を設定して端の色が軸の両端を越えて続くようにします

Delphi における PDFium Component の axial と radial シェーディングのジオメトリ。Type 2 のグラデーションは x0 y0 から x1 y1 への線に沿って StartColor から EndColor へ混ぜ /Extend true true が両端を越えて続き、Type 3 のグラデーションは半径 r0 の始円と半径 r1 の終円の間で混ぜる
PDFium はシェーディングをクリップが許す範囲に塗ります。Type 2 の Coords が x0 y0 から x1 y1 へのブレンド軸を置き、Type 3 の Coords が 2 つの円を置き、/Extend [true true] が両端の色をジオメトリの先まで運びます
uses
  System.SysUtils, PDFium, FPdfCompress;

procedure AddHeaderBand(const InFile, OutFile: string);
var
  Pdf: TPdf;
  Fills: TPdfShadingCreateShadings;
  Report: TPdfShadingCreateReport;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := InFile;
    Pdf.Active := True;

    SetLength(Fills, 1);
    Fills[0] := Default(TPdfShadingCreate);
    Fills[0].ShadingType := pstAxial;
    Fills[0].ColorSpace := pscDeviceRGB;
    Fills[0].Coords := [0, 792, 0, 640];        // x0 y0 x1 y1、上から下へ
    Fills[0].StartColor := [0.12, 0.35, 0.75];  // 上端の濃い青
    Fills[0].EndColor := [1, 1, 1];             // その下は白
    Fills[0].PageNumber := 1;                   // 1 起点
    Fills[0].ResourceName := 'HeaderBand';      // 任意。既定は Sh1、Sh2...

    if not Pdf.SaveAsShadingCreated(OutFile, Fills, Report) then
      raise Exception.Create('Shading creation failed: ' + Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

すべての色の成分数はカラースペースと一致しなければなりません。pscDeviceGray と pscCalGray は 1、pscDeviceRGB、pscCalRGB、pscLab は 3、pscDeviceCMYK は 4 です。pscICCBased の成分数は IccProfile に渡すプロファイルバイトから来ます。1、3、4 成分を公開しなければならず、プロファイルは対応する /N 付きの専用 ICC ストリームとして埋め込まれます。較正されたスペースは、任意の CalWhitePoint(3 値、Y は正)、CalGamma(CalGray は 1 値、CalRGB は 3 値)、Lab の場合は 6 値の LabRange を受け付けます。すべての座標と成分は、1 バイトが書かれる前に NaN と無限大が検査されます

座標は既定のユーザー空間、原点はページの左下であることに注意してください。軸が定義するのはブレンドが起きる場所だけだと覚えておいてください。塗られる領域はクリップが許すものです。後のセクションで示すとおり、これは聞こえより重要です

マルチストップのグラデーションは Type 3 の stitching 関数にコンパイルされる

PDFium Component は Stops 配列を PDF リーダーが期待する関数に変えます。2 ストップは単一の Type 2 関数になり、3 から 256 ストップは、隣接ペアごとに 1 本の線形 Type 2 セグメントからなる Type 3 stitching 関数(ISO 32000-1 §7.10.4)になります。各 TPdfShadingCreateStop は Position と Color を運びます。Stops が空でなければ StartColor と EndColor を完全に置き換えます

stitching 関数には、手でやると微妙に間違えやすい 3 つの配列が要ります。/Functions がセグメントを保持し、/Bounds は内側のストップ位置だけを保持し(k セグメントに k-1 個の境界)、/Encode が各サブドメインを [0 1] へ写し戻して、すべてのセグメントが完全な入力範囲を見るようにします。コンポーネントは 3 つすべてをストップから導出するため、入力には厳格です。位置は有限、厳密に増加、0 で始まり 1 で終わる(誤差 1e-6 以内)必要があります。0.1 で始まるストップリストは、ドメインの一部を関数未定義のままにするため、メソッドは推測する代わりに拒否します

function Stop(Position: Double; const Rgb: array of Double): TPdfShadingCreateStop;
var
  I: Integer;
begin
  Result.Position := Position;
  SetLength(Result.Color, Length(Rgb));
  for I := 0 to High(Rgb) do
    Result.Color[I] := Rgb[I];
end;

procedure BuildSealGlow(var Fill: TPdfShadingCreate);
begin
  Fill := Default(TPdfShadingCreate);
  Fill.ShadingType := pstRadial;
  Fill.ColorSpace := pscDeviceRGB;
  // x0 y0 r0 x1 y1 r1:印章中心の点が 90 pt の円へ育つ
  Fill.Coords := [450, 160, 0, 450, 160, 90];
  Fill.Stops := [Stop(0.0, [1.00, 0.95, 0.70]),
                 Stop(0.4, [0.95, 0.75, 0.30]),
                 Stop(1.0, [1.00, 1.00, 1.00])];
  Fill.PageNumber := 1;
end;

3 ストップのグラデーションでは、/Bounds [0.4] と /Encode [0 1 0 1] 付きの、2 つの Type 2 セグメントからなる /Functions が生まれます。セグメント境界はハードな色の接合なので、ストップリストはその向こうを滑らかにしません。接合が鋭すぎるなら、リーダーが曲率を補間してくれると期待するのではなく、ストップを足してください

Delphi における PDFium Component のマルチストップグラデーションのコンパイル。位置 0.0、0.4、1.0 の 3 つの Stops が、隣接ストップペアごとに 1 つの Type 2 セグメントを持つ Type 3 stitching 関数になり、内側の位置に /Bounds [0.4]、各サブドメインを全範囲へ写し戻す /Encode [0 1 0 1] が付く
隣接するストップペアがそれぞれ Type 3 関数の中の 1 つの Type 2 セグメントになります。導出された /Bounds と /Encode 配列こそ、手書きの stitching 関数がたいてい間違える箇所です

PDFium はなぜ無言で何も描かないのか

PDFium が新しく作られたシェーディングを描くのは、2 つの細部が正しいときだけです。/ShadingType が整数でなければならないこと、そして sh 呼び出しがクリッピングパスの中にいなければならないことです。どちらかを間違えると、ファイルは開き、構造的には検証を通り、何も手を加えていないページを表示します。エラーも警告もログ行もなしです

罠その 1:/ShadingType は名前ではなく数

ISO 32000-1 Table 78 は /ShadingType を整数と定義します。axial は 2、radial は 3 です。このライターの最初の版は、16 進ダンプで読みやすかったため /ShadingType /Axial を出力しました。PDFium はこのキーを整数ゲッターで読み、名前には 0 を得て、シェーディングを不正と分類し、ロードに失敗し、文句なしに sh 演算子をスキップします。ページは白紙に描かれ、PDFium のどこにも理由はありませんでした。原因が見えるようになったのは、最小の手書き PDF を作り、独立した 2 つ目のレンダラで開いた後です。そちらは未知のシェーディングタイプ 0 と報告しました。教訓はすべての PDF ライターに当てはまります。仕様が列挙を整数として型付けしたら、自己記述的な名前がどんなに魅力的でも、整数を書くことです

罠その 2:sh だけのコンテンツストリームにはバウンディングボックスがない

sh 演算子(ISO 32000-1 §8.7.4.2)は現在のクリッピング領域にシェーディングを塗るため、紙の上ではページ自体の範囲がそれを束ねます。ところが PDFium は、矩形がアクティブなクリップパスから来るか、同じストリームの先行する描画演算子が積み上げたバウンディングボックスから来るシェーディングページオブジェクトを組み立て、空の矩形のオブジェクトをカリングします。/Sh1 sh しか含まない追記されたばかりのストリームにはどちらもないため、グラデーションはラスタライズの前に落とされます。そこで TPdf.SaveAsShadingCreated は各ページの crop box を読み(media box、さらにページサイズへフォールバック)、すべての呼び出しを明示的な re W n クリップで包みます。原点がゼロの US Letter ページでは、追記されるストリームは次のようになります

q
0 0 612 792 re W n
/HeaderBand sh
Q

q/Q のペアが、クリップとどんなトランスフォームも既存のページコンテンツから切り離すため、後でページに描かれるものがそれを引き継ぎません。別のツールで自分でシェーディング呼び出しを書くなら、この形を写してください。同じストリーム内の明示的なクリップは安価で、先行するものへの依存を取り除きます

一見有効な PDF シェーディングが PDFium で何も描かない理由。名前 Axial として書かれた /ShadingType は整数ゲッターに 0 と読まれて sh 演算子が無言でスキップされ、sh だけのコンテンツストリームは、明示的な re W n クリップが矩形を与えるまでバウンディングボックスを持たない
不可視シェーディングの 2 つの失敗はどちらも PDFium で無言です。だからこそ SaveAsShadingCreated は整数の ShadingType を自前で書き、すべての sh 呼び出しを crop box の re W n クリップで包みます

カスタムのグラデーション行列はクリップとどう相互作用するのか

PDFium Component のグラデーション行列は、クリップの後、sh の前に置かれる cm 演算子として適用されるため、クリップはフルページのまま、グラデーションのジオメトリだけが変換されます。HasMatrix を True にし、MatrixA から MatrixF を、どんな PDF 変換行列と同じ 6 つの数で埋めます。値は有限で、行列式は非ゼロでなければなりません。特異行列はシェーディングを 1 本の線へ潰します。コンポーネントは同じ 6 値をシェーディング辞書の /Matrix としても記録しますが、直接の sh 呼び出しでは、PDFium が塗る座標系を決めるのはコンテンツストリームの中の cm です

順序がここでは重要で、これが 3 つ目の罠です。re のオペランドは現在の変換行列で解釈されます。cm を先に置くと、ページサイズのクリップ矩形はグラデーションと一緒に拡縮、回転、剪断され、楕円を潰す行列は可視領域も潰し、ページの一部が塗りを失います。変換されていないユーザー空間で先にクリッピングし、それから行列を連結すると、期待どおりの結果になります。ページ全体を覆う変換されたグラデーションです。Form XObject スタンプとその 6 数行列のガイドが、行列を作る必要が生じたときの各成分の役割をたどります

procedure MakeEllipticalGlow(var Fill: TPdfShadingCreate);
begin
  BuildSealGlow(Fill);       // 450,160 中心の radial 塗り
  Fill.Coords := [0, 0, 0, 0, 0, 90];  // 原点の円
  Fill.HasMatrix := True;
  Fill.MatrixA := 1.6;       // 水平に伸ばす
  Fill.MatrixB := 0;
  Fill.MatrixC := 0;
  Fill.MatrixD := 0.8;       // 垂直に圧縮
  Fill.MatrixE := 450;       // それから印章中心へ移動
  Fill.MatrixF := 160;
end;

レポートを読み、限界を知る

TPdfShadingCreateReport が書かれたものを告げます。SourceByteCount と OutputByteCount、CreatedShadingObjectNumbers(入力エントリごとに 1 つ、順番どおりのオブジェクト番号)、AttachedShadingCount、Succeeded、ErrorMessage です。検証エラーはエントリとフィールドを名指しします。たとえば厳密に増加していないストップリストなので、そのままログに取る価値があります

if Pdf.SaveAsShadingCreated(OutFile, Fills, Report) then
  Writeln(Format('%d shading(s) attached, %d -> %d bytes',
    [Report.AttachedShadingCount, Report.SourceByteCount,
     Report.OutputByteCount]))
else
  Writeln('Rejected: ' + Report.ErrorMessage);

プロファイルは意図的に限定的です。作るのは axial と radial のシェーディングだけです。関数ベースとメッシュのシェーディング(Type 1 と 4〜7)とパターンカラースペースは拒否されます。1 回の呼び出しは 1 から 4096 の塗りを受け付け、ページ番号は存在しなければならず、リソース名は文字、数字、アンダースコア、マイナス、ピリオドの 127 文字までに制限されます。暗号化された入力はトレーラーの段階で拒否されます。署名済み文書には AllowSignedDocument := True が要り、出力はインクリメンタル更新ではなく完全な書き直しなので、既存の署名はもう検証を通りません。グラデーションを足してから署名してください。各塗りはページコンテンツの末尾、すでにあるものの上に塗られます。背景効果には、グラデーションを先に作り、テキストと画像を後から足すか、塗りを切り替え可能にしたいときはDelphi で PDF の optional content レイヤーを制御する記事のとおり optional content を使ってください

FAQ

SaveAsShadingCreated はマルチストップのグラデーションに対応するか

はい。位置が 0 から 1 へ厳密に増加する 2 から 256 エントリの Stops を渡してください。2 ストップは Type 2 関数を、3 以上は導出された /Bounds と /Encode 配列を持つ Type 3 stitching 関数を作ります

ファイルが有効に見えるのにグラデーションが PDFium で不可視なのはなぜか

/ShadingType が名前ではなく整数の 2 か 3 であること、そして sh 演算子が同じコンテンツストリーム内の re W n クリップの中で走っていることを確認してください。PDFium は不正なシェーディングタイプを静かにスキップし、空の矩形のシェーディングオブジェクトをカリングします

作成されるシェーディングはどのカラースペースを使えるか

TPdfShadingCreate は DeviceGray、DeviceRGB、DeviceCMYK、CalGray、CalRGB、Lab、および 1、3、4 成分プロファイルの ICCBased を受け付けます。Pattern と Indexed カラースペースは作成プロファイルの一部ではありません

グラデーション作成、optional content、その他のステージドライターは、Delphi、C++Builder、Lazarus プロジェクト向けに PDFium を包む PDFium Component for Delphi and C++Builder とともに出荷されます