PDF Library for Delphi 能把 HTML 轉譯進 PDF 頁面,實現真正的二維版面配置:display: flex 與 display: grid 會被量測並實際放置,而不是退化成堆疊的區塊,註腳則會保留在承載其參照的方框底部,其編號在跨欄與跨頁時都保持連續。進入點仍是熟悉的那些:DrawHTMLTextBox 用於單一方框,DrawHTMLStory 用於多欄流式排版
這件事之所以重要,是因為現在多數報表內容都是以 HTML 形式送來的。範本是由撰寫 CSS 的人設計的,儀表板是以卡片形式設計的,而一個把 flex 列悄悄壓成四個堆疊區塊的轉譯器,產生出來的文件跟設計完全不像。在這項能力出現之前,這套引擎唯一能量測的二維容器只有表格,因此每個卡片式版面配置都得手動改寫成表格
版面配置模型改變了什麼?
先前的主迴圈維護一個單一的行方框,並向下沿著頁面推進。這個模型能完美處理行內內容與堆疊區塊,卻無法表達一個子項尺寸彼此相關的容器。表格是唯一的例外,有自己一套兩階段量測
Flex 與 grid 各自為容器的子項新增了一個有邊界限制的量測階段,重點就在「有邊界限制」這幾個字。一個 flex 容器最多會把 256 個直接子項量測進一個固定陣列。一個 grid 則使用最多 64 乘 64 格的佔用矩陣,做確定性的自動放置。這些上限的存在,是為了讓惡意或產生出來的樣式表,無法引發無界的遞迴或二次方成長的放置記憶體,而當 HTML 來自客戶能自行編輯的範本時,這是一項真實的顧慮
flex 項目如何取得自己的尺寸
在列方向上,容器會加總每個項目的基準值,連同其伸縮與收縮權重,再依這些權重分配剩餘空間(正值或負值皆可)。搭配 flex-wrap 時,每一行會被獨立求解,因此一列拆成兩行時,是逐行分配剩餘空間,而不是跨整個容器分配。在欄方向上,同樣的主軸分配邏輯,會針對明確指定的高度或內容高度來執行
justify-content、align-items、gap 與反向排列方向,都是在已經量測完成的幾何上運作。它們只移動方框,絕不會觸發重新量測項目內容。正是這種分離,讓一個複雜的儀表板不必把它的子項重複量測好幾遍
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 繪圖進入點回報「什麼沒放下」的方式。把它傳給下一個方框或下一頁,流式排版就會從中斷的地方繼續
Grid 放置方式,以及軌道能是什麼
Grid 軌道接受固定長度、百分比、fr 單位、簡單的 repeat() 運算式與 minmax()。自動放置會以確定性方式填滿佔用矩陣,因此同一份 HTML 每次都會產生同樣的排列。明確座標允許重疊,這是刻意設計:一個把徽章疊在卡片上的設計,表達的是意圖,而不是錯誤。只明確指定一個軸時,放置只會在另一個軸上搜尋
跨越多列的項目,會把自己量測到的高度平均貢獻回它所涵蓋的那些列,這避免了一個跨列的高項目壓縮單獨一列,卻讓它的鄰居維持矮小:
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 項目內部的行為,都與在頂層完全一致,外層的版面配置計畫會記錄最終的文字與矩形繪製指令,因此重複繪製會重複利用既有的量測快取
為什麼註腳是一個分頁問題?
註腳不是跟在含有其參照的段落後面流動的內容;它必須出現在含有其參照的同一個方框底部。這把慣常的量測順序反轉了過來,因為現在本文可用的空間,取決於一段尚未排版的內容
轉譯器因此會在遇到參照的當下量測該註腳,並從目前有邊界方框的本文高度預算中扣除該註腳所佔的面積。若參照、目前為止的本文與該註腳無法全部放下,註腳標記與其後所有內容會一起移入延續字串。這條規則正是防止兩種典型失敗的關鍵:一是註腳蓋住本文,二是註腳被困在一頁上,而它的參照卻在前一頁
在有邊界的方框中,註腳區域會被釘在底部,上方有一條分隔線。在無邊界的量測情境下,因為沒有方框高度可以釘住,註腳區域就緊接在本文之後。編號被承載在延續堆疊的一個延伸欄位中,因此 DrawHTMLTextBox 與 DrawHTMLStory 能讓序號跨欄與跨頁持續運作,而在這個欄位存在之前產生的延續字串,也仍能正確地繼續運作
// 多欄故事內的註腳保持同一組連續序號
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 的 grid 是一張試算表,同樣的建議也適用。對於多欄本文,欄與斷字行為說明於 斷字與均衡多欄文字 一文,它決定了每欄內部流式排版的呈現方式
當版面配置必須恰好裝下內容時,先量測再繪製。GetHTMLTextHeight 會回報給定寬度所需要的高度,這是在下筆之前,決定要用哪種版面配置最省事的方法。並把非空的延續字串當成常態而非例外:它正是長內容賴以分頁的機制,而不是錯誤訊號
當 HTML 來自報表引擎而非手寫範本時,資料集報表引擎 一文所述的路徑與這項功能搭配得很好,能產生出交給 flex 與 grid 排版的標記。而當同一份內容也需要再從 PDF 匯出時,把 PDF 匯出為 Markdown 與 DOCX 一文所述的語意匯出路徑,能閉合這條往返路徑
HTML 版面配置、報表產生與語意匯出,都是同一套適用 Delphi、C++Builder 與 Free Pascal 函式庫的一部分;完整功能清單列於 PDF Library for Delphi 頁面