技術記事

Delphi の HotPDF で CSS マルチカラム HTML を PDF へ

HotPDF Component は、THPDFHTMLImporter.RenderHTML5 が Delphi で HTML を PDF へ変換するとき、CSS マルチカラムのテキストを組版します。ページ化 HTML エンジンは内容ボックスに対して column-count と column-width を解決し、すべての multicol コンテナの最終フラグメントを二分探索でバランスさせ、それより前のページはページ末までカラムを順に満たし、column-fill、column-span: all、column-rule に対応します

これが欲しくなる理由は退屈ですが現実的です。2 カラムの製品告知、3 本の細いカラムに組まれた利用規約の附属書、マーケティングチームがすでに HTML で設計した印刷ニュースレターのようなリリースノート PDF。カラム対応がなければ、インポーターは全段落を 1 本の広い行長に積み上げ、行長は倍になり、文書はログファイルのように読めます。あれば、ブラウザプレビューを駆動するのと同じスタイルシートが PDF を駆動し、難しい部分はページ分割になります。ブラウザが画面上ではほとんど考えなくてよいやつです

HotPDF はカラム数をどう決めるのか

HotPDF は、パディングとボーダーを除いた内容ボックス幅 W に対して、CSS Multi-column Layout Module Level 1 §3.4 の疑似アルゴリズムと同じ規則でカラム数を解決します。宣言された column-count はそのまま使われます。column-width だけが与えられた場合、エンジンは N = Trunc((W + gap) / (column-width + gap)) を計算し、下限 1 カラムです。両方ある場合は小さい方が採られ、宣言した数は導出数を上限として抑えますが、膨らませることはありません

具体例を挙げます。483pt の内容ボックス(A4 に 56pt の左右マージン、丸めた値)と column-width: 140pt; column-gap: 12pt なら Trunc(495 / 152) = 3 カラムで、各カラムは (483 - 2 * 12) / 3 = 153pt 幅です。同じ規則に column-count: 2 を足すと 2 カラムに落ちます。最小が勝つからです。gap キーワードの normal はコンテナのフォントサイズの 1em に解決され、この解決はパース時ではなくレイアウト時に行われるため、同じ規則内で column-gap の後に宣言された font-size でも正しいギャップが出ます

Delphi で HTML を PDF へする際の HotPDF CSS マルチカラム数の解決。column-count は宣言どおり使われ、column-width は N = Trunc((W + gap) / (column-width + gap)) を導出し、両方設定時は小さい方が勝ち、12pt 未満のカラムは単一カラムに潰れる
エンジンは CSS の疑似アルゴリズムを内容ボックスに適用し、導出した数を宣言数で上限切りし、12pt の下限ガードを足します。信頼できないテンプレートが数千の微小カラムを生めないためです

仕様にないガードが 1 つあり、意図的なものです。カラム幅が計算された後、12pt 未満の値はコンテナを単一カラムへ折り返します。column-count: 9999 を宣言したスタイルシートは、事故であれ信頼できないテンプレート由来であれ、そのままだと負や極小の幅のカラムを数千作り、1 グリフ 1 行を試す行ブレーカを生むことになります。単一カラムへのフォールバックで、出力は読めるまま、レイアウトコストは有限のままです。ゼロや負の数はそこまで届きません。バリデータは column-count に auto か正の整数だけを受け付け、column-width と column-gap はそれぞれ auto か normal、またはポイントか em の長さを取ります

Delphi でマルチカラム HTML セクションを PDF に描く

マルチカラム出力にインポーターのスイッチは要りません。THPDFHTMLImporter は描き込み先の THotPDF 文書と共に生成され、BeginDoc がすでに走っている必要があり、カラムプロパティは通常のカスケードを通って届きます。マークアップでも、RenderHTML5 の第 2 引数として渡す著者スタイルシートでも構いません

uses
  SysUtils, HPDFDoc, HPDFHTMLImport;

procedure BuildBulletin(const FileName: string);
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := FileName;
    Pdf.BeginDoc;
    Importer.Margin := 56;
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;

    Css := 'section.news { column-width: 140pt; column-gap: 12pt; ' +
           '  column-rule: 0.5pt solid #999999 } ' +
           'section.news h2 { column-span: all }';

    Html := '<section class="news">' +
            '<p>Release 4.2 ships the new export pipeline...</p>' +
            '<p>Licensing changes take effect on 1 March...</p>' +
            '<h2>Known issues</h2>' +
            '<p>Import of legacy archives &amp; templates...</p>' +
            '</section>';

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('Nothing was emitted');
    Pdf.EndDoc;
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

最後の段落の二重エスケープに注目してください。Pascal 文字列内の &amp; はアンパサンドの HTML エンティティで、インポーターが 1 文字へデコードし戻します。カスケードの残り、予算、リソースコールバックは、HotPDF HTML5 paged-media インポートの概要で述べたとおりに振る舞います。カラムは同じプロファイルの中のもう 1 つのレイアウトモードにすぎません

二分探索でカラムをバランスさせる

HotPDF は、貪欲な充填でも残りの全行が置ける最小のカラム高さを探索してカラムをバランスさせます。貪欲パスは単純で、次の行が候補高さを超えるまでカラム 1 に行を歩かせ、超えたらカラム 2 へ続き、という具合です。探索は最も高い 1 行を下限、全行高の合計を上限に始め、各ラウンドで区間を半分にし、上下限が 0.05pt 以内に収まったら止まります

Delphi における HotPDF マルチカラムのバランシング。最も高い 1 行と全行高の合計の間の二分探索が、貪欲充填で全行が置ける最小のカラム高さを見つけ、上下限は 0.05pt 以内に収束する
全行が収まる述語はカラム高さに対して単調なので、区間の半分化は最短のバランス帯へ収束し、最後の貪欲充填が孤児行を出さずに全行を消費します

二分探索が機能するのは述語が単調なときだけで、ここではそうです。高いカラムが低いカラムより少ない行を収めることはなく、「全行が収まる」は高さの成長とともにちょうど 1 回 false から true へ反転します。この性質が収束を保証し、見つかった高さでの最後の貪欲充填が全行を消費すること、偽の余分ページを誘発する孤児行を残さないことを保証します。結果の細部の説明にもなります。バランスされた帯は行ボックスが許す限り短いのであって、必ずしも等高のカラムではありません。行は分割できず、最後のカラムは他より 1、2 行高いところで終わり得ます

カラムが改ページを跨ぐと何が起きるか

現在のページに収まらないカラムはページ末まで順に満たされ、バランスされるのは最終ページフラグメントだけです。これは CSS Multi-column Layout Level 1 §7.1 が分割された文脈での column-fill: balance に定める挙動で、印刷ニュースレターの見た目でもあります。最後のページ以外はすべてのページでカラムは満杯で、残りのテキストは、1 本の高いカラムの隣に空のカラムを残すのではなく、均等に分割されます

各ページフラグメントは同じフラグメンターを独立に通ります。残りの行が利用可能高さの N カラムに収まるならフラグメントはバランスされ、そうでなければ全カラムをページ末まで満たしてループが新しいページを始めます。エンジンが break-inside: avoid の判断と範囲測定に使うコンテナ全体の自然高さは、容量無限のバランス帯の高さで、新規ページならバランスされたブロックとして収まるコンテナは、薄切りにされずそのままそこへ移動します。1 行がページ全体より高いとき、エンジンは制約を緩め、その行をとにかくページ先頭に置き、multi-column を subject とする hdkPaginationConstraint の診断を報告します

Delphi で HTML を PDF へする際の HotPDF マルチカラムのページ分割。最終ページより前のフラグメントは全カラムをページ末まで満たし、最終フラグメントはカラム間でバランスされ、column-span all はコンテナを全幅要素を挟んで別々にバランスされた帯へ分割する
バランスされるのは multicol コンテナの最終フラグメントだけです。それより前のページは順に満たされ、各フラグメントはバランス、罫線、ページ分割を独立に繰り返します

column-fill: auto と固定高さボックス

column-fill: auto と確定した height では、HotPDF はカラムを順に満たし、各カラムの高さは残りページ空間と、コンテナの高さからパディングとボーダーを引いたものの小さい方です。この最小が見た目より重要です。残りページ空間だけを使うと、ページにボックスより余裕があるとき、最初のカラムがすべての行を飲み込み得ます。確定した高さがない場合、column-fill: auto はブラウザと同じくバランシングへフォールバックします。制約のない multicol コンテナには満たすべきカラム高さがないからです。最初の実装はこのフォールバックを見落とし、27 個のリグレッションチェックの 1 つが捕まえました

column-span: all とネストした段落

HotPDF は CSS Multi-column Layout Level 1 §6.1 に従い、マルチカラムコンテナの直接の子に対する column-span: all に対応します。スパンする子はコンテナをセグメントに分割します。それより前の子が 1 つのカラム帯を形成し、スパン要素は全幅で描かれ、その後の子が新しい帯を始めます。各帯は独立にバランス、充填、罫線、ページ分割されるため、3 カラムセクションの途中の見出しは、間に全幅の見出しを挟んだ 2 つのバランス帯を生みます。モックアップで人々が実際に描くレイアウトです

そこに至るには、まず目立ちにくい問題の修正が要りました。<div style="column-count:2"> のようなコンテナに複数の <p> 子があると、以前はすべての段落が 1 本の積み上げカラムに描かれていました。各段落が独自のテキストブロックとして組まれ、親のカラム宣言が一度も適用されなかったからです。HotPDF は今、ネストしたブロック子のテキストをコンテナ用の 1 つのフローに集めます。収集器はその際、すべてのブロック境界に強制改行を差し込まなければなりません。省くと、ある段落の最後の単語と次の段落の最初の単語が同じ行で連結されます。テストテキストがたまたま句読点で終わっていると見逃しやすいバグです

この統合フローには意図的な適用範囲があります。サブツリー全体が素のテキストフローのときだけ使われます。フォームコントロール、画像、テーブル、リスト、<hr> をどこかに含むコンテナは通常のブロック経路を保ち単一カラムで描かれます。統合テキスト収集器にはそれらのジオメトリを表現できないからです。テーブルはとりわけ独自のページ分割規則を持ち、HotPDF の rowspan 処理とテーブルヘッダーの繰り返しの記事がそれを扱っており、カラム帯に混ぜるとそれらの保証が壊れます

column rule の位置とテスト方法

HotPDF は CSS Multi-column Layout Level 1 §4 の記述どおり、各ギャップの中央線、つまりカラムの右端にギャップの半分を足した位置に、すべてのページフラグメントで帯の上端から下端まで column-rule を描きます。幅の既定は 1pt。色は宣言がないか currentColor のとき要素のテキスト色が既定です。スタイルキーワードのない column-rule ショートハンドは solid として扱われます。border ショートハンドが普通そう読まれるのと合わせたもので、none と hidden は罫線を抑止します

カラムのジオメトリは PDF をパースしなくても観測できます。THPDFHTMLImporter の OnLineLayout イベントは、描かれた行ごとに THPDFHTMLLineLayout レコード付きで 1 回発火し、エンジンは各カラムを別々に描くため、各行の X フィールドはそのカラムの左端です。隣接カラムは正確にカラム幅+ギャップだけ違い、リグレッションテストで 12pt ギャップと 20pt ギャップの判別が容易です

type
  TColumnProbe = class
  public
    Lines: TStringList;
    constructor Create;
    destructor Destroy; override;
    procedure LineDrawn(Sender: TObject; const Line: THPDFHTMLLineLayout);
  end;

constructor TColumnProbe.Create;
begin
  inherited Create;
  Lines := TStringList.Create;
end;

destructor TColumnProbe.Destroy;
begin
  Lines.Free;
  inherited;
end;

procedure TColumnProbe.LineDrawn(Sender: TObject;
  const Line: THPDFHTMLLineLayout);
begin
  // PageIndex は 0 起点。X は行のカラムの左端
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// 配線。RenderHTML5 の前に:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

このイベントでテストを書いて得た実務メモが 2 つです。PageIndex は 0 起点で、手書きのアサーションが最初に間違えるのはたいていここです。そして X は配置のずれを含むため、中央寄せや右寄せのテキストでは、左寄せのフィクスチャでカラム位置を比較してください。罫線そのものはイベントに現れません。位置を assert するには、非圧縮のコンテンツストリームを縦の m と l 演算子で走査します

知っておくべき限界と落とし穴

いくつかの振る舞いは近似で、レイアウトレビューの後にではなく前に知れている方がよいものです。統合されたカラムフロー内の段落間隔は、各ブロック境界でコンテナの行高 1 行分で近似されるため、カラム帯内の段落ごとのマージンは正確には再現されません。column-span: all がコンテナを分割するとき、各セグメントはコンテナのパディングと背景ボックスを繰り返します。セグメントは視覚的に連続に保たれますが、厳密な CSS の意味での単一ボックスではありません。none と hidden 以外のすべての column-rule-style 値は検証を通りますが、罫線は実線として描かれます

未対応の値は無言で失敗しません。StrictCSS を True にするとすべての診断が EHPDFHTMLUnsupportedCSS 例外になり、OnDiagnostic ならバッチジョブがそれをログに取って続行できます。strict モードはページ分割診断にも適用される点に注意してください。ページより高い行のある文書は、StrictCSS の下では制約を静かに緩めるのではなく例外を上げます

procedure TReportJob.HtmlDiagnostic(Sender: TObject;
  const Diagnostic: THPDFHTMLDiagnostic);
begin
  case Diagnostic.Kind of
    hdkUnsupportedProperty, hdkUnsupportedValue:
      FLog.Add('CSS ' + Diagnostic.Subject + ': ' + Diagnostic.MessageText);
    hdkPaginationConstraint:
      FLog.Add('Layout relaxed (' + Diagnostic.Subject + '): ' +
        Diagnostic.MessageText);
  else
    FLog.Add(Diagnostic.MessageText);
  end;
end;

// テンプレート作成時:エンジンが無視するものはすべて即座に失敗させる
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

strict モードはテンプレートを書いている間の正しい設定です。column-span: both のようなタイプミスが、見出しをカラム内に静かに流す代わりに即座に拒否されます。本番では OnDiagnostic 経由のロギングがたいてい良い取引です。顧客提供 HTML の 1 つの奇妙な宣言に文書全体を払わせるべきではありません。同一の診断(同じ kind、subject、value)は MaxDiagnostics リストに収まっている間 1 回だけ報告されます。既定は 256 エントリで、カスケードが数百の要素に対して訪れる宣言がログを水没させません

FAQ

HotPDF は columns ショートハンドに対応しているか

はい。columns ショートハンドは最大 1 つの正の整数カウントと 1 つのカラム長をどちらの順でも受け付け、auto は両方のロングハンドをリセットします。解決された値は個別のプロパティと同じカウントと幅の規則に従います

マルチカラムコンテナは画像やテーブルを保持できるか

保持はできますが、その場合 HotPDF はそのコンテナを単一カラムで描きます。カラムフローが適用されるのは、すべての子孫が素のテキストフローのときだけです。画像、テーブル、リスト、フォームコントロールには独自のボックスジオメトリとページ分割規則が必要だからです

最終ページだけカラムがバランスし、前のページは満杯なのはなぜか

ページ化メディアでの column-fill: balance の CSS 規則がそうだからです。バランスされるのは最終フラグメントだけです。それより前のページは各カラムをページ末まで満たし、文書中盤で空間を無駄にしません

マルチカラムレイアウト、HTML5 paged-media プロファイル、HTML to PDF パイプラインの残りは、直接 PDF 描画、フォーム、暗号化、テキスト抽出もカバーする HotPDF Component for Delphi and C++Builder の一部です