技術文章

HotPDF 的 HTML 表格 rowspan 佔用格與跨頁標頭

HotPDF 透過它的 HTML5 分頁媒體設定檔算繪 HTML 表格:用真正的佔用格網格處理 rowspan 與 colspan,用實測列高取代字元數估算,並在每一張續頁重複標頭列。有兩種情況它會拒絕重複標頭,事先知道,比事後除錯一個被複製兩次的儲存格便宜

會逼出這些需求的文件類型,是每個報表團隊遲早要出貨的那種:發票或合規報告,事實來源是 HTML,表格跑過四頁,而標頭必須在每一頁都讀得清楚。只要不到「真正的表格版面」這個水準,立刻出現讀者一眼就看到的兩種失敗:標頭只在第一頁出現一次,以及用字元數猜出來的列高

表格能力為什麼搬進了 HTML 算繪器?

因為另一條路會把豐富文字丟掉,而內容之所以是 HTML,本來就是為了豐富文字。看起來順理成章的方案像是重用:HotPDF 已經有一個帶正規網格的版面 DOM 表格物件,把 HTML 解析器橋接過去,跨欄跨列就免費到手。問題在那個表格物件用什麼畫。它的儲存格只帶文字與一份樣式,繪製路徑輸出的是純文字,於是 HTML 裡真正含有的、字型與顏色之外的任何東西——連結、上標、行內字級變化、逐段 run 的顏色——抵達頁面之前就全部沒了

跟真實文件正面交鋒還能活下來的,是反過來的方向。把表格引擎的能力——佔用格網格、實測、標頭重複、欄位加權——搬進 HTML 算繪器,豐富文字算繪留在它已經運作良好的地方。這是比橋接更大的改動,也是讓表格儲存格裡的超連結依然是超連結的那個改動

不需要 union-find 的 rowspan

跨列儲存格造出原子性的列群組,但對這些群組取閉包不需要一般化的 disjoint-set 結構,因為佔用永遠是一段連續區間。一個 rowspan="3"、從第 K 列開始的儲存格,佔的就是第 K 到 K+2 列,不多不少,所以群組資訊可以化約成每列一個結束標記

演算法的意圖只有兩行。放置一個從 K 開始、到 E 結束的跨列儲存格時,記下 GroupEnd[K] := Max(GroupEnd[K], E)。接著把列反向走訪一次,套用 G[R] := G[G[R]],讓每一列的結束往回傳過重疊的 span,一趟就得出遞移閉包。結果是,對每一列都能答出「必須跟它同頁的範圍到哪一列為止」,而這正是分頁步驟決定斷點可以落在哪裡所需的全部

分配高度是另一半。當跨列儲存格需要的垂直空間超過它涵蓋那幾列目前提供的總和,多出來的部分全部進到 span 的最後一列,不是平均攤。先把普通列高搞定,再處理跨列儲存格,替每個 span 的最後一列補足。平均分攤看起來比較公平,產出的卻是肉眼可見的錯誤:只裝著短小單行儲存格的列被撐胖了,只因為三列之上某個不相干的儲存格碰巧很高

HotPDF HTML 表格網格:一個 rowspan 3、從第 2 列開始的儲存格,以單一原子矩形佔住第 2 到第 4 列;旁邊是一次反向走訪得出的每列群組結束值 G[R],顯示第 2、3、4 列被綁在同一頁
跨列佔用永遠是連續區間,所以每列結束標記加一次反向走訪就取代了 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 的第二個參數收一份選用的作者樣式表,列印規則就該放這裡,螢幕樣式表別混進來。設定檔有版本管理,HTML5ProfileMilestones 會回報目前建置實作了哪些能力群組——himParserCascadehimPagedLayouthimTablesFormshimBoundedResources——應用程式因此可以刻意降級,而不是在正式環境才發現缺口

測量必須與繪製完全一致

只有當測量換行的程式碼與繪製換行的程式碼按同一套規則換行,列高才是對的。這話聽起來像廢話,卻是「表格框線跟內容對不齊」最常見的單一根源。HotPDF 用一個貪婪式的行數計數器做測量,這個計數器必須在三個具體方面與豐富文字輸出路徑的換行語意一致:只在空格處斷行、絕不切開一個單詞,以及比欄位還寬的單詞獨佔一行

第二個要求是字型。測量必須用儲存格自己的字型跑:呼叫寬度函式之前,先透過 SetFont 把實際名稱、樣式集與字級設好,而不是用當下湊巧在作用的字型。同樣字級下,粗體通常比 regular 寬超過一成,足以把三行的儲存格變成四行。標頭儲存格是粗體、內容儲存格不是的表格,若只用單一字型測量,錯的恰好是讀者最先看的那幾列

把這件事做對,會改變您能在測試裡斷言什麼。精確測量的可觀察效果是行距,不是字形數:單行列的列高約 20 點,而同一內容的字元數估算會預測兩行、約 35 點。請對列與列之間的垂直距離下斷言。並且記得 PDF 使用者空間的 Y 軸向上遞增,標頭在內容列上方,代表標頭的 Y 值比較大——跟螢幕座標的直覺剛好相反

HotPDF 什麼時候拒絕重複標頭?

兩種情況,硬做的話都會產出肉眼可見的錯誤輸出。第一種是標頭區塊裡有跨列儲存格越過標頭、伸進內容列。重複標頭等於把那格內容在不屬於它的位置再畫一次,所以標頭只畫一次,表格沒有它照樣往下走。第二種是標頭高度超過可用頁高的九成,重複它幾乎不留資料空間,表格也無法向前推進

HotPDF 跨頁重複 HTML 表格標頭的決策流程:rowspan 跨進內容列的標頭只畫一次,高度超過可用頁高九成的標頭只畫一次,其餘標頭在每張續頁重複
兩種拒絕都是刻意的:重複一個擁有跨列內容儲存格、或佔掉大半頁面的標頭,會把內容畫到不再屬於它的位置,或幾乎不留資料空間

兩種拒絕都是刻意的、也是設計上的安靜,因為另一個選項更糟。如果您的標頭該重複卻沒重複,先檢查標記裡有沒有 rowspan 跨過 thead 邊界,再來懷疑引擎。光這一種標記模式,就解釋了絕大多數的意外

// 欄位權重來自標記,所以列印樣式表就是控制它們的地方。
// 寬度被當成權重對待,不是像素
const
  PrintStyleSheet =
    'table { width: 100%; }' +
    'thead th { font-weight: bold; background: #eee; }' +
    'td.amount { text-align: right; }';

// 帶著 rowspan 跨進 body 的標頭列會讓標頭重複失效。
// 把跨欄跨列留在同一個區段裡:
//   <thead><tr><th rowspan="2">Item</th>...</tr></thead>  可以
//   <tr><th rowspan="3">Item</th>...  跨進 tbody,不重複

欄寬的行為像權重,不是絕對量測,這正是內容跟作者估算不符時表格還用得住的原因。宣告 30% 的欄位會拿到可用寬度的大約三成,但分配會尊重每個欄位實際需要的最小寬度,所以裝著一個長得切不開的 token 的窄欄,不會無聲無息地溢出表格框

它在文件管線裡的位置

表格工程是更大的分頁媒體設定檔的一部分,HTML5 分頁媒體匯入路徑描述的分頁規則、資源預算與 CSS 處理,對含表格的文件原樣適用。如果您的資料起點不是 HTML,直接把表格建進 PDF的直構路線完全跳過解析層,用 API 給您同一套網格行為。又因為列高最終取決於斷行位置,文字對齊與斷行裡的測量討論,是任何在調密集表格輸出的人的姊妹篇

這裡可以帶走的教訓根本不在表格。當一個新子系統需要一個舊子系統已經有的能力,先問:兩者之中,誰擁有那個最難重寫的東西。網格算術就幾十行,搬家很容易;帶行內連結、上標與逐 run 樣式的豐富文字算繪不是,所以網格搬了、文字留下。HotPDF 把兩條路線都放進 HotPDF Delphi PDF component 一起出貨,HTML 輸入還是直接建構,是專案層級的決定,不是函式庫層級的