技術文章

在 Delphi 中跨文件重複使用 THotPDF 實例

一個 THotPDF 實例可以一口氣寫出任意多份文件:EndDoc 之後再 BeginDoc,開始的是一份全新、獨立的文件。單一實例的多文件重複使用,自 HotPDF v2.764.0 起可用。訊息 Please load the document before using BeginDoc 則屬於隔壁的另一道防線:LoadFromFile 在一份以 BeginDoc 開始、還沒用 EndDoc 關閉的文件上會拋出它。兩種行為都是元件生命週期的必然結果,想通這一層,兩者就都不再神秘

Delphi 中的 THotPDF 重複使用:自 v2.764.0 起,一個實例在單次 Create 與 Free 之間,為每個輸出檔案執行 BeginDoc、繪製與 EndDoc;v2.764.0 之前的版本則每個檔案都需要新實例
每份文件都執行 BeginDoc、繪製、EndDoc;自 HotPDF v2.764.0 起,一個 THotPDF 實例可以在 Free 之前重複這個循環,較舊的版本則每個檔案都需要新實例

THotPDF 實例是一份文件,還是文件工廠?

THotPDF 實例是一個文件工廠:您可以只建立一次——例如做成拖到表單上的元件——然後像保持資料庫連線開啟、透過它一個查詢接一個查詢那樣,餵給它一份又一份文件。BeginDoc 開啟一份文件並將實例標記為有正在進行的文件。EndDoc 把所有內容序列化到 FileName,接著丟棄為該檔案註冊的一切,包括它的圖片、字型、表單欄位、書籤大綱、文件 JavaScript、命名目的地、頁面標籤、ICC 設定檔與輸出意圖。已寫出文件的頁面與物件在下一次 BeginDoc 釋放它們之前仍可讀取,因此 ValidatePDFUA2Structure、CurrentPageNumber 與 PagesCount 在 EndDoc 之後仍然可用。開啟 ReproducibleOutput 時,重複使用的實例所寫出的第二份文件,與新實例寫出的同一份文件逐位元組相同

生命週期,以及它必須發生的順序

HotPDF 從頭開始寫入的每一份文件都遵循相同的四個步驟,並且順序不可協商。Create 配置元件。BeginDoc 開啟文件並固定結構選項,因此任何影響整個檔案的設定(頁面大小、壓縮、加密、輸出檔名)都必須在 Create 和 BeginDoc 之間設定。然後您進行繪製。接著 EndDoc 將位元組寫入磁碟。Free 釋放實例。放置在 BeginDoc 之前的繪圖呼叫沒有可落腳的頁面;在它之後指派的全文件屬性則會被靜默忽略。在重複使用的實例上,FileName、Compression、PDFACompliance、Author 與 Title 這類屬性設定會延續到下一份文件,因此若有不同,請在每次 BeginDoc 之前重新設定

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // 開啟文件
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // 寫出 invoice.pdf,並將其關閉
  finally
    Pdf.Free;                            // 一個實例,一份文件
  end;
end;

把這段讀成工作單元:一次 BeginDoc、一次 EndDoc、磁碟上的一個檔案。第二個檔案就是第二組 BeginDoc 與 EndDoc,跑在同一個實例或新實例上都可以

「重複使用」的含義:一個實例,還是每個檔案一個新實例

節儉的版本行得通:建立元件一次,在批次上跑迴圈,並在迴圈內部呼叫 BeginDoc 和 EndDoc。每次迭代都產出一份獨立的檔案,為某份文件註冊的東西不會跑到下一份。受保護輸出仍有一個陷阱:EndDoc 會清空 UserPassword 與 OwnerPassword,因此若 ActivateProtection 仍開著,您必須在下一次 BeginDoc 之前重新設定密碼,否則下一次 BeginDoc 就會直接拋出例外,而不是用空密碼去保護檔案。下面的版本改走另一條路,把每個輸出當成自己短暫的物件。相對於佈局與序列化 PDF 的工作,建立元件的配置成本微不足道,所以無論哪種方式,囤積實例都省不了多少

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // 每次迭代都建立新實例
    try
      Pdf.FileName := Names[I] + '.pdf';
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 12);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Statement for ' + Names[I]);
      Pdf.EndDoc;
    finally
      Pdf.Free;
    end;
  end;
end;

位於迴圈內部的 try/finally 是在審查中值得捍衛的部分。如果 BeginDoc 或任何繪圖呼叫在一份文件中途引發例外,該迭代的實例仍會在下一次開始之前被釋放,因此一筆不良記錄不會讓一個建置一半的元件滯留並毒害剩餘的執行過程。把 Create 拉到迴圈上方也行得通,但一份被例外中途拋棄的文件,仍然是讓下一筆記錄改用新實例的好理由

修改現有檔案是不同的進入點

「重複使用」還有第二種完全合理的解讀:您不想要一份空白文件,您想開啟一個已經存在的 PDF 並對其進行更改。這條路徑根本不經過 BeginDoc,這正是錯誤訊息中提到載入的原因。您載入檔案,編輯它,並以您選擇的任何名稱儲存

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('contract.pdf');
    if PageCount > 0 then
    begin
      Pdf.CurrentPage.SetFont('Arial', [fsBold], 10);
      Pdf.CurrentPage.TextOut(40, 30, 0, 'REVIEWED');
      Pdf.SaveLoadedDocument('contract-reviewed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

LoadFromFile 傳回頁數,零或更小的值表示載入失敗,因此在您觸碰 CurrentPage 之前值得先檢查一下。配對很重要:您使用 LoadFromFile 開啟的文件要使用 SaveLoadedDocument 儲存,而不是使用屬於從無到有編寫文件的 BeginDoc/EndDoc 配對。把兩者混著用,最容易踩響本文開頭描述的那道生命週期防線。在心理上將這兩個流程分開:BeginDoc ... EndDoc 用於建立,LoadFromFile ... SaveLoadedDocument 用於編輯

檔案鎖定問題是真實的,而解決方案不是強制關閉檢視器視窗

生命週期錯誤往往伴隨著第二個抱怨,兩者會糾纏在一起,因為它們出現在同一個「重新產生檔案」的工作流程中。使用者開啟您剛產生的 PDF,讓它在 Acrobat 或 Foxit 中保持開啟狀態,然後觸發重建。EndDoc 嘗試寫入相同的路徑,作業系統拒絕,因為檢視器持有一個阻擋寫入者的讀取共用,接著您會得到一個存取被拒的失敗。這確實是一個 Windows 檔案鎖定問題,而不是元件狀態問題,它值得真正的解法,而不是治標的權宜之計

流傳的治標方法,例如列舉頂層視窗並將 WM_CLOSE 發布給任何標題看起來像 PDF 檢視器的視窗,是一種錯誤的直覺。它跨越行程邊界來關閉不屬於您程式的視窗,它透過標題文字猜測檢視器,而且它可能會在不詢問的情況下丟棄使用者未儲存的註解。請將這整個方法視為一種程式碼異味 (code smell)。可靠的修復方法是永遠不要寫入另一個行程可能持有的路徑。序列化到同一個目錄中的暫存檔,然後在 EndDoc 成功後透過原子重新命名將其置換到位。如果檢視器仍然開啟舊檔案,重新命名要麼乾淨地成功,要麼大聲失敗,您就可以顯示明確的訊息,而不是與鎖定對抗

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // 暫存檔要放在跟目標檔案相同的目錄:在同一個
  // NTFS 磁碟區內重新命名能原子性地置換名稱,
  // 而跨磁碟區的搬移會退化成複製加刪除,失去這個保證
  TempPath := TPath.Combine(TPath.GetDirectoryName(FinalPath),
    TGUID.NewGuid.ToString + '.pdf.tmp');
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.FileName := TempPath;
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 11);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
      Pdf.EndDoc;                    // 此時暫存檔已完整寫入磁碟
    finally
      Pdf.Free;
    end;

    // 置換到位。TFile.Move 拒絕覆寫,所以先清除過時的
    // 目標檔案;如果檢視器仍握著舊檔案,會是刪除動作
    // 明顯地失敗,而在此之前不會動到任何一個好的位元組
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // 或者:RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // 絕不要讓寫到一半的暫存檔孤懸在那裡
    raise;
  end;
end;

關於這段程式碼有兩個誠實的註腳。TFile.Move 和經典的 RenameFile 都對應到相同的 Windows 重新命名,它只有在來源和目的地都位於同一個磁碟區時才是原子操作,這正是暫存檔放在目的地目錄而不是 TPath.GetTempPath 的原因。而先刪除再移動的配對本身並不是一個原子步驟:會有一個短暫的空窗期兩個檔案都不存在。對於重新產生報表的桌面應用程式來說,這個空窗期無關緊要;需要同一個磁碟區上更強烈契約的讀者,可以直接呼叫帶有 MOVEFILE_REPLACE_EXISTING 的 Win32 ReplaceFile 或 MoveFileEx,這會將置換合併為單一呼叫

對於不斷重新產生文件的高流量伺服器,更乾淨的原則是以唯一的名稱(時間戳記或工作 ID)寫入每個輸出,讓兩次執行永遠不會爭奪一條路徑,並讓獨立的保留策略清理舊檔案。這種模式是每個要求一行的命名原則

// 每個要求都有自己的輸出路徑:兩個並行工作絕不會
// 爭奪同一個名稱,因此不需要重新命名,也沒有鎖可失去
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

當周圍的框架已經交給您一個要求 ID 或工作 ID 時,它們的作用和 GUID 一樣好,而且它可以讓檔名免費追蹤回日誌行。無論哪種方式,原則都是相同的:設計成讓您寫入的檔案在您寫入的那一刻只屬於您。鎖定消失不是因為您強制關閉了一個視窗,而是因為沒有其他東西正在觸碰這些位元組

修復的輪廓

將這兩個問題追溯到它們的根源,它們都是關於尊重邊界。狀態機錯誤希望您遵守文件邊界:在開始或載入下一份文件之前,先用 EndDoc 完成當前這份。檔案鎖定錯誤希望您遵守檔案邊界:在沒有其他東西讀取的地方寫入,然後將結果移動到位。兩者都不需要繞過元件本身,也不需要對桌面下指令碼。兩者都是把每份文件當成一個自成一體的工作單元——全新建立、乾淨寫入、然後釋放——所自然長出的結果,這與讓元件其餘部分可預測的是同一套模式

此處顯示的 BeginDoc、EndDoc、LoadFromFile 和 SaveLoadedDocument 呼叫都來自 HotPDF Delphi Component(Delphi 與 C++Builder 版)