技術記事

DelphiでのHTMLからPDFへの変換:HTML5ページ付きメディアパス

HotPDFはTHPDFHTMLImporter.RenderHTML5を通じてHTMLをPDFページに変換する。これはバージョン管理されたページ付きメディアプロファイルであり、本物のDOM、本物のCSSカスケード、テーブル、改ページ、そしてHTMLフォームコントロールからのAcroForm生成をオプションで備える。すべてプロセス内で完結して動作する:ブラウザエンジンなし、Chromiumなし、外部コンバーターなし、そしてデフォルトではファイルアクセスもネットワークアクセスも一切ない

改良版のRenderHTMLではなく別のエントリポイントが存在する理由は互換性である。旧来のメソッドは長年にわたり本番アプリケーションで請求書や手紙をレンダリングし続けており、寛容なDOMとフルカスケードは、旧来の挙動に合わせて調整されたドキュメントの出力を変えてしまう。そのため旧メソッドは正確な意味論をそのまま保ち、新しいものはすべてRenderHTML5の背後に置かれる。そのプロファイルバージョンはHTML5ProfileVersionで報告され、サポートされるマイルストーンはHTML5ProfileMilestonesで一覧できる

HTML5プロファイルが実際に追加するもの

実際には4つの機能が重要である。htmlとbodyがテキストノードではなくコンテナである構造的DOM。正しい優先順位を持つCSSカスケード。テーブルレイアウト。そして明示的な改ページ制御であり、これが「ページ付きメディア」の意味するところである:HTMLはスクロールするビューポート向けに設計されており、それをページに変換するにはコンテンツがどこで分割されてよいかを決める必要がある

コンテナに関する点は実装の細部のように聞こえるが、実際には動くか動かないかの違いを生む。ルート要素がテキストコンテナとして扱われると、DOM全体が解析時に1つのテキストブロックへ潰れてしまい、その後のすべてのスタイルシートルール、改ページ、テーブル、フォームのサブツリーは何にも適用されなくなる。構造化されたドキュメントから1枚の未分化なテキストの壁を生成するHTMLレンダラーは、まさにこの誤りを犯している

カスケードの順序を明確にする

CSSの優先順位は単一のソートではない。HotPDFはマッチしたルールから通常の宣言を詳細度の順、続いてソース順に適用し、その上にインラインの通常宣言を適用し、次にマッチしたルールの!important宣言を適用し、最後にインラインの!importantを適用する。この順序で4パスとなる

HotPDFがDelphiにおいて各CSSプロパティを、通常ルールのマッチングからインラインの!important宣言まで4段階の順序付けられたカスケードパスで解決する図
後のパスが意図的に同点を制する:インライン宣言はスタイルシートより優先され、!importantは通常宣言されたすべてに優先する

これを単に「後勝ち」に潰してしまう実装は、よくあるケースは正しく処理するが、興味深いケースは間違える。これは一律に間違えるより悪い。なぜなら、その失敗はベーススタイルシートを上書きするために!importantを使うテンプレート——つまりカスケードが正しく機能することを期待して書かれたテンプレート——にしか現れないからである

uses
  HPDFDoc, HPDFHTMLImport;

var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := 'statement.pdf';
    Pdf.BeginDoc;

    Importer.Margin := 56;              // points
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;
    Importer.CreateAcroForms := False;  // fieldを作成せずrender controlだけを行う

    Css := '@page { margin: 56pt } ' +
           'table { border-collapse: collapse } ' +
           'tr.total { page-break-inside: avoid; font-weight: bold }';
    Html := LoadTemplate('statement.html');

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('nothing was emitted - check the markup');

    Pdf.EndDoc;

    Writeln(Format('profile=%d nodes=%d rules=%d layout ops=%d',
      [Importer.Statistics.ProfileVersion,
       Importer.Statistics.ParsedNodeCount,
       Importer.Statistics.CSSRuleCount,
       Importer.Statistics.LayoutOperationCount]));
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

第2引数は、ドキュメントが持つものと併せて適用される作成者スタイルシートであり、すべてのテンプレートを編集することなくページ余白やウィドウ制御といった社内ルールを強制する、すっきりした方法である

インポーターが何も取得しない理由

外部参照を解決するHTMLからPDFへのコンバーターは、ドキュメント生成の衣を纏ったサーバーサイドリクエストフォージェリエンジンである。ユーザー提供のマークアップに向ければ、それは喜んでhttp://169.254.169.254/やアプリケーションサーバー上のfile URLを取得し、その結果を誰かがダウンロードするPDFに埋め込んでしまう

そのためHotPDFはデフォルトでファイルもネットワークも読み取らない。画像やその他のリソースは正確に2通りの方法でのみ届く:マークアップ内のdata: URIとして、またはOnResourceコールバックを通じて、そこでは自分のコードがURIの意味を判断しバイト列を供給する

HotPDFのDelphi HTMLインポーターがすべてのリソースURIをローカルで解決し、data URIとOnResourceコールバックは受け入れつつ、個数とバイト数の上限の背後でファイルおよびネットワークフェッチを拒否する図
ローカルで届いたバイトだけがページに到達し、拒否された参照はカウンターを増加させ、フェッチが一度もプロセスの外に出ることはない
procedure TReportForm.SupplyResource(Sender: TObject;
  const URI: WideString; ResourceKind: THPDFHTMLResourceKind;
  var Data: TBytes; var ContentType: string; var Handled: boolean);
begin
  Handled := False;
  // allow-list済みのlocal asset directoryからのみserveし、決して
  // URIをHTTP clientに渡したり、pathにjoinしたりせず
  // 先にvalidationしてから実行する
  if StartsText('asset:', URI) then
  begin
    Data := LoadAssetBytes(Copy(URI, 7, MaxInt));
    ContentType := 'image/png';
    Handled := Length(Data) > 0;
  end;
end;

実際に届いたすべてのリソースは、それでもなお3つの独立した上限に対してカウントされる:リソースの個数、それぞれのバイト数、そして合計バイト数である。求められたものを忠実に返すだけのコールバックは、同じ50MBの画像を200回参照するドキュメントによってメモリを枯渇させられることはない

4つの予算、1つの失敗モード

パーサーノード、CSSルール、レイアウト操作、リソースは独立して計測される。なぜならそれぞれ独立して失敗するからである。あるドキュメントは小さくても病的にレイアウトコストが高いことがあり、別のドキュメントは浅くてもセレクタマッチングを爆発させるよう設計されたスタイルシートを持つことがある。いずれかを使い果たすとEHPDFHTMLImportBudgetが発生する。これは専用の例外クラスであり、予算の枯渇を不正な入力とは別に捕捉できるようにする

統計値はRenderHTML5を呼び出すたびにリセットされるため、その数値はインポーターオブジェクトの生涯ではなく1回のインポートを表す。信頼できないテンプレートをレンダリングするサービスでは、ドキュメントごとにParsedNodeCount、LayoutOperationCount、ResourceRejectCountをログに記録するとよい。拒否件数の増加は通常、テンプレート作成者がコールバックが認識しないアセットを参照していることを意味し、レイアウト操作数が上限近くにあるということは、次にわずかに大きいドキュメントが来れば失敗することを意味する

HTMLインポートとレイアウトAPIのどちらを選ぶか

HTMLインポートが有利なのは、コンテンツがDelphi開発者ではない人々によってHTMLとして作成される場合、テンプレートがコードよりも頻繁に変更される場合、あるいは同じコンテンツをメールとしても送信する必要がある場合である。タグ付き出力を伴う宣言的PDFレイアウトで説明されている宣言的レイアウトツリーが有利なのは、ドキュメントが自分のコードによってデータから生成される場合、PDF/UA構造を自動的に出力する必要がある場合、あるいはページ付けの正確な制御が作成の利便性よりも重要な場合である

両者は排他的ではない。よくある構成は、レポート本文にはHTMLインポートを使い、ヘッダー・フッター・署名ブロックは直接描画するというものである。これらは固定された幾何形状であり、どのテンプレートにも所有させるべきではないからである。テーブルは両方を計測する価値がある唯一の領域である:複雑な財務テーブルは、PDFでのテーブル生成で説明されている専用のテーブル経路の方がうまくページ分割される場合がある

CreateAcroFormsは意図的に設定すること。オフにすると、HTMLフォームコントロールは静的なアートワークとしてレンダリングされ、これは印刷用の明細書が望むものである。オンにすると、それらは実際のインタラクティブなAcroFormフィールドになり、これは記入可能なフォームが望むものであり、それに続くフィールドの挙動はロード済みPDFへのフォームフィールドの追加で扱われている

HTMLインポート、宣言的レイアウト、直接描画APIはすべて、DelphiおよびC++Builderにおいて同じドキュメントオブジェクトを対象とする。機能一覧の全体はHotPDF Delphi PDFコンポーネントページにある