技術文章

Delphi 中的 HTML 轉 PDF:HTML5 分頁媒體路徑

HotPDF 透過 THPDFHTMLImporter.RenderHTML5 將 HTML 轉換為 PDF 頁面,這是一套具版本編號的分頁媒體(paged-media)設定檔,擁有真正的 DOM、真正的 CSS 層疊、表格、分頁控制,並可選擇從 HTML 表單控制項自動產生 AcroForm。它完全在行程內執行:沒有瀏覽器引擎、沒有 Chromium、沒有外部轉換器,預設情況下也完全不存取檔案或網路

之所以另設一個進入點,而不是直接改良 RenderHTML,原因在於相容性。舊方法多年來一直在正式環境中渲染發票與信件,而寬鬆的 DOM 搭配完整層疊會改變那些依照舊行為調校過的文件輸出結果。因此舊方法保留其確切語意,所有新功能都放在 RenderHTML5 之後,其設定檔版本可由 HTML5ProfileVersion 取得,支援的里程碑則列在 HTML5ProfileMilestones 中

HTML5 設定檔實際新增了什麼?

實務上有四項能力最重要:html 與 body 作為容器而非文字節點的結構化 DOM;優先順序正確的 CSS 層疊;表格版面配置;以及明確的分頁控制,這正是「分頁媒體」的意義所在——HTML 原本是為可捲動的檢視區設計,要把它變成分頁內容,就必須決定內容允許在哪裡斷開

容器這一點聽起來像實作細節,實際上卻是能不能正常運作的分水嶺。若根元素被當成文字容器處理,整個 DOM 在剖析階段就會塌陷成一整塊文字,之後的每一條樣式表規則、每個分頁點、每個表格與表單子樹都會作用在空無一物之上。任何把結構化文件轉成一整片不加區分文字牆的 HTML 渲染器,犯的都是這個錯誤

層疊順序,逐一說明

CSS 優先順序不是單一排序。HotPDF 會先依特異性、再依來源順序,套用符合規則中的一般宣告,接著套用行內一般宣告,然後套用符合規則中的 !important 宣告,最後套用行內 !important。共四個階段,順序如上

HotPDF 在 Delphi 中透過四個依序執行的層疊階段解析每個 CSS 屬性,從一般規則比對一路到行內 !important 宣告
後面的階段刻意贏得平手:行內宣告的優先順序高於樣式表,!important 又高於一切一般宣告

把這個過程簡化成「後面覆蓋前面」的實作,在常見情況下是對的,在有趣的情況下卻是錯的,而這比一律做錯還糟糕,因為這種錯誤只會出現在使用 !important 來覆蓋基礎樣式表的範本中——恰好就是那些期望層疊機制能正常運作的作者所寫的範本:

uses
  HPDFDoc, HPDFHTMLImport;

var
  Pdf: THotPDF;
  Importer: THPDFHTMLImporter;
  Html, Css: WideString;
begin
  Pdf := THotPDF.Create(nil);
  Importer := THPDFHTMLImporter.Create(Pdf);
  try
    Pdf.FileName := 'statement.pdf';
    Pdf.BeginDoc;

    Importer.Margin := 56;              // points
    Importer.BaseFontName := 'Arial';
    Importer.BaseFontSize := 10;
    Importer.CreateAcroForms := False;  // render controls,不建立 fields

    Css := '@page { margin: 56pt } ' +
           'table { border-collapse: collapse } ' +
           'tr.total { page-break-inside: avoid; font-weight: bold }';
    Html := LoadTemplate('statement.html');

    if not Importer.RenderHTML5(Html, Css) then
      raise Exception.Create('nothing was emitted - check the markup');

    Pdf.EndDoc;

    Writeln(Format('profile=%d nodes=%d rules=%d layout ops=%d',
      [Importer.Statistics.ProfileVersion,
       Importer.Statistics.ParsedNodeCount,
       Importer.Statistics.CSSRuleCount,
       Importer.Statistics.LayoutOperationCount]));
  finally
    Importer.Free;
    Pdf.Free;
  end;
end;

第二個引數是一份作者樣式表,會與文件本身攜帶的樣式並行套用,這是在不必修改每個範本的前提下,強制套用頁邊界、孤行控制等內部規範的乾淨做法

為何匯入器從不擷取任何外部內容?

一個會解析外部參照的 HTML 轉 PDF 轉換器,本質上就是一台披著文件產生外衣的伺服端請求偽造(SSRF)引擎。把它指向使用者提供的標記內容,它就會欣然擷取 http://169.254.169.254/,或是你應用程式伺服器上的某個檔案 URL,並把結果嵌入某人即將下載的 PDF 之中

因此 HotPDF 預設既不讀取檔案也不存取網路。影像與其他資源只能透過兩種方式送達:以 data: URI 形式內嵌在標記中,或透過 OnResource 回呼,由你的程式碼決定某個 URI 代表什麼並提供對應的位元組:

HotPDF 在 Delphi 中的 HTML 匯入器只在本機解析每個資源 URI,接受 data URI 與 OnResource 回呼,並在數量與位元組上限之後拒絕檔案與網路擷取
只有在本機送達的位元組才會進入頁面,被拒絕的參照會累加計數器,任何擷取都不會離開該行程
procedure TReportForm.SupplyResource(Sender: TObject;
  const URI: WideString; ResourceKind: THPDFHTMLResourceKind;
  var Data: TBytes; var ContentType: string; var Handled: boolean);
begin
  Handled := False;
  // 只從列入 allow-list 的本機資產目錄提供服務,永遠不要
  // 將 URI 傳給 HTTP client,也不要把它接到路徑上
  // 未先驗證前不要這麼做
  if StartsText('asset:', URI) then
  begin
    Data := LoadAssetBytes(Copy(URI, 7, MaxInt));
    ContentType := 'image/png';
    Handled := Length(Data) > 0;
  end;
end;

每個確實送達的資源,仍會計入三道獨立上限:資源數量、每個資源的位元組數,以及位元組總數。一個忠實回傳所要求內容的回呼,不會因為文件把同一張 50 MB 的影像參照兩百次,就被誘導耗盡記憶體

四道額度,同一種失敗模式

剖析器節點、CSS 規則、版面配置運算與資源,是各自獨立計量的,因為它們也是各自獨立失敗的。一份文件可以體積很小,卻在版面配置上異常昂貴;另一份文件可以結構很淺,卻搭配一套刻意讓選擇器比對爆炸的樣式表。任何一項額度耗盡都會引發 EHPDFHTMLImportBudget,這是一個專屬例外類別,讓額度耗盡可以與格式錯誤的輸入分開捕捉

統計資料會在每次呼叫 RenderHTML5 時重設,因此這些數字描述的是單次匯入,而不是匯入器物件的整個生命週期。在一個渲染不受信任範本的服務中,請針對每份文件記錄 ParsedNodeCount、LayoutOperationCount 與 ResourceRejectCount:拒絕計數持續上升,通常代表範本作者參照了你的回呼無法識別的資源,而版面配置運算計數逼近上限,則代表下一份稍大一點的文件就會失敗

在 HTML 匯入與版面配置 API 之間如何選擇

當內容是由非 Delphi 開發者以 HTML 撰寫、範本變動頻率高於程式碼、或同一份內容也需要以電子郵件寄送時,HTML 匯入是較好的選擇。而具標記輸出的宣告式 PDF 版面配置一文所描述的宣告式版面配置樹,則在文件是由你自己的程式碼依資料產生、需要自動輸出 PDF/UA 結構,或分頁的精確控制比撰寫便利性更重要時勝出

兩者並非互斥。常見的做法是報告本文採用 HTML 匯入,而頁首、頁尾與簽名區塊則直接繪製,因為這些屬於固定幾何配置,不該由任何範本掌控。表格是特別值得兩種方式都實測比較的一個領域:複雜的財務表格有時透過在 PDF 中產生表格一文所述的專用表格路徑,分頁效果會更好

CreateAcroForms 應審慎設定。關閉時,HTML 表單控制項會渲染為靜態圖稿,這正是列印用的對帳單所需要的效果。開啟時,它們會變成真正可互動的 AcroForm 欄位,這正是可填寫表單所需要的效果,後續的欄位行為則涵蓋在為已載入的 PDF 新增表單欄位一文中

HTML 匯入、宣告式版面配置與直接繪圖 API,在 Delphi 與 C++Builder 中都指向同一個文件物件;完整功能清單請見HotPDF Delphi PDF 元件頁面