技術記事

HotPDFのrowspanグリッドと表ヘッダーの繰り返し

HotPDFはHTML5ページドメディアプロファイル経由でHTML表を描画します。rowspanとcolspanには本物の占有グリッドを使い、行の高さは文字数概算ではなく実測、ヘッダー行は継続ページの毎ページで繰り返します。ヘッダーの繰り返しを拒む状況が2つあり、それを先に知っておく方が、後でセルの二重描画をデバッグするより安上がりです

これを強制するドキュメントの類型は、報告書を作るチームがいずれ必ず出荷するものです。正がHTMLであり、表が4ページにまたがり、しかもそのどのページでもヘッダーが読める必要がある請求書やコンプライアンス報告書。本物のテーブルレイアウトより弱いものを作ると、読者が即座に気づく2種類の失敗が出ます。ヘッダーが1ページ目にしか出ないのと、行の高さが文字数による当て込みになっているのとです

表機能はなぜHTMLレンダラー側へ移ったのか

別のやり方ではリッチテキストが失われ、リッチテキストこそコンテンツがHTMLである理由だからです。一見もっともな計画は再利用に見えます。HotPDFにはちゃんとしたグリッドを持つレイアウトDOMのテーブルオブジェクトがすでにあるのだから、HTMLパーサをそこへ橋渡しすれば、スパン対応はただで手に入る、と。問題はそのテーブルオブジェクトが何で描くかです。セルが持つのはテキストとスタイルだけで、描画経路はプレーンテキスト出力を吐きます。つまりHTMLに実際に含まれていた、フォントと色の域を超えるもの、リンク、上付き文字、インラインでのサイズ変更、ラン単位の色は、ページに届く時点で消えています

実ドキュメントとの接触に耐えるのは、逆の方向です。テーブルエンジンの能力、占有グリッド、実測、ヘッダー繰り返し、カラムの重み付けをHTMLレンダラー側へ移し、リッチテキスト描画はすでに動いている場所に留める。橋渡しより大きな変更でありながら、表セルの中のハイパーリンクをハイパーリンクであり続けさせるのはこの変更です

union-findなしのrowspan

スパンセルはアトミックな行グループを作りますが、そのグループの閉包には汎用のdisjoint-set構造は要りません。占有は常に連続した区間だからです。行Kから始まるrowspan="3"のセルが占めるのは行KからK+2までだけで、それ以外はありません。グループ情報は行ごとの終端マーカーに還元されます

アルゴリズムの意図は2行で書けます。Kで始まりEで終わるスパンセルを配置するときはGroupEnd[K] := Max(GroupEnd[K], E)を記録する。そして行を1度だけ逆順に歩き、G[R] := G[G[R]]を適用する。これが重なり合うスパンを通して各行の終端を後方へ伝播させ、1パスで推移閉包を得ます。手に入るのは、各行について、それと同じページに留まるべき最後の行です。改ページをどこに入れられるかを決めるのに、ページネーション段階がまさに必要とする情報です

高さの配分が残りの半分です。スパンセルが、カバーする行が現在用意している縦幅より多くを必要とするとき、余剰はスパンの最後の行へ行き、各行に均等には振り分けられません。スパンセルは通常の行高さが確定した後に処理し、各スパンの最終行を持ち上げます。余剰を均等配分にする方が公平に見え、出力は目に見えて壊れます。3行上の無関係なセルがたまたま高かっただけで、短い1行セルしか持たない行が膨らむのです

HotPDF の HTML 表グリッド。行 2 から始まる rowspan 3 のセルが行 2 から 4 を 1 つのアトミックな矩形として占有し、その隣に 1 回の逆順走査が生む行ごとのグループ終端値 G(R)。行 2、3、4 が同じページに束ねられる様子を示す
スパンの占有は常に連続区間なので、行ごとの終端マーカーと1回の逆順走査がunion-findを置き換え、改ページを入れられる位置をページネーションに正確に伝えます
var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Stats: THPDFHTMLImportStatistics;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'audit-report.pdf';
    Pdf.BeginDoc;
    Importer := THPDFHTMLImporter.Create(Pdf);
    try
      Importer.Margin := 48;
      Importer.BaseFontName := 'Arial';
      Importer.BaseFontSize := 10;
      Importer.MaxDOMNodes := 200000;
      Importer.MaxLayoutOperations := 2000000;
      if Importer.RenderHTML5(SourceHtml, PrintStyleSheet) then
      begin
        Stats := Importer.Statistics;
        Writeln('tables ', Stats.TableCount,
                '  page breaks ', Stats.PageBreakCount);
      end;
    finally
      Importer.Free;
    end;
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

RenderHTML5は第2引数にオプションの作者スタイルシートを取ります。印刷ルールはここに書くべきものであり、画面用スタイルシートを持ち込まないこと。プロファイルはバージョン管理されており、HTML5ProfileMilestonesが現在のビルドが実装する機能グループ、himParserCascadehimPagedLayouthimTablesFormshimBoundedResourcesを報告します。アプリケーションは本番で欠落を発見する代わりに、意図的に縮退できます

実測は描画と厳密に一致しなければならない

行の高さが正しいのは、折り返し行を測るコードが、描くコードと同じルールで折り返すときだけです。当然に聞こえますが、枠線が中身と揃わない表の単独最大の原因はここです。HotPDFは貪欲な行カウンターで測定し、このカウンターはリッチテキスト出力経路の折り返しセマンティクスと、3つの具体点で一致しなければなりません。空白でのみ改行すること、単語を決して分割しないこと、カラムより幅の広い単語は1行を独占すること

2つ目の要件はフォントです。測定はセル自身のフォントで走らなければなりません。幅関数を呼ぶ前に、実際の名前、スタイルセット、サイズでSetFontしておくことであり、たまたまアクティブだったフォントであってはなりません。ボールドは同サイズのレギュラーより日常的に1割以上広く、3行のセルを4行のセルに変えるのに十分です。ヘッダーセルはボールドで本文セルはそうでない表を単一フォントで測ると、狂うのは読者が最初に見る行そのものです

ここを正しくすると、テストで主張できることが変わります。正確な測定の観測可能な効果はグリフ数ではなく行間です。1行の行は約20ポイントの高さになる一方、同じ内容の文字数概算は2行、約35を予測します。主張すべきは行間の縦距離です。そしてPDFのユーザー空間ではYが上向きに増えるため、本文行の上にヘッダーが座っているということは、ヘッダーのY値が大きい方という意味です。画面座標の感覚が書くものの正反対です

HotPDFはどんなときヘッダーの繰り返しを拒むのか

2つのケースで、どちらも進めたなら出力が目に見えて壊れるものです。1つ目は、ヘッダーから本文行へ突き出すスパンセルを含むヘッダーブロック。ヘッダーを繰り返すと、あのセルの中身が、もう属していない位置へ2回目の描画を受けるため、ヘッダーは1回だけ描かれ、表はヘッダーなしで続きます。2つ目は、使用可能ページ高の90%を超えるヘッダーで、繰り返すとデータの余地がほとんど残らず、表は前に進めません

HotPDF が改ページをまたいで HTML 表ヘッダーを繰り返すかの判断フロー。rowspan が本文行に食い込むヘッダーは 1 回だけ描画し、使用可能ページ高の 90 パーセントを超えるヘッダーも 1 回だけ描画し、それ以外のヘッダーは継続ページごとに繰り返す
2つの拒否は意図的です。スパンする本文セルを持つヘッダーや、ページの大半を埋めるヘッダーを繰り返すと、中身がもう属さない場所に描かれるか、データの余地が失われます

両方の拒否は設計による意図的で静かなものです。代わりの選択肢の方がひどいからです。ヘッダーが繰り返されず、繰り返されると思っていた場合は、エンジンを疑う前に、thead境界を越えるrowspanがマークアップにないか確認してください。驚きの大半は、この1つのマークアップパターンで説明がつきます

// カラムの重みはマークアップから来る。制御するなら印刷スタイルシートが
// 適切な場所。幅はピクセルではなく重みとして扱われる
const
  PrintStyleSheet =
    'table { width: 100%; }' +
    'thead th { font-weight: bold; background: #eee; }' +
    'td.amount { text-align: right; }';

// 本文へ食い込む rowspan を持つヘッダー行はヘッダー繰り返しを抑制する。
// スパンは 1 つのセクション内に収める:
//   <thead><tr><th rowspan="2">Item</th>...</tr></thead>  問題なし
//   <tr><th rowspan="3">Item</th>...  tbody へまたがる、繰り返しなし

カラム幅は絶対値ではなく重みとして振る舞います。コンテンツが作者の見積もりと合っていないときも表を使い物に保つのは、この振る舞いです。30パーセントと宣言したカラムは利用可能幅の大体30パーセントを得ますが、配分は各カラムが実際に必要とする最小幅を尊重するため、長い分割不能トークンを抱えた狭いカラムが、表ボックスを黙って溢れることはありません

ドキュメントパイプラインの中での位置づけ

表の作業はより広いページドメディアプロファイルの中に収まっており、HTML5ページドメディアのインポート経路で説明したページネーションルール、リソース予算、CSSの扱いは、表を含むドキュメントにもそのまま当てはまります。データの起点がHTMLでないなら、PDFに直接テーブルを構築する直接構築ルートはパース層を完全に回避し、同じグリッド挙動をAPI経由で与えます。そして行の高さは突き詰めると改行位置に依存するため、テキストのジャスティフィケーションと改行の測定の議論は、密な表組み出力を調整する人の必需品です

ここで再利用できる教訓は、表についてはまったくありません。新しいサブシステムが、古いサブシステムがすでに持つ能力を必要とするとき、どちらが再実装が最も困難なものを所有しているのかを問うことです。グリッド演算は数十行であり、簡単に引っ越せます。インラインリンク、上付き文字、ラン単位のスタイリングを備えたリッチテキスト描画はそうではありません。だからグリッドが引っ越し、テキストは留まったのです。HotPDFは両経路をHotPDF Delphi PDFコンポーネントの一部として出荷しています。HTML入力と直接構築の選択は、ライブラリではなくプロジェクト側の決断だということです