技術文章

在 Delphi 中以宣告式 PDF 版面配置產生標記輸出

HotPDF 可以從宣告式樹狀結構建置分頁文件,而不必靠座標。你把區段、堆疊、文字、清單與表格組裝成一個 THPDFDOMDocument,交給 THPDFDOMRenderer,轉譯器會負責量測、分頁、繪製頁面裝飾元素,並在需要時輸出讓成品具備無障礙功能的 PDF/UA 結構樹。版面配置程式碼從不需要計算任何 y 座標

任何維護過座標驅動式報表產生器的人都明白這為什麼重要。第一版程式能動。接著客戶地址長成三行、表格新增了列、在地化後的標題換行了,於是所有下游的 y 座標全都錯了。修補會逐漸累積成散落在業務邏輯各處的手動換頁檢查,而兩年後才出現的標記化 PDF 需求,根本無法補套在一段連段落是什麼都不知道的程式碼上

這棵樹擁有什麼,以及為何所有權要如此嚴格?

這個 DOM 在每個層級都強制單一擁有權:文件擁有其區段,區段擁有其主體、頁首與頁尾,堆疊、容器與表格擁有其子項。重複使用要透過 Clone 或已註冊的工廠來達成,絕不能把同一個物件掛到兩個父節點下。這條規則並非形式上的講究。若某個元件在樹中出現兩次,就會被套用兩種不同的限制條件量測兩次,並在拆卸時被釋放兩次

對呼叫端程式碼而言,實際的後果是輔助函式回傳的是新的實例。透過 RegisterComponent 註冊一個工廠並呼叫 CreateComponent,會給你一份具名配方,每次呼叫都會產生一個全新的元件,這正是簽名區塊或法律頁尾之類重複出現的裝飾元素該歸屬於這棵樹的方式

uses
  HPDFDoc, HPDFLayoutDOM;

var
  Doc: THPDFDOMDocument;
  Section: THPDFDOMSection;
  Table: THPDFDOMTable;
  Row: THPDFDOMTableRow;
  I: Integer;
begin
  Doc := THPDFDOMDocument.Create;
  Doc.GenerateStructure := True;        // 輸出 PDF/UA 結構樹
  Doc.Language := 'en-US';

  Section := Doc.AddSection;
  Section.PageWidth := 595;           // A4 尺寸,單位為點
  Section.PageHeight := 842;
  Section.MarginLeft := 56;
  Section.MarginTop := 56;
  Section.MarginRight := 56;
  Section.MarginBottom := 56;
  Section.Style.FontName := 'Helvetica';
  Section.Style.FontSize := 10;

  Section.Body.AddHeading('Annual maintenance report', 1);
  Section.Body.AddText('Every asset inspected during the reporting ' +
    'period is listed below, grouped by site.');
  Section.Body.AddSpacer(12);

  Table := THPDFDOMTable.Create('assets');
  Table.AddColumn(3);                 // 權重,而非絕對寬度
  Table.AddColumn(1);
  Table.AddColumn(1);
  Table.RepeatHeaders := True;
  Row := Table.AddRow(18, True);      // 標題列
  Row[0].Text := 'Asset';
  Row[1].Text := 'Last service';
  Row[2].Text := 'Status';
  for I := 0 to High(Assets) do
  begin
    Row := Table.AddRow(16);
    Row[0].Text := Assets[I].Name;
    Row[1].Text := Assets[I].ServiceDate;
    Row[2].Text := Assets[I].Status;
  end;
  Section.Body.Add(Table);
end;

分頁如何避免二次方成本?

最天真的分頁做法,是複製任何裝不下的內容並帶到下一頁。在一張有一萬列的表格上,這會導致每頁都要複製一次剩餘的列,把一份線性成長的文件變成二次方成長

HotPDF 改採更精細的拆分方式。頂層轉譯器依索引走訪主體子項,從不複製整個區段或主體。只有真正橫跨頁面邊界的巢狀堆疊與容器,其受影響的子樹才會被複製,而兩種重量級的葉節點類型則各自帶著一個游標而非一份副本:文字延續段儲存它還欠下的原始字元範圍,表格延續段儲存它尚未放置的列切片。長文件維持線性成本,長段落無論斷成一段還是五段,成本都相同

量測過程對副作用保持誠實。THPDFLayoutElement.Measure 被要求不得有任何繪圖副作用,實際的放置一律透過 THotPDF.PlaceLayoutElement 這個中央程序執行,該程序會重新量測已放置的片段、建立溢位所有權並記錄診斷資訊。DOM 轉譯器只決定何時開新頁的策略、頁面裝飾元素、間距與延續段的生命週期

避免無窮文件的表格標題規則

讓表格標題在跨頁時重複顯示,聽起來很單純,卻隱藏著兩種失敗模式。HotPDF 要求標題列只能出現在連續列中的第一段,且第一次拆分必須能容納所有標題列加上至少一列內容列。若沒有第二條規則,一個比剩餘空間還高的標題,會產生一頁只有標題、後面接著另一頁一模一樣的標題,無窮無盡

延續頁會重新繪製標題,而那份重繪的副本會被標記為裝飾內容而非正式內容,這是無障礙功能與文字擷取兩方面都正確的答案。原始標題列在邏輯表格結構中,恰好只保留一份。若跳過這一步,螢幕報讀器會在資料的中間再讀一次欄位標題,文字擷取工具則會在內容列之間插入重複的標題列

延續深度還有一道防禦性上限,因為第三方元件完全有可能把 Split 實作成永遠回傳一個等效的尾段。轉譯器會在切離尾段之後、開始下一頁之前檢查這個上限,而目前的迭代會在自己的 finally 區塊中釋放尾段,因此行為異常的第三方元件會以可診斷的錯誤失敗,而不是把磁碟塞滿

一個邏輯元素、多個頁面片段

自動標記正是分頁模型與結構模型必須達成一致的地方。一個橫跨兩頁的段落,仍是同一個邏輯段落,因此必須維持為單一結構元素。但標記內容識別碼是逐頁的,所以每個可見片段都需要在其所在的頁面上有自己的 MCID

HotPDF 的解法是維持單一結構元素,並為每個片段在其 /K 陣列中附加一筆標記內容參照,以 /Pg/MCID 這一對值標識頁面與識別碼。該 MCID 所對應的 ParentTree 欄位會指回同一個元素。這正是 ISO 14289 所要求的做法,也是延續複本與一般複本不同的原因:一般的 Clone 代表新的邏輯內容,會取得新的語意身分,而內部的延續複本則繼承它所延續的那個元件的身分

元素重用是透過一個依元件指標排序、以二分比對搜尋的語意身分索引來查詢,這讓大型樹上的查詢維持對數時間複雜度。這個索引只保存非擁有型的參照;結構物件本身的生命週期仍歸屬於 PDF 物件圖

轉譯器預先強制執行的結構規則

啟用 GenerateStructure 後,多項 PDF/UA 規則會在樹被轉譯的當下就被檢查,而不是等到檔案已經產生完畢之後。標題層級從第 1 級開始,不得跳級。LI 只能出現在 L 之內,LblLBody 只能出現在 LI 之內。TR 屬於表格,THTD 屬於列。在 PDF/UA 模式下,沒有替代文字的圖形會被拒絕

提早拒絕是這裡刻意做出的選擇。等文件產生完畢後才回報缺少替代文字的驗證工具,只能告訴你一批一萬份的對帳單需要重新產生;而拒絕該元件的轉譯器,能在產生它的資料仍在作用範圍內時,直接告訴你是哪個元件出了問題。合規驗證仍然應該作為流程中獨立的一個步驟,其機制說明於 PDF/A、PDF/X 與 PDF/UA 驗證 一文

var
  Pdf: THotPDF;
  Renderer: THPDFDOMRenderer;
  Stats: THPDFDOMRenderStatistics;
begin
  Pdf := THotPDF.Create(nil);
  Renderer := THPDFDOMRenderer.Create;
  try
    Pdf.FileName := 'maintenance-report.pdf';
    Pdf.BeginDoc;
    Stats := Renderer.Render(Doc, Pdf);
    Pdf.EndDoc;

    Writeln(Format('%d page(s), %d placement(s), %d split(s)',
      [Stats.PageCount, Stats.PlacementCount, Stats.SplitCount]));
    Writeln(Format('structure elements=%d marked content=%d artifacts=%d',
      [Stats.StructureElementCount, Stats.MarkedContentCount,
       Stats.ArtifactCount]));
    Writeln(Format('deepest continuation chain: %d',
      [Stats.MaximumContinuationDepth]));
  finally
    Renderer.Free;
    Doc.Free;
    Pdf.Free;
  end;
end;

這份統計紀錄比乍看之下更有用。SplitCount 在樣板變更後大幅上升,通常代表某個元件量出來的高度突然變得比它的容器還高。MaximumContinuationDepth 逐漸攀升,則是某個元件的 Split 每頁進展太少的早期警訊。而比對 ArtifactCount 與延續頁的數量,能確認重複的標題確實被標記為裝飾內容

DOM 如何與直接 API 並存

DOM 並不會取代直接繪圖,它是架在同一套頁面物件之上。轉譯器放置的任何內容,都能與 THotPDF 上的直接呼叫交錯使用,這在報表需要一個手動定位在精確位置的元素(例如一張簽名影像)時很重要。頁面關閉仍由 AddPageEndDoc 控制,因此立即清除模式不會把已完成的頁面留在記憶體中,常駐記憶體用量仍受目前的延續段、字型資源與一般文件物件圖所管控

當內容是資料驅動、版面配置是規則驅動時,選用 DOM;固定的美術元素則保留直接繪圖。若你目前的痛點特別集中在表格分頁,在 PDF 中產生表格 一文所述的較窄範圍做法值得先讀,而諸如對齊排版之類文字層級的行為,說明於 文字對齊排版 一文

宣告式版面配置、自動標記與直接繪圖 API,都在同一個適用 Delphi 與 C++Builder 的元件中提供;完整功能清單列於 HotPDF Delphi PDF 元件頁面