技術記事

DelphiでXPSとOpenXPSをPDFへ:座標とブラシ

HotPDFはXPSとOpenXPSパッケージを、プリントドライバーなしでDelphiとC++Builderの内部でPDFへ変換します。すべての96DPI固定ページ座標を単一の0.75倍・Y反転ページ行列を通して写像し、各VisualBrushを共有Form XObjectとして公開し、ImageBrushのタイルモードを画像描画の反復ではなくネイティブなPDFタイルパターンへ変えます

大半のWindows系の開発現場をここへ引きずり込むのは、退屈で避けがたいシナリオです。何かがすでにMicrosoft XPS Document Writerへ印刷しています。旧来のERPレポート、署名済みフォーム、明細のバッチ。そしてアーカイブポリシーがPDFを要求します。XPSは優れたキャプチャ形式であり、十年後の記録管理システムに渡すには最悪の形式です。だからスプールファイルはページ単位のPDFにならなければならず、その変換器を書き始めた瞬間、興味深い部分がXMLではないことに気づきます。XPSとPDFは、原点がどこにあるか、単位が何を意味するか、ブラシが何であってよいかについて合意していないのです

パッケージからPDFへ一方向で

エントリポイントは特殊なXPSクラスではなく、ドキュメントハンドラーレジストリです。THPDFDocumentHandlerRegistry.RegisterStandardHandlersがXPS、EPUB、CBZの各ハンドラーを導入します。認識はコンテンツベースで、[Content_Types].xmlと少なくとも一つの.fpageパートを運ぶパッケージは、拡張子が嘘をついていても95点を取ります。素の.xps.oxps拡張子は10点にすぎません。アップロードを受け付けるならこの順序が重要です。EPUBを.xpsにリネームした攻撃者がパイプラインを操ってはならないからです

var
  Handled: IHPDFHandledDocument;
  Info: THPDFDocumentHandlerInfo;
  Options: THPDFDocumentHandlerOptions;
  Registry: THPDFDocumentHandlerRegistry;
  Output: TFileStream;
begin
  Registry := THPDFDocumentHandlerRegistry.Create;
  Output := TFileStream.Create('spool.pdf', fmCreate);
  try
    Registry.RegisterStandardHandlers;
    Options := THPDFDocumentHandlerOptions.Default;
    if not Registry.OpenFromFile('spool.xps', '', Options, Handled, Info) then
      raise Exception.Create('no registered handler recognised the package');
    if Handled.Format = hdfXPS then
      Handled.WritePDF(Output, Options, Info);
    // Info.UnsupportedFeatureCountがこの変換の正直な成績
  finally
    Output.Free;
    Registry.Free;
  end;
end;

変換に関わるすべては、試みる前に予算化されます。THPDFDocumentHandlerOptions.Defaultはアーカイブエントリを10,000、展開後のアーカイブバイトを1 GiB、圧縮比を200、リソースを4,096、ページを10,000で上限とし、サーバー側ジョブをパッケージ途中で止められるオプションのCancellationTokenを運びます。その後Info.UnsupportedFeatureCountを読み、非ゼロを現実の所見として扱ってください。HotPDFは近似を描いて黙るのではなく、写像できなかったものを意図的に数えます

XPSページに座標書き換えではなく行列が必要な理由

座標を書き換えると変換スタックが失われるからです。XPSのFixedPageは96DPI単位で、原点は左上、Yは下向きに伸びます。PDFユーザー空間は72DPIで、原点は左下、Yは上向きです。素朴な修正は、すべての数値を0.75倍し、出力時に各Yをページ高から引くことです。平らなパス一つなら機能しますが、RenderTransform、ネストしたCanvas、ブラシローカル行列が入った瞬間に壊れます。それらの変換はXPS空間で定義されており、座標ごとの書き換えはすでにXPS空間を出てしまっているからです。そこでHotPDFは射影を行列として保持し、合成します。HPDFXPSPageMatrixがページごとに固定定数を一度返し、HPDFMultiplyXPSMatrixがそれを蓄積済みパス変換と連結し、結果はジオメトリの前に単一のcm演算子として出力されます。パスデータはその後、無修正のXPS数値で書かれます。省略ジオメトリ構文がSVGパスデータ用の有界パーサーを共有できるのもこのためで、先頭のF0F1の塗り規則トークンだけをXPSアダプターが扱います。EMFとWMFのベクターインポートで同じ考え方を追っていれば、議論の形は見覚えがあるでしょう。インポート形式は行列で変換されるのであって、葉の座標に対する算術では決してない、という形です

HotPDFは固定のXPSページ行列を蓄積済みパス変換と合成する。96DPI・左上原点のXPS座標系が72DPI・左下原点のPDFユーザー空間へ到達し、ビジュアルごとに単一のcm演算子として出力される
射影は行列のままであり、ネストしたすべての変換と合成される。だからパスデータは無修正のXPS数値で書ける
function HPDFXPSPageMatrix(PageHeight: Double): THPDFXPSMatrix;
begin
  Result.A :=  0.75;       // 96DPIのXPS単位を72DPIのPDFポイントへ
  Result.B :=  0;
  Result.C :=  0;
  Result.D := -0.75;       // XPSのYは下向き、PDFのYは上向き
  Result.E :=  0;
  Result.F := PageHeight;  // PDFページ高、ポイント単位
end;

// ビジュアルごとに一つの合成済みCTM、パス演算子の前に出力
Effective := HPDFMultiplyXPSMatrix(HPDFXPSPageMatrix(PageHeightPDF), PathMatrix);
Page.AppendRawContent(HPDFXPSMatrixOperator(Effective));

VisualBrushを二度描かずに再利用する方法

VisualBrushは任意のビジュアルツリー(CanvasPathGlyphsの子)を領域内に描き、領域をまたいで繰り返すこともあります。HotPDFはそのビジュアルを一度だけPDF Form XObjectへコンパイルし、それを配置します。Form XObject経由のSVGインポートで述べたのと同じリソース戦略です。成否を分ける細部が二つあります。第一に、ビジュアルは直接のXML子として辿らなければなりません。タイル向きの要素を平面的に検索すると、ネストしたビジュアルがページ最上位へ引き上げられ、リソーススコープと描画順序の両方が崩れます。第二に、コンテンツはXPSからPDFへのページ行列が既に適用された状態で捕捉されるため、Formの公開にはその逆行列を掛けなければなりません。さもなくばすべての配置が0.75倍とY反転を再適用します。Formは自身のリソースも所有しなければなりません。HotPDFは捕捉されたコンテンツストリームが実際に参照するフォント、XObject、パターン、ExtGState、カラースペースだけをコピーします。ページのリソース辞書全体をクローンすると、登録中のFormが自分自身のリソースグラフへ引き込まれ、循環ができます。フォントは通常ページでは直接辞書に留まり、捕捉コンテンツが本当にTfを含むときだけ共有間接辞書へ昇格するため、再利用ビジュアルのない文書はこの仕組みの代金を払いません。バグ報告の前に知っておくべき仕様境界があります。ECMA-388の13.4節はVisualBrushViewboxUnitsViewportUnitsの両方にAbsoluteを要求するため、相対単位は未実装の機能ではなく非適合入力であり、HotPDFはそのための座標セマンティクスを発明しません

HotPDFはXPS VisualBrushのビジュアルツリーを一度だけPDF Form XObjectへコンパイルし、固定ページ行列の逆を通して公開することで配置がスケールを再適用しないようにし、捕捉コンテンツが実際に参照するリソースのみをコピーする
コンテンツはページ行列が適用済みの状態で捕捉されるため、Formはその逆を通して公開され、自身のコンテンツストリームが参照するリソースだけを運ぶ

ImageBrushのタイリング:4モード、4セルサイズ

XPSのタイルモードは、被覆領域への画像配置の反復へ展開されるのではなく、ISO 32000-1の8.7.3節にあるPDFタイルパターンへ写像されます。これにより出力サイズと変換時間が、ブラシがページのどれだけを覆っても無関係に保たれます。写像を見れば機械的です。反射は、鏡像配置を一つのパターンセル内に入れ、セルを一致する大きさまで拡張することで表現されます

  • Tile — 配置一つ、セルは1×1ビューポートのまま
  • FlipX — 配置二つ、セルは2×1へ拡幅
  • FlipY — 配置二つ、セルは1×2へ增高
  • FlipXY — 配置四つ、セルは2×2へ拡張

各配置は固有のクリップ矩形を運びます。Viewboxの写像がサブセルをはみ出すと隣の反射に染み出すからです。パターンの/Matrixは人を引っ掛ける部分です。タイリングパターンは親コンテンツストリームの既定ユーザー空間に固定され、パターン選択時のグラフィックス状態に固定されないため、行列は三層をすべて明示的に合成しなければなりません。固定ページ射影、パス変換、ブラシローカルのTransformです。周囲のCTMに頼ってはいけません。HotPDFは割り当て前にも検証します。RegisterImageTilingPatternはパターンを1,024配置に制限し、縮退クリップ、非可逆行列、無効画像インデックスを拒否します。PDF側の一般モデルが知りたければ、タイルパターンとPatternカラースペースが基礎の演算子を扱っています

// 固定ページ射影をパターン行列へ折り込み、その後にブラシローカルのもの
PatternMatrix := HPDFMatFromOps( 0.75 * PathMatrix.A, -0.75 * PathMatrix.B,
                                 0.75 * PathMatrix.C, -0.75 * PathMatrix.D,
                                 0.75 * PathMatrix.E,
                                 PageHeight - 0.75 * PathMatrix.F);
if Brush.HasTransform then
  PatternMatrix := HPDFMatMul(PatternMatrix, BrushMatrix);

PatternName := Document.RegisterImageTilingPattern(Resource.ImageIndex,
  Brush.Viewport.Left, Brush.Viewport.Top,
  Brush.Viewport.Left + CellWidth, Brush.Viewport.Top + CellHeight,
  CellWidth, CellHeight, Placements, pttNoDistortion, PatternMatrix);

放射グラデーションが円でないとき何が起こるか

XPSはGradientOriginCenterRadiusXRadiusYを持つRadialGradientBrushを定義するため、ブラシは楕円です。ISO 32000-1の8.7.4.5.4節にあるPDFシェーディングタイプ3は二つの円の間でブレンドし、楕円を直接表現する手段がありません。二つの半径を一つの数値へ平均するのが誘惑的な近道で、丸に近くないブラシでは目に見えて間違います。HotPDFはその代わり、問題を座標系の中へ移します。YをRadiusY / RadiusXでスケールし、そのスケール空間で正直な円形シェーディングを登録し、パターンを選択し、直後に逆スケールを出力します。次に書かれるパスジオメトリは依然として元のXPSユーザー空間にあります

ScaleY := Brush.RadiusY / Brush.RadiusX;
PatternName := Document.RegisterMultiStopRadialGradient(
  Brush.StartX, Brush.StartY / ScaleY, 0,
  Brush.EndX,   Brush.EndY   / ScaleY, Brush.RadiusX,
  StopPositions, StopColours, 3);

if Abs(ScaleY - 1) > 0.000001 then
  Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(ScaleY) + ' 0 0 cm'#10);
Page.SetFillPattern(PatternName);   // パターンはここでCTMを捕捉する
if Abs(ScaleY - 1) > 0.000001 then
  Page.AppendRawContent('1 0 0 ' + HPDFPDFNumber(1 / ScaleY) + ' 0 0 cm'#10);

このスニペットの順序こそが全トリックで、様式の話ではありません。PDFのシェーディングパターンは、現在の色として選択された瞬間の現在変換行列を捕捉します。したがって一時的なスケールはSetFillPatternSetStrokePatternの前に出力され、逆スケールは選択の後、パス演算子の前に来なければなりません。どちらかの方向で順序を間違えると、最初のパスでは正しく描かれ、それ以降のすべてのパスでずれていくグラデーションができます。相対座標モードにも関連制約があります。RadiusXRadiusYはパスの幅と高さに対して別々に解決されなければなりません。単一の辺長で両方をスケールすると、非正方形パス上で楕円のアスペクト比が黙って変わります

HotPDFはYをスケールし、スケール空間で円形シェーディングを登録し、パターンが現在変換行列を捕捉した後でのみ逆スケールを出力することで、楕円のXPS RadialGradientBrushをPDFシェーディングタイプ3へ写像する
楕円はシェーディングではなく座標系に吸収され、一時的なスケールはパターン選択を正確にその順序で挟まなければならない

変換が限界について正直である場所

近似変換されるXPS構成要素と、まったく変換されないものがあり、全体を貫く設計選択は、偽るのではなく数えることです。TIFFとJPEG XRパートはWICを通してラスタライズされ、アルファ保持についての約束はありません。有効なアルファチャンネルを持つPNGはベース画像と/SMaskに分解されます。画像の固有サイズはpixel * 96 / DPIとして導出され、PNGのpHYsかJPEGのJFIF密度を先に読み、96 DPIへフォールバックするため、壊れた密度ヘッダーは恣意的なサイズではなく予測可能なサイズに着地します。未解決の行列リソース、非標準の相対変換、ColorConvertedBitmap、未対応のグラデーションスプレッドモード、不正なジオメトリはすべてUnsupportedFeatureCountを増やし、不正な入力は黙って異なる描画へ劣化する代わりにフェイルクローズします

アーカイブ用変換器にとって有用な姿勢とはこれです。黙って近似する変換は、表現できなかった四つの要素を教えてくれる変換より劣ります。文書が記録管理システムに封印される前に検査の対象になるのは後者だけだからです。ページ構成、フォント、署名、PDF/A出力を含む文書パイプラインの残りと並べてXPSとOpenXPS変換を評価しているなら、HotPDF Delphi PDF componentのページが完全な機能セットと対応するDelphi、C++Builderの各バージョンを一覧にしています