技術記事

PDF グラフィックスの仕組み: コンテンツストリームとオペレータ

PDF ページにはピクセルは保存されておらず、SVG のようにシェイプオブジェクトのツリーも保存されていません。そこにはプログラムが保存されています。ページ上のすべての線、曲線、塗りつぶし、および配置された画像は、コンテンツストリーム内のオペレータのシーケンスを上から下へ、実行中のグラフィックス状態に対して実行した結果です。この 1 つの事実を理解すれば、フォーマットの動作のほとんどが驚くべきものではなくなります。パスの構築後に塗りつぶしに個別の描画オペレータが必要な理由、ブラケットで囲まない限り色や線の幅が 1 つの図形から次の図形に漏れる理由、単一の座標変換後に同じ描画コードがまったく異なる場所に着地する理由などです。これは、ISO 32000 で定義されている実行モデルのツアーです。つまり、コンテンツストリームを開いたときに遭遇するオペレータと、ページに何を表示するかを決定するルールです

コンテンツストリームは後置(postfix)バイトコードである

コンテンツストリームは、オペランドとそれに続くオペレータのフラットなバイトシーケンスです。オペランドが最初に来て、それを消費するオペレータが最後に来ます。これは関数呼び出しの逆であり、スタックマシンと同じです。つまり、数値をプッシュしてから、動詞を発行します。ネストや式の構文、変数はありません。三角形の輪郭は、次の 5 行になります

100 100 m    % moveto: (100, 100) で新しいサブパスを開始
200 200 l    % lineto: (200, 200) へのセグメントを追加
300 100 l    % lineto: (300, 100) へのセグメントを追加
h            % closepath: 開始点に接続して閉じる
S            % stroke: パスの輪郭を描画する

オペレータは意図的に簡潔になっています。実際のページはこれらの何千もの集まりであり、通常は FlateDecode で圧縮されています。そのコンパクトさの代償として、ストリームにはクエリできる構造がありません。ビューアは「このページの見出しはどこにあるか」と尋ねることはできず、プログラムを実行してどこにインクが着地するかを確認することしかできません。これが、任意の PDF からのテキスト抽出が困難である根本的な理由です

原点は左下にあり、Y は上に向かって大きくなる

座標が意味を持つ前に、(0, 0) がどこにあるかを知る必要があります。PDF はページの左下隅に原点を置き、X は右に向かって増加し、Y は上に向かって増加します。1 インチあたり 72 ポイントで測定されます (ISO 32000-2 §8.3.2)。US レターサイズのページでは、上端は y = 0 ではなく y = 792 になります。原点が左上にあり、Y が下に向かって増加する画面グラフィックスから来た人は皆、最初の試みでこれを逆にし、最初の線をページの下部から外して描いてしまいます。単位はメディアにも依存しません。ページが電話の画面にレンダリングされるかイメージセッターにレンダリングされるかに関係なく、72 単位は 1 インチです

ほとんどのページ描画ライブラリは、この規則を直接継承しています。たとえば、HotPDF では、TextOut とパスの呼び出しはすべて左下からポイントで測定されるため、ページの高さに近い値を指定するとコンテンツが上部に配置されます

// HotPDF, Delphi: y は下端から上に向かって測定されます(ポイント単位)
Pdf.CurrentPage.SetLineWidth(2.0);
Pdf.CurrentPage.MoveTo(100, 700);   // ページの上部付近
Pdf.CurrentPage.LineTo(300, 700);
Pdf.CurrentPage.Stroke;             // moveto/lineto/stroke オペレータを出力します

この呼び出しシーケンスは、上記の ml、および S オペレータに正確にコンパイルされます。ライブラリはコンテンツストリームのタイピストにすぎず、図形が予期しない場所に着地した場合に、出力について推論できるのは、出力されるものを知っているからです

パスを構築してから、描画する

PDF はパスの構築とパスの描画を分離しており、その分離は衒学的なものではありません。最初に視覚的なものを追加しない構築オペレータで図形を記述し、次に蓄積されたパスで何をするかを決定する単一の描画(painting)オペレータを発行します。最後に指定する動詞のみに依存して、同じ三角形を輪郭、単色の塗りつぶし、またはその両方にすることができます

構築オペレータは少数です。m はポイントで新しいサブパスを開始します。l は直線セグメントを追加します。c は、6 つのオペランド(2 つの制御点と 1 つの端点)から 3 次ベジェ曲線を追加します。re は、x、y、幅、高さの 4 つの要素から長方形全体を追加するショートカットです。h は現在のサブパスをその開始点に戻して閉じます。どれもページにインクを置きません。これらはジオメトリを蓄積するだけです

200 250 m                    % サブパスを開始
300 350 400 450 500 250 c    % 3 次ベジェ: 2つの制御点、次に端点
150 200 re                   % 150 x 200 の長方形、独自のサブパスとして追加
h                            % 閉じる

元の例では、現在では廃止された曲線オペレータの y バリアントを使用していました。3 つの明示的な点を持つ c は、実際に目にする形式であり、手を伸ばすべき形式です。パスが存在すると、1 つの描画オペレータでパスが完了します。語彙は少なく暗記する価値があります。なぜなら、すべてのページのすべての図形は、これらのいずれかで終わるからです

  • S は、現在の線幅とストロークカラーを使用してパスの輪郭を描画(ストローク)します
  • f は、現在の塗りつぶし色とノンゼロ・ワインディング規則を使用して内部を塗りつぶします
  • f* は、奇偶(even-odd)規則を使用して塗りつぶします。これは、自己交差する図形や穴のある図形で重要になります
  • B は、1 回の操作で塗りつぶしてからストロークします。b は最初にパスを閉じます
  • n は何も描画しません。これは、視覚的なマークを残さずにパスがクリップ領域になる方法です

ワインディング(巻き)規則は、人々が間違えやすい部分です。ノンゼロ (fB) は、テストポイントからのレイの符号付き交差をカウントし、カウントがゼロでない場所を塗りつぶします。したがって、穴が空のままであるのは、そのサブパスが外側のものと反対に巻かれている場合のみです。奇偶 (f*B*) は、方向に関係なくすべての交差点で切り替わります。「ドーナツ」の図形が塗りつぶされて出力される場合は、内側の円が外側の円と同じ方向に巻かれているため、それを逆にするか、奇偶に切り替えます

色はパラメータではなく、モードである

コンテンツストリーム内の色はスティッキー(粘着性)です。色を設定すると、別の色を設定するか以前の状態を復元するまでその色が設定されたままになります。ブラケットで囲まれていない色変更が、それ以降に描画されたすべてのものを静かに着色するのはこのためです。また、PDF は塗りつぶしの色とストロークの色を 2 つの独立した設定として保持し、塗りつぶしには小文字のオペレータ、ストロークには大文字のオペレータを使用します。デバイスの色空間には、それぞれ独自の省略形があります

0.5 g                % DeviceGray 塗りつぶし、中間グレー (0 = 黒、1 = 白)
0.2 0.6 0.8 rg       % DeviceRGB 塗りつぶし
0.8 0.2 0.1 RG       % DeviceRGB ストローク (大文字 = ストローク)
0.2 0.8 0.0 0.1 k    % DeviceCMYK 塗りつぶし

DeviceRGB は画面出力に適し、DeviceCMYK は印刷生産が期待するものであり、DeviceGray はモノクロコンテンツの最小の選択肢です。デバイスの色空間は便利ですが、キャリブレーションされていません。同じ RGB のトリプルでも 2 つのモニターで異なるレンダリングが行われる可能性があります。これが、ICC ベースの色空間と PDF/A 出力インテントが存在して解決しようとしている問題です。色に厳しい作業では、csCS でキャリブレーションされた色空間を選択し、scscn でコンポーネントを設定しますが、通常のドキュメントではデバイスの省略形が負荷を担います。ライブラリはこれらを型指定された呼び出しでラップします。たとえば HotPDF は、単一の TColor を受け取り、一致するオペレータを出力します

Pdf.CurrentPage.SetRGBFillColor(clRed);
Pdf.CurrentPage.Rectangle(100, 100, 200, 150);  // x, y, 幅, 高さ
Pdf.CurrentPage.Fill;

Pdf.CurrentPage.SetRGBFillColor(RGB(0, 255, 0));
Pdf.CurrentPage.Circle(150, 400, 50);           // x, y, 半径
Pdf.CurrentPage.Fill;

グラフィックス状態と q/Q スタック

パス自体以外のすべては、グラフィックス状態に存在します。現在の変換行列、塗りつぶしとストロークの色、線幅、破線パターン、クリップ領域、アルファなどです。状態はグローバルで変更可能であるため、ローカルな変更を行う唯一の安全な方法は、全体を保存し、変更し、描画し、ロールバックすることです。これが qQ の働きです。q は現在の状態のコピーをスタックにプッシュします。Q はそれをポップし、対応する q 以降に行われたすべての変更を破棄します

q                    % グラフィックス状態全体を保存
2 0 0 2 100 100 cm   % 変換を連結: 2倍にスケーリング、(100,100) に平行移動
0.8 g                % グレーの塗りつぶし、このブロックにスコープされます
% ... スケーリングされたグレーのコンテンツを描画 ...
Q                    % 復元: 変換と色が元に戻ります

不均衡な qQ は、手作業で構築されたり縫い合わされたりしたコンテンツストリームがうまくいかなくなる一般的な原因です。対応する Q のない迷子の q は、ページが終了したときにスタックを深いままにします。余分な Q はアンダーフローを引き起こします。いずれにせよ、ビューアは古いクリップや変換を有効なまま保持する可能性があり、コンテンツが消えたり、間違った場所に着地したりします。パスが説明できる理由なしにグラフィックスが消えた場合は、最初にステートスタックを監査してください

CTM はすべての座標を変換する

現在の変換行列(CTM)は、オペレータ内の数値と実際のページの間に位置します。何かが描画される前に、すべての座標に CTM が乗算されます。したがって、行列を変更すると、単一のパス座標に触れることなく、後続のすべての描画が表示される場所と方法が変更されます。cm オペレータは、現在の行列に新しい行列を連結し、アフィン行列 [a b c d e f] にマッピングされる 6 つのオペランドを受け取ります

1 0 0 1 100 50 cm        % (100, 50) 移動: e と f がオフセットを運びます
2 0 0 1.5 0 0 cm         % x を 2 倍、y を 1.5 倍にスケーリング: a と d はスケール係数です
0.707 0.707 -0.707 0.707 0 0 cm   % 45 度回転 (a, b, c, d の cos/sin)

人々がつまずくのは 2 つのことです。まず、cm は置換するのではなく構成するため、変換は蓄積され、順序が重要になります。スケーリングしてから平行移動することは、平行移動してからスケーリングすることと同じではありません。第 2 に、回転とスケーリングは、図形の中心ではなく現在の原点を中心に回転するため、所定の場所で何かを回転させるには、それを原点に平行移動し、回転させてから、元に戻すという手順を、すべて q/Q でラップして実行します。この同じマトリックスが画像を配置するものであり、これが最後に見る価値のある部分です

画像と再利用可能なコンテンツは XObject である

ラスター画像は、コンテンツストリーム内にインラインで存在しません。それらは、幅、高さ、ビット深度、色空間、および圧縮フィルターを記述する独自の辞書を持つ外部オブジェクトである画像 XObject として保存され、コンテンツストリームはそれらを参照するだけです。JPEG をバックにした写真は、次のように宣言されます

/Photo <<
  /Type /XObject
  /Subtype /Image
  /Width 640
  /Height 480
  /BitsPerComponent 8
  /ColorSpace /DeviceRGB
  /Filter /DCTDecode        % 画像データは JPEG ストリームです
>>

画像 XObject は、単位正方形に描画されます。つまり、常にユーザー空間の (0, 0) から (1, 1) の領域を占有します。位置やサイズを渡すことはありません。代わりに、単位正方形が目的の四角形にマップされるように CTM を設定し、次に Do で呼び出します。これが、画像を配置することが常に変換の後に呼び出しが続き、スケールが次の操作ににじまないように保存/復元でラップされる理由です

q
640 0 0 480 50 300 cm    % 単位正方形を (50, 300) の 640x480 のボックスにマッピング
/Photo Do                % 画像 XObject を描画
Q

同じ Do メカニズムがフォーム XObject を駆動します。フォーム XObject は、ロゴや繰り返し使用されるスタンプなど、再利用可能なグラフィックスの塊を、境界ボックスを持つ独自のコンテンツストリームとして保持します。これを 1 回定義し、異なる CTM で何度も呼び出すと、ファイルにはバイトが 1 回しか表示されません。ほとんどのライブラリは、これを 1 つの配置呼び出しの背後に隠しています。HotPDF は、マトリックスを手動で構築するように要求するのではなく、明示的な x、y、幅、および高さを受け取って、AddImage でビットマップを登録し、ShowImage でそれを配置します

var
  Bmp: TBitmap;
  ImgIndex: Integer;
begin
  Bmp := TBitmap.Create;
  try
    Bmp.LoadFromFile('logo.bmp');
    ImgIndex := Pdf.AddImage(Bmp, icFlate);
    // x, y (左下), 幅, 高さ, 回転角度
    Pdf.CurrentPage.ShowImage(ImgIndex, 50, 300, 200, 150, 0);
  finally
    Bmp.Free;
  end;
end;

その 1 行の下で、ライブラリは画像 XObject 辞書を書き込み、単位正方形のサイズと位置を決めるために CTM を設定し、Do を出力します。根底にあるモデルは、すべての奇妙な結果を説明するため、知っておく価値があります。引き伸ばされた画像はスケール係数が一致しない CTM であり、40 ページで同じロゴは 40 回呼び出された 1 つのフォーム XObject であり、上下逆さまにレンダリングされる画像は破損したファイルではなくマトリックスの符号の反転です

これが導くところ

グラフィックスモデルは、その形が見えれば小さなものです。コンテンツストリームは、変更可能な状態に対して実行される後置(postfix)バイトコードです。座標は左下から始まり、CTM を通過します。パスは静かに構築され、1 つの意図的なオペレータで描画されます。色と線の設定は、q/Q で囲むまで保持されます。画像と再利用可能なグラフィックスは、単位正方形を変換することによって配置される XObject です。混乱を招くレンダリング結果のほとんどすべては、これら 5 つのルールのいずれかに帰結します。これらのグラフィックスオペレータが、より大きなオブジェクトモデル、ページ辞書、およびそれらを指す相互参照テーブルの内側にどのように配置されているかを確認したい場合は、PDF ファイル構造の技術的概要でその層を取り上げており、単純な PDF をゼロから構築するでバイトをエンドツーエンドで順を追って説明しています。テキストの描画は独自のオペレータファミリーに属し、独自の落とし穴があります。これについては、PDF のテキストとフォントの処理に関する関連記事で取り上げています

ここに示されている Delphi の描画呼び出し MoveToLineToStrokeRectangleFillSetRGBFillColorAddImage、および ShowImage は、Delphi および C++Builder 向けの HotPDF Component の一部であり、コンポーネントがこれらのコンテンツストリームオペレータを出力してくれます