Technical Article

CSS Multi-Column Layout in HTML-to-PDF with HotPDF Delphi

HotPDF Component lays out CSS multi-column text when THPDFHTMLImporter.RenderHTML5 converts HTML to PDF in Delphi. The paged HTML engine resolves column-count and column-width against the content box, balances the last fragment of every multicol container with a binary search, fills earlier pages column by column to the page bottom, and supports column-fill, column-span: all and column-rule

The usual reason to want this is boring and real: a two-column product bulletin, a terms-and-conditions annex set in three narrow columns, or a release notes PDF that should look like the printed newsletter the marketing team already designed in HTML. Without column support the importer stacks every paragraph in one wide measure, the line length doubles, and the document reads like a log file. With it, the same stylesheet that drives the browser preview drives the PDF, and the hard part becomes pagination, which browsers mostly never have to think about on screen

How does HotPDF decide how many columns to use?

HotPDF resolves the column count with the same rules as the pseudo-algorithm in CSS Multi-column Layout Module Level 1 §3.4, applied to the content box width W after padding and borders are removed. A declared column-count is taken as is. When only column-width is given, the engine computes N = Trunc((W + gap) / (column-width + gap)) with a floor of one column. When both are present, the result is the smaller of the two, so the declared count can cap the derived count but never inflate it

A concrete case: a 483pt content box (A4 with 56pt side margins, rounded) and column-width: 140pt; column-gap: 12pt gives Trunc(495 / 152) = 3 columns, each (483 - 2 * 12) / 3 = 153pt wide. Adding column-count: 2 to the same rule drops it to two columns, because the minimum wins. The gap keyword normal resolves to 1em of the container font size, and that resolution happens at layout time rather than at parse time, so a font-size declared after column-gap in the same rule still produces the right gap

HotPDF CSS multi-column count resolution for HTML to PDF in Delphi: column-count is used as declared, column-width derives N = Trunc((W + gap) / (column-width + gap)), the smaller wins when both are set, and any column below 12pt collapses to a single column
The engine applies the CSS pseudo-algorithm to the content box, caps the derived count with a declared one, and adds a 12pt floor guard so untrusted templates cannot produce thousands of microscopic columns

One guard is not in the specification, and it is there on purpose. After the column width is computed, any value below 12pt collapses the container back to a single column. A stylesheet that says column-count: 9999, whether by accident or because it came from an untrusted template, would otherwise produce thousands of columns with negative or microscopic widths and a line breaker that tries to fit one glyph per line. Falling back to one column keeps the output readable and the layout cost bounded. Zero or negative counts never get that far: the validator accepts only auto or a positive integer for column-count, and column-width and column-gap take auto or normal respectively, or a length in points or em

Rendering a multi-column HTML section to PDF in Delphi

Multi-column output needs no switch on the importer. THPDFHTMLImporter is created with the THotPDF document it draws into, BeginDoc must already have run, and the column properties arrive through the normal cascade, either in the markup or in the author stylesheet passed as the second argument of RenderHTML5

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;

Note the doubled escaping in the last paragraph: &amp; inside the Pascal string is the HTML entity for an ampersand, which the importer decodes back to a single character. The rest of the cascade, budgets and resource callbacks behave exactly as described in the overview of HotPDF HTML5 paged-media import; columns are just one more layout mode inside the same profile

Balancing columns with a binary search

HotPDF balances columns by searching for the smallest column height at which a greedy fill still places every remaining line. The greedy pass is simple: walk the lines into column one until the next line would exceed the candidate height, then continue in column two, and so on. The search starts with the tallest single line as the lower bound and the sum of all line heights as the upper bound, halves the interval each round, and stops once the bounds are within 0.05pt

HotPDF multi-column balancing in Delphi: a binary search between the tallest single line and the sum of all line heights finds the smallest column height whose greedy fill places every line, with bounds converging within 0.05pt
The every-line-fits predicate is monotonic in column height, so halving the interval converges on the shortest balanced band and the final greedy fill consumes every line without orphans

A binary search only works if the predicate is monotonic, and here it is: a taller column can never hold fewer lines than a shorter one, so "every line fits" flips from false to true exactly once as the height grows. That property is what guarantees convergence and guarantees that the final greedy fill at the found height consumes every line, rather than leaving an orphan line that would trigger a spurious extra page. It also explains a detail of the result: the balanced band is as short as the line boxes allow, not necessarily equal columns, because lines are indivisible and the last column may end a line or two higher than the others

What happens when columns cross a page break?

Columns that do not fit on the current page are filled sequentially to the page bottom, and only the final page fragment is balanced. This is the behavior CSS Multi-column Layout Level 1 §7.1 prescribes for column-fill: balance in fragmented contexts, and it is what a printed newsletter looks like: full columns on every page except the last, where the remaining text is split evenly instead of leaving one tall column next to an empty one

Each page fragment goes through the same fragmenter independently. If the remaining lines fit into N columns of the available height, the fragment is balanced; otherwise every column fills to the page bottom and the loop starts a new page. The natural height of the whole container, which the engine uses for break-inside: avoid decisions and extent measurement, is the balanced band height with unlimited capacity, so a container that would fit on a fresh page as a balanced block is moved there intact rather than sliced. When a single line is taller than an entire page, the engine relaxes the constraint, places the line anyway at the top of a page, and reports an hdkPaginationConstraint diagnostic with the subject multi-column

HotPDF multi-column pagination for HTML to PDF in Delphi: fragments before the last page fill every column to the page bottom, the final fragment balances across columns, and column-span all splits a container into separately balanced bands around a full-width element
Only the final fragment of a multicol container is balanced; earlier pages fill sequentially, and each fragment repeats the balance, rules and pagination independently

column-fill: auto and fixed-height boxes

With column-fill: auto and a definite height, HotPDF fills columns in order, and each column height is the smaller of the remaining page space and the container height minus its padding and borders. The minimum matters more than it looks. Using only the remaining page space would let the first column swallow every line whenever the page has more room left than the box. Without a definite height, column-fill: auto falls back to balancing, as browsers do, since an unconstrained multicol container has no column height to fill to; the first implementation missed that fallback, and one of the 27 regression checks caught it

column-span: all and nested paragraphs

HotPDF supports column-span: all on direct children of a multi-column container, following CSS Multi-column Layout Level 1 §6.1. A spanning child splits the container into segments: the children before it form one column band, the spanning element renders across the full width, and the children after it start a new band. Each band is balanced, filled, ruled and paginated on its own, so a heading in the middle of a three-column section produces two balanced bands with a full-width heading between them, which is the layout people actually draw in mockups

Getting there required fixing a less visible problem first. A container like <div style="column-count:2"> with several <p> children used to render every paragraph in a single stacked column, because each paragraph was laid out as its own text block and the column declaration on the parent never applied. HotPDF now collects the text of nested block children into one flow for the container. The collector has to insert a hard break at every block boundary while doing so; skipping it concatenates the last word of one paragraph with the first word of the next on the same line, a bug that is easy to miss when the test text happens to end in punctuation

That merged flow has a deliberate scope. It is used only when the whole subtree is plain text flow. A container that holds form controls, images, tables, lists or <hr> anywhere inside it keeps the ordinary block route and renders in a single column, because the merged text collector cannot express their geometry. Tables in particular have their own pagination rules, covered in the article on rowspan handling and repeated table headers in HotPDF, and mixing them into a column band would break those guarantees

Where the column rule goes, and how to test it

HotPDF draws column-rule on the midline of each gap, at the right edge of a column plus half the gap, from the top to the bottom of the band on every page fragment, as CSS Multi-column Layout Level 1 §4 describes. The width defaults to 1pt. The color defaults to the element text color when it is not declared or is currentColor. The column-rule shorthand without a style keyword is treated as solid, matching how the border shorthand is commonly read, while none and hidden suppress the rule

Column geometry is observable without parsing the PDF. The OnLineLayout event on THPDFHTMLImporter fires once per drawn line with a THPDFHTMLLineLayout record, and because the engine draws each column separately, the X field of each line is the left edge of its column. Adjacent columns differ by exactly the column width plus the gap, which makes it easy to tell a 12pt gap from a 20pt gap in a regression test

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 is zero-based; X is the left edge of the line's column
  Lines.Add(Format('page=%d x=%.1f y=%.1f %s',
    [Line.PageIndex, Line.X, Line.Y, string(Line.Text)]));
end;

// Wiring, before RenderHTML5:
//   Probe := TColumnProbe.Create;
//   Importer.OnLineLayout := Probe.LineDrawn;

Two practical notes from writing tests against this event. PageIndex is zero-based, which is the first thing a hand-written assertion tends to get wrong. And X includes alignment shifts, so for centered or right-aligned text compare column positions on left-aligned fixtures. The rule itself does not appear in the event; to assert its position, scan the uncompressed content stream for the vertical m and l operators

Limits and pitfalls worth knowing

A few behaviors are approximations, and they are better known before a layout review than after. Paragraph spacing inside a merged column flow is approximated by one line of the container line height at each block boundary, so per-paragraph margins inside a column band are not reproduced exactly. When column-span: all splits a container, each segment repeats the container padding and background box, which keeps the segments visually continuous but is not a single box in the strict CSS sense. Every column-rule-style value other than none and hidden passes validation, but the rule is stroked as a solid line

Unsupported values do not fail silently. Setting StrictCSS to True turns every diagnostic into an EHPDFHTMLUnsupportedCSS exception, while OnDiagnostic lets a batch job log them and carry on. Note that strict mode applies to the pagination diagnostic as well, so a document with a line taller than a page will raise under StrictCSS rather than quietly relax the constraint

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;

// Template authoring: fail fast on anything the engine would ignore
Importer.StrictCSS := FValidatingTemplate;
Importer.OnDiagnostic := HtmlDiagnostic;

Strict mode is the right setting while a template is being written, since a typo such as column-span: both is rejected immediately instead of silently letting the heading flow inside the columns. In production, logging through OnDiagnostic is usually the better trade, because one odd declaration in customer-supplied HTML should not cost the whole document. Identical diagnostics (same kind, subject and value) are reported once while they fit in the MaxDiagnostics list, which defaults to 256 entries, so a declaration the cascade visits for hundreds of elements does not flood the log

FAQ

Does HotPDF support the columns shorthand?

Yes. The columns shorthand accepts at most one positive integer count and one column length in either order, and auto resets both longhands. The resolved values follow the same count and width rules as the individual properties

Can a multi-column container hold images or tables?

It can hold them, but HotPDF then renders that container in a single column. Column flow is applied only when every descendant is plain text flow, because images, tables, lists and form controls need their own box geometry and pagination rules

Why does the last page show balanced columns while earlier pages are full?

That is the CSS rule for column-fill: balance in paged media: only the final fragment is balanced. Earlier pages fill each column to the page bottom so no space is wasted mid-document

Multi-column layout, the HTML5 paged-media profile and the rest of the HTML-to-PDF pipeline are part of HotPDF Component for Delphi and C++Builder, which also covers direct PDF drawing, forms, encryption and text extraction