技術文章

HotPDF Delphi 的 HTML 轉 PDF CSS 多欄版面

當 THPDFHTMLImporter.RenderHTML5 在 Delphi 中把 HTML 轉成 PDF 時,HotPDF Component 會做 CSS 多欄排版。分頁 HTML 引擎對內容方塊解析 column-count 與 column-width,用二分搜尋平衡每個多欄容器在最後一頁的片段,較早的頁面逐欄填到頁底,並支援 column-fill、column-span: all 與 column-rule

想要它的理由無聊但真實:兩欄的產品快報、排成三條窄欄的條款附錄,或一份該長得像行銷團隊已經用 HTML 排好的印刷通訊的 release notes PDF。沒有欄位支援,匯入器把每個段落堆進一條過寬的行寬裡,行長翻倍,文件讀起來像日誌檔。有了它,驅動瀏覽器預覽的同一份樣式表也驅動 PDF,難的部分變成分頁——瀏覽器在螢幕上幾乎永遠不必想這件事

HotPDF 怎麼決定用幾欄?

HotPDF 解析欄數用的規則與 CSS Multi-column Layout Module Level 1 §3.4 的偽演算法相同,套在去除 padding 與邊框後的內容方塊寬度 W 上。宣告的 column-count 照用。只給 column-width 時,引擎算 N = Trunc((W + gap) / (column-width + gap)),下限一欄。兩者都有時取較小值,所以宣告的欄數可以封頂導出的欄數,但不能抬高它

一個具體案例:483pt 的內容方塊(A4 帶 56pt 側邊界,取整)加 column-width: 140pt; column-gap: 12pt,得 Trunc(495 / 152) = 3 欄,每欄 (483 - 2 * 12) / 3 = 153pt 寬。同一條規則再加 column-count: 2 就降到兩欄,因為較小者勝。gap 關鍵字 normal 解析為容器字號的 1em,這個解析發生在排版時而不是剖析時,所以同一條規則裡寫在 column-gap 之後的 font-size 仍會產出正確的間距

Delphi 中 HotPDF 為 HTML 轉 PDF 做的 CSS 多欄欄數解析:column-count 照宣告採用,column-width 導出 N = Trunc((W + gap) / (column-width + gap)),兩者都設時取較小者,任何低於 12pt 的欄塌縮成單欄
引擎把 CSS 偽演算法套到內容方塊上,用宣告的欄數封頂導出的欄數,並加上 12pt 下限護欄,讓不可信的範本造不出成千上萬條顯微欄

有一道護欄不在規範裡,是刻意加的。欄寬算出之後,任何低於 12pt 的值都讓容器塌回單欄。一條寫著 column-count: 9999 的樣式表——無論是意外還是來自不可信的範本——否則會產出成千上萬條負寬或顯微寬的欄,以及一個想一行塞一個字形的斷行器。退回單欄保住可讀性,也讓排版成本有界。零或負的欄數根本走不到那裡:驗證器只接受 auto 或正整數作為 column-count,column-width 與 column-gap 各自接受 auto 與 normal,或以 pt 或 em 表示的長度

在 Delphi 中把多欄 HTML 區塊渲染成 PDF

多欄輸出不需要在匯入器上開關。THPDFHTMLImporter 以它要畫進去的 THotPDF 文件建立,BeginDoc 必須已經跑過,欄屬性則透過正常層疊到達——寫在標記裡,或寫在作為 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;

注意最後一個段落的雙重轉義:Pascal 字串裡的 &amp; 是代表 & 符號的 HTML 實體,匯入器會把它解碼回單一字元。層疊的其餘部分、預算與資源回呼的行為與HotPDF HTML5 分頁媒體匯入總覽描述的完全一致;欄只是同一 profile 裡的另一種排版模式

用二分搜尋平衡欄位

HotPDF 平衡欄位的方式,是搜尋貪婪填充仍能放下所有剩餘行的最小欄高。貪婪那一趟很簡單:把行往第一欄裡放,下一行會超過候選高度就換第二欄,依此類推。搜尋以最高的單行為下界、所有行高之和為上界,每輪把區間對半砍,上下界相差在 0.05pt 內就停

Delphi 的 HotPDF 多欄平衡:在最高單行與所有行高之和之間二分搜尋,找出貪婪填充能放下每一行的最小欄高,上下界在 0.05pt 內收斂
每一行都放得下這個述詞對欄高是單調的,所以對半砍區間會收斂到最短的平衡帶,最後一趟貪婪填充把每一行都消化掉、不留孤行

二分搜尋只有在述詞單調時才成立,而這裡成立:更高的欄不可能比矮的欄裝得更少行,所以「每一行都放得下」在高度增長時恰好從假翻真一次。這個性質保證收斂,也保證在找到的高度上做最後一趟貪婪填充時每一行都被消化,而不是留下一行孤行、觸發一頁多餘的分頁。它也解釋了結果的一個細節:平衡帶矮到行盒允許的極限,不一定是等高欄,因為行不可分割,最後一欄可能比其他欄早結束一兩行

欄位跨越分頁時會發生什麼?

當前頁放不下的欄會依序填到頁底,只有最後一頁的片段被平衡。這是 CSS Multi-column Layout Level 1 §7.1 對分段情境下 column-fill: balance 的規定,也正是印刷通訊的樣子:除最後一頁外每頁都是滿欄,最後一頁把剩餘文字均分,而不是一條高欄旁邊陪著一條空欄

每個頁面片段都獨立走同一套分段器。如果剩餘的行裝得進可用高度的 N 欄,片段被平衡;否則每欄填到頁底,迴圈開新頁。整個容器的自然高度——引擎在 break-inside: avoid 決策與範圍量測時用的那個——是容量無限時的平衡帶高度,所以原本能以平衡塊的形式完整放進新頁的容器會整體搬過去,而不是被切開。單行比整頁還高時,引擎放寬約束,把這行照樣放在頁首,並以 multi-column 為主體回報一條 hdkPaginationConstraint 診斷

Delphi 中 HotPDF 為 HTML 轉 PDF 做的多欄分頁:最後一頁之前的片段把每欄填到頁底,最後一個片段跨欄平衡,column-span all 把容器切成圍繞全寬元素、各自平衡的欄帶
多欄容器只有最後一個片段被平衡;較早的頁面依序填充,每個片段獨立重複平衡、欄線與分頁

column-fill: auto 與固定高度方塊

設了 column-fill: auto 且有確定的 height 時,HotPDF 按順序填欄,每欄高度取剩餘頁面空間與「容器高度減去 padding 與邊框」兩者中較小者。這個最小值比看上去重要。只用剩餘頁面空間的話,只要頁面剩下的空間比方塊大,第一欄就會吞掉所有行。沒有確定高度時,column-fill: auto 退回平衡,與瀏覽器一致,因為不受約束的多欄容器沒有可以填到的欄高;第一版實作漏了這個退回,27 項迴歸檢查中的一項逮到了它

column-span: all 與巢狀段落

HotPDF 依 CSS Multi-column Layout Level 1 §6.1 支援多欄容器直接子元素上的 column-span: all。跨欄子元素把容器切成幾段:它之前的子元素組成一條欄帶,跨欄元素以全寬渲染,它之後的子元素開一條新欄帶。每條欄帶各自平衡、填充、畫線、分頁,所以三欄區塊中間的一個標題,產出兩條平衡欄帶夾著一個全寬標題——這正是人們在 mockup 裡實際畫的版面

走到那一步之前,得先修一個比較不顯眼的問題。像 <div style="column-count:2"> 帶幾個 <p> 子元素的容器,過去會把每個段落渲染成單一疊放的欄,因為每個段落被當成獨立的文字塊排版,父元素上的欄宣告從未生效。HotPDF 現在把巢狀區塊子元素的文字收集成容器的一條流。收集器這麼做時得在每個區塊邊界插入硬換行;跳過它,上一段的最後一個詞就會與下一段的第一個詞連在同一行——當測試文字恰好以標點結尾時,這種 bug 很容易漏掉

這條合併流的適用範圍是刻意的。只有整個子樹都是純文字流時才用它。容器內任何地方出現表單控制項、影像、表格、清單或 <hr>,就保持普通區塊路線、以單欄渲染,因為合併文字收集器表達不了它們的幾何。表格尤其有自己的分頁規則,見HotPDF 的 rowspan 處理與重複表格表頭一文,把它們混進欄帶會破壞那些保證

欄線畫在哪裡,以及怎麼測它

HotPDF 把 column-rule 畫在每條間隙的中線上,即欄的右緣加間隙的一半,從欄帶頂部畫到底部,每個頁面片段都是,如 CSS Multi-column Layout Level 1 §4 所述。寬度預設 1pt。顏色在未宣告或為 currentColor 時取元素文字色。不帶樣式關鍵字的 column-rule 簡寫按 solid 處理,與 border 簡寫的普遍讀法一致,而 none 與 hidden 抑制欄線

欄幾何不必剖析 PDF 就能觀察。THPDFHTMLImporter 上的 OnLineLayout 事件每畫一行就帶著一個 THPDFHTMLLineLayout 記錄觸發一次,而引擎逐欄分開繪製,所以每行的 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;

對著這個事件寫測試的兩條實務筆記。PageIndex 從 0 起算,這是手寫斷言第一個容易出錯的地方。另外 X 包含對齊位移,所以置中或靠右的文字要比較欄位置,請用靠左對齊的夾具。欄線本身不出現在事件裡;要斷言它的位置,請掃描未壓縮內容流裡的垂直 m 與 l 運算子

值得知道的限制與陷阱

有幾個行為是近似,在版面審查之前知道比之後知道好。合併欄流內部的段落間距,以每個區塊邊界一行容器行高來近似,所以欄帶內的逐段 margin 不會精確重現。column-span: all 切開容器時,每段重複容器的 padding 與背景方塊,段落之間視覺連續,但嚴格的 CSS 意義上不是單一方塊。除了 none 與 hidden 之外的每個 column-rule-style 值都通過驗證,但欄線一律以實線描繪

不支援的值不會靜默失敗。把 StrictCSS 設為 True 會把每條診斷變成 EHPDFHTMLUnsupportedCSS 例外,而 OnDiagnostic 讓批次作業把它們記進日誌、繼續跑。注意嚴格模式也適用於分頁診斷,所以行高超過一頁的文件在 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;

範本還在寫的階段,嚴格模式是正確設定,因為 column-span: both 這類打錯字會被立刻拒絕,而不是讓標題悄悄流進欄裡。上線之後,透過 OnDiagnostic 記日誌通常是更好的交易,因為客戶提供的 HTML 裡一條怪宣告不該賠上整份文件。相同的診斷(同種類、主體與值)在 MaxDiagnostics 清單裝得下時只回報一次,預設 256 條,所以層疊對幾百個元素重複拜訪的同一條宣告不會灌爆日誌

FAQ

HotPDF 支援 columns 簡寫嗎?

支援。columns 簡寫接受至多一個正整數欄數與一個欄長度,順序不限,auto 重置兩個長手屬性。解析出的值遵循與個別屬性相同的欄數與欄寬規則

多欄容器可以裝影像或表格嗎?

裝得下,但 HotPDF 隨後會以單欄渲染那個容器。欄流只在所有後代都是純文字流時生效,因為影像、表格、清單與表單控制項需要自己的方塊幾何與分頁規則

為什麼最後一頁是平衡欄,前面的頁面卻是滿欄?

這是分頁媒體中 column-fill: balance 的 CSS 規則:只有最後一個片段被平衡。較早的頁面把每欄填到頁底,文件中段就不浪費空間

多欄版面、HTML5 分頁媒體 profile 與其餘 HTML 轉 PDF 管線都是HotPDF Component for Delphi and C++Builder 的一部分,它還涵蓋直接 PDF 繪圖、表單、加密與文字擷取