技術記事

DelphiでPDFへ出力するFlexbox、CSS Grid、脚注

PDF Library for Delphiは、HTMLを本物の2次元レイアウトを備えたPDFページへレンダリングします。display: flexdisplay: gridは、縦に積み上げたブロックへと劣化させるのではなく、きちんと測定して配置されます。脚注は、その参照を含むボックスの下端に確保され、段組みやページをまたいでも連番が維持されます。使用するエントリポイントはおなじみのもので、単一ボックスにはDrawHTMLTextBox、複数段のフローにはDrawHTMLStoryを使用します

これが重要なのは、今日ではレポートのコンテンツの大半がHTMLとして届くからです。テンプレートはCSSを書く人によって作成され、ダッシュボードはカード形式でデザインされます。flexの行を黙って4つの縦積みブロックへ折り畳んでしまうレンダラーは、デザインとはまったく似ても似つかないドキュメントを生み出してしまいます。この機能が存在するまでは、このエンジンが測定できる2次元コンテナはテーブルだけだったため、カード形式のレイアウトはすべて手作業でテーブルとして書き直さなければなりませんでした

レイアウトモデルの何が変わったのか?

従来のメインループは、単一の行ボックスを維持しながらページを下方向に進んでいくものでした。このモデルは、インラインコンテンツと縦積みのブロックについては完璧に処理できますが、子要素が互いの関係でサイズ決定されるコンテナを表現できません。テーブルだけが唯一の例外であり、独自の2パス測定を持っていました

FlexとGridは、それぞれコンテナの子要素に対して上限付きの測定パスを追加します。重要なのは「上限付き」という点です。flexコンテナは、最大256個の直接の子要素を固定配列に測定します。gridは、決定論的な自動配置のために、最大64×64セルの占有マトリクスを使用します。これらの上限が存在するのは、悪意のある、あるいは自動生成されたスタイルシートが、無限の再帰や2乗のオーダーの配置用メモリを引き起こさないようにするためです。これは、HTMLが顧客の編集するテンプレートから届く場合に、現実的な懸念となります

flexアイテムはどのようにサイズを得るのか

行方向では、コンテナは各アイテムの基準サイズ(basis)に、伸縮のウェイト(grow・shrink)を加えて合計し、その後、余った空間——正でも負でも——をそれらのウェイトに応じて分配します。flex-wrapが指定されている場合、各行は独立して解決されるため、2行に折り返された行は、コンテナ全体をまたいでではなく、行ごとに空きスペースを割り当てます。列方向でも、同じ主軸方向の分配が、明示的な高さかコンテンツの高さのいずれかに対して行われます

justify-contentalign-itemsgap、そして逆方向の指定は、すでに測定済みのジオメトリに対して作用します。これらはボックスを動かすだけであり、アイテムのコンテンツを再測定させることは決してありません。この分離こそが、複雑なダッシュボードで子要素を何度も測定し直すことを防いでいます

uses
  PDFlibrary;

var
  Lib: TPDFlib;
  Html, Remainder: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.SetPageSize('A4');
    Lib.NewPage;

    Html :=
      '<div style="display:flex; gap:12px;">' +
      '  <div style="flex:2 1 0; background:#f4f6f8; padding:8px;">' +
      '    <b>Revenue</b><br/>EUR 4,182,300</div>' +
      '  <div style="flex:1 1 0; background:#f4f6f8; padding:8px;">' +
      '    <b>Margin</b><br/>18.4%</div>' +
      '  <div style="flex:1 1 0; background:#f4f6f8; padding:8px;">' +
      '    <b>Backlog</b><br/>92 days</div>' +
      '</div>';

    Remainder := Lib.DrawHTMLTextBox(40, 40, 515, 120, Html);
    if Remainder <> '' then
      Log('content did not fit - carry the remainder to the next box');

    Lib.SaveToFile('dashboard.pdf');
  finally
    Lib.Free;
  end;
end;

戻り値は継続文字列であり、これは、あらゆるHTML描画エントリポイントが、収まらなかった部分をどう報告するかの仕組みです。それを次のボックスや次のページに渡せば、フローは止まった場所から再開します

グリッドの配置、そしてトラックに指定できるもの

グリッドのトラックには、固定長、パーセンテージ、fr単位、単純なrepeat()式、minmax()を指定できます。自動配置は占有マトリクスを決定論的に埋めるため、同じHTMLは常に同じ配置を生み出します。明示的な座標は重なることを許容します。これは意図的なものです。カードの上にバッジを重ねるデザインは、エラーではなく意図の表現だからです。片方の軸だけが明示的に指定されている場合、配置探索はもう片方の軸だけを対象に行われます

複数の行にまたがるアイテムは、測定された高さを、それがまたがる行に均等に配分して還元します。これにより、背の高いスパンするアイテムが1つの行だけを圧迫し、隣接する行を低いままにしてしまうことを防ぎます:

Html :=
  '<div style="display:grid; grid-template-columns:repeat(3, 1fr); ' +
  '            gap:10px;">' +
  '  <div style="grid-row:span 2; background:#eef;">Site plan</div>' +
  '  <div>Inspector</div>' +
  '  <div>Date</div>' +
  '  <div style="grid-column:2 / span 2;">Findings summary</div>' +
  '</div>';

Remainder := Lib.DrawHTMLTextBox(40, 180, 515, 260, Html);

FlexとGridの子要素は、他のすべてと同じHTMLレンダラーを通してレンダリングされます。これが、この機能を独立した別世界にするのではなく、実際に使えるものにしている性質です。フォント、CSSのカスケード、リンク、画像、テーブル、さらにネストしたflexコンテナやgridコンテナは、flexアイテムの内部でも、トップレベルとまったく同じように振る舞います。外側のレイアウトプランは最終的なテキストと矩形の描画コマンドを記録するため、繰り返しの描画は既存の測定キャッシュを再利用します

なぜ脚注はページ分割の問題になるのか

脚注は、その参照を含む段落の後に流れ込むコンテンツではありません。参照と同じボックスの下端に現れなければならないコンテンツです。これは、通常の測定順序を逆転させます。本文用に使える空間が、まだレイアウトされていないコンテンツに左右されることになるからです

そのため、レンダラーは参照に出会った時点で脚注を測定し、その脚注の領域を、現在の範囲指定されたボックスの本文用の高さ予算から差し引きます。参照、それまでの本文、そして脚注のすべてが収まらない場合、脚注のマーカーとそれ以降のすべては、まとめて継続文字列へ回されます。この規則が、2つの典型的な失敗を防いでいます。脚注が本文の上に重ねて印刷されてしまう失敗と、参照が前のページにあるのに脚注だけが取り残されてしまう失敗です

範囲指定されたボックスでは、脚注の領域は下端に固定され、その上に区切り線が引かれます。範囲指定のない測定では、固定すべきボックスの高さが存在しないため、脚注の領域は本文の直後に続きます。番号は継続スタック上の拡張フィールドで運ばれるため、DrawHTMLTextBoxDrawHTMLStoryは、段組みやページをまたいでも連番を維持し続けます。そのフィールドが存在する前に生成された継続文字列も、正しく再開されます

// 複数段のストーリー内の脚注は1つの連番を維持し続ける
Html := LoadTemplate('chapter.html');    // float:footnote マーカーを使用
Remainder := Lib.DrawHTMLStory(40, 40, 515, 700,
  2,        // 段数
  16,       // ガター幅(ポイント単位)
  20,       // このストーリーの最大ページ数
  Html);
if Remainder <> '' then
  Log('story exceeded its page budget');

テンプレート作成者向けの実務上のガイダンス

文書化された上限の範囲内で設計してください。256個を超える直接の子要素を持つflexコンテナは、ほとんどの場合、flexの衣装をまとっただけのデータテーブルであり、いずれにせよテーブル向けの経路の方がうまく測定できます。64×64を超えるグリッドはスプレッドシートであり、同じ助言が当てはまります。複数段の本文テキストについては、ハイフネーションと均等な段組みで説明した段組みとハイフネーションの挙動が、各段内でのフローの見え方を左右します

レイアウトを収める必要がある場合は、描画する前に測定してください。GetHTMLTextHeightは、指定した幅が必要とする高さを報告してくれます。これは、実際に描画を確定する前に、複数のレイアウト案を比較検討する安上がりな方法です。そして、空でない継続文字列は、例外ではなく正常な状態として扱ってください。それは長いコンテンツがページ分割されるための仕組みであり、エラー信号ではありません

HTMLが手書きのテンプレートではなくレポート生成エンジンから届く場合は、データセットレポートエンジンで説明したデータ駆動の経路がこれとうまく組み合わさり、FlexとGridがその後に配置するマークアップを生成します。また、同じコンテンツを再びPDFから取り出す必要がある場合は、PDFをMarkdownやDOCXへエクスポートするで説明した意味的なエクスポート経路が、この往復を締めくくります

HTMLレイアウト、レポート生成、意味的なエクスポートは、Delphi、C++Builder、Free Pascal向けの1つのライブラリの一部です。完全な機能一覧はPDF Library for Delphiページに掲載されています