技術記事

HotPDFでDelphiからPDFページをSVGへエクスポート

HotPDFは、読み込み済みのPDFドキュメントの1ページを、BuildLoadedPageSVGという単一の呼び出しでスタンドアロンのSVGマークアップにエクスポートします。この関数は完全なSVGドキュメントを文字列として返します。エクスポートされたマークアップには、ページのジオメトリ、実際のSVG text要素としてのテキスト、埋め込みラスター画像、そして各描画操作の時点でPDFオペレーターが確立していたストローク状態が含まれます

その最後の部分こそ、自作のコンバーターの多くが静かに破綻する箇所です。PDFページをSVGに変換する作業は座標の問題に見えますが、実際には状態の問題です。PDFはコンテンツストリームの解釈が進むにつれてグラフィックス状態が変化するスタックマシンであり、SVGは各要素が独自のプレゼンテーション属性を持つ宣言的なツリーです。要素が出力される瞬間にインタプリタがスナップショットを取り損ねたものは、出力から単純に消えてしまいます。しかもその失敗は静かに起こります——一見正しく見えるが、微妙に誤ったページを描画する、文法的には正当なSVGが出来上がるのです

なぜPDFページはそのままSVGへ変換できないのか?

変換を一筋縄ではいかなくしている食い違いは3つあり、そのいずれもが、原本と並べて比較するまではもっともらしく見える出力を生み出します。1つ目はY軸です。PDFのユーザー空間はページ左下隅から上方向に伸びるのに対し、SVGは左上隅から下方向に伸びます。ページ全体を1回反転させるだけで描画座標は解決しますが、それによって今度はすべてのグリフが崩れます。キャンバス全体を反転させると文字の形そのものも鏡像になってしまうからです

2つ目の食い違いは継承です。PDFでは、qQが、線幅、線端形状、線結合形状、マイター制限、破線配列、破線位相、アルファ値を含むグラフィックス状態をプッシュ・ポップします。SVGでは、ある要素が属性を明示していない場合、祖先のグループから継承しますが、これはまったく別のスコープ規則です。現在の変換行列だけを追跡しストローク状態を追跡し忘れているエクスポーターでは、Qによる状態復元後の値が後続の要素へ漏れ込んでしまいます

3つ目は、PDFが値ではなく慣習によって物事を表現している点です。線端・線結合形状は整数値で表され、線幅ゼロは不可視の線ではなくデバイス空間上の極細線を意味し、ペイント演算子のアスタリスク付きバリアントは色ではなく塗りつぶし規則を変更します。これらはいずれもコピーではなく、翻訳が必要です

一般的なケースには呼び出し1回で十分

Webビューア、差分比較ツール、デザインの引き渡しなど、ページをエクスポートする通常の作業であれば、APIの表面はたった1つの関数で済みます。BuildLoadedPageSVGは、現在読み込まれているドキュメントに対してゼロ始まりのページインデックスを受け取り、SVGドキュメントをAnsiStringとして返します:

var
  Pdf: THotPDF;
  I: Integer;
  Svg: AnsiString;
  Output: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('statements.pdf', '') <= 0 then
      Exit;                     // LoadFromFileはページ数を返す
    for I := 0 to Pdf.LoadedPageCount - 1 do
    begin
      Svg := Pdf.BuildLoadedPageSVG(I);
      if Length(Svg) = 0 then
        Continue;
      Output := TFileStream.Create(Format('page-%d.svg', [I + 1]), fmCreate);
      try
        Output.WriteBuffer(Svg[1], Length(Svg));
      finally
        Output.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

同じエクスポート機能は、HotPDFのコマンドラインツールでもexport-svgコマンドとして公開されており、Pascalコードを一切書かずにページのテキスト差分比較可能な表現が欲しいビルドパイプラインや回帰テストのスクリプトで役立ちます。SVGはテキストであるため、PDFページをビットマップにレンダリングするで説明したラスターパスと自然に対をなします。ビットマップはページがどう見えるかを教えてくれ、SVGはページが何でできているかを教えてくれます

PDFのテキストはどのようにSVGのtext要素へマッピングされるのか?

HotPDFは、テキスト行列のチェーンを「接頭行列 × CTM × テキスト行列 × グリフ反転」として合成します。ここでグリフ反転とは、matrix(1,0,0,-1,0,0)による右からの乗算です。この右側の因子は、グリフの形状に対してページ全体の垂直反転を打ち消すためだけに存在します。そうしないと、反転したローカル座標系で描画されたSVGテキストは上下逆さまに表示されてしまうからです。この補正を特別扱いのコードではなく行列そのものに組み込むことで、回転・鏡像・せん断されたテキストのいずれも、余分な分岐なしに正しく出力されます

水平方向の位置づけには、SVGのtext要素が持つ複数値のx構文を使用し、文字ごとに1つの座標を、その時点で有効な各グリフのアドバンス幅に文字間隔Tcと単語間隔Twを加算して積み上げていきます。水平方向の拡大縮小Tzは、個別に出力するのではなくテキスト行列のaとcの列に折り込まれるため、特殊なテキスト属性を無視するビューアでも、すべてのグリフはPDFが配置した通りの位置に表示されます。複雑なスクリプトのテキストシェーピングで扱った複雑なシェーピングによって生成されたテキストも同じ経路をたどります。シェーパーは、コンテンツストリームが解釈される時点で、すでにクラスターを位置付き済みのグリフへ解決しているからです

回転と画像:逆向きにしやすい2つの反転

/Rotateエントリがゼロでないページには、回転後のキャンバス高さに対する反転と、y軸上向きの表示空間で表現された回転とを組み合わせた前処理変換が必要です。3つの回転行列は、90度が(0,-1,1,0,0,W)、180度が(-1,0,0,-1,W,H)、270度が(0,1,-1,0,H,0)で、WとHは回転前のページ寸法です。これらを手作業で導出すると、ちょうど3か所で符号ミスを招きやすいため、エクスポーターは他のあらゆる変換を処理するのと同じ行列乗算ルーチンを通してこれらを合成します

埋め込み画像にはそれ自体の反転が必要です。PDFの画像空間では単位正方形の上端に最初のサンプル行を配置するのに対し、SVGのimage要素はy軸下向きのローカル座標系を持つからです。そのため出力される変換は、CTMにmatrix(1,0,0,-1,0,1)を右から乗算したものになります。これを誤ると、それ以外は完璧なページの上に上下反転した写真が現れます。これはレビュアーなら一目で気づく類いの不具合ですが、自動テストではしばしば見逃されます

グラフィックス状態デバイスは実際に何を保持するのか?

HotPDFは、ストローク状態の演算子であるwJjMdを、別個のオプションのデバイスインターフェースを通じてディスパッチします。そのため、ストロークの忠実度を追加するにあたって既存のコンテンツデバイスのvtableを変更する必要も、以前のバージョン向けにビルドされたコードのバイナリ互換性を壊す必要もありませんでした。具体的には、エクスポートされたSVGは、生のPDF整数値ではなく変換済みのキーワードを受け取ります:

// PDFの整数列挙値はSVGのキーワード属性になる
//   線端形状  0, 1, 2  ->  butt, round, square
//   線結合形状 0, 1, 2  ->  miter, round, bevel
//
// PDFでは線幅ゼロはデバイス空間上の極細線を意味するため、
// エクスポーターはvector-effect="non-scaling-stroke"を出力し、
// CTM適用後もストロークが見え、デバイスピクセル約1本分の太さを保つようにする
//
// f* B* b*は偶奇規則を選択しfill-rule="evenodd"を出力する一方、
// f B bはSVGの既定であるノンゼロ巻数規則を維持する

Qによる状態復元は、不透明度、線幅、線端形状、線結合形状、マイター制限、破線配列、破線位相をまとめてカバーします。ネストしたフォームXObjectも、その境界において同じ完全な状態セットのスナップショットと復元を行うため、スタンプの内部で定義された破線の枠が、後続のページコンテンツにパターンを漏らすことはありません。他の理由からすでにクリッピングとCTMの挙動を追跡しているなら、これはEMFおよびWMFベクターのインポートに登場するのと同じ状態モデルが、逆方向に動作しているものです

出荷前に知っておく価値のある制約

このエクスポーターは自らの守備範囲について正直であり、その境界をあらかじめ把握しておく方が、本番環境で発見するよりもコストが低く済みます。色はrgRGgGの各演算子を通じてSVGデバイスに届きます。カラースペースとscnを組み合わせて設定される塗り——Separation、DeviceN、ICCBasedの各カラーが描画される方法——は、解決済みのRGB三つ組としてデバイスに届くわけではありません。そのため、この方式でスポットカラーを使用するページは、ジオメトリはエクスポートされてもそれらの色はエクスポートされません。印刷向けのソースであれば、代わりにラスタライズするか、事前にスポットカラーをフラット化してください。ペイントモデル自体はSeparation・DeviceNのスポットカラーのレンダリングで扱っています

デバッグ時間を節約する小さな注意点が2つあります。16進数のカラーリテラルは大文字で出力されるため、#ff0000を検証するテストは、完全に正しい#FF0000に対して失敗します。また、SVGデバイスはそのインターフェースを通じて参照カウントされているため、解放する際はオブジェクトに対してFreeを呼び出すのではなく、インターフェースをスコープ外にすることになります。これは、ページコンテンツと並べて独自のマークアップを出力するようデバイスを拡張する場合に重要になる違いです

生成されたドキュメントが2つのビルド間で実際に変化したかどうかを知る必要がある場合、SVGエクスポートは構造比較と自然に組み合わせられます。レンダリングから編集、エクスポートまで、読み込み済みドキュメントを取り巻くより幅広いツールキットについては、HotPDF Delphi PDFコンポーネントページで説明しています