技術文章

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

錯誤訊息顯示 Please load the document before using BeginDoc,而且它幾乎總是在第二次執行時出現。第一份文件寫入正常。接著同一個 THotPDF 實例被要求開始第二份文件,這時 BeginDoc 就會引發例外,而且訊息指向載入文件,這與程式碼嘗試做的事完全相反。症狀和訊息之間的不匹配使得這個問題令人困惑。真正的主題是元件的生命週期,一旦理解了這點,這個錯誤就不再神秘了

顯示每個輸出檔案的 Create、BeginDoc、EndDoc 和 Free 的 THotPDF 文件生命週期
一個 THotPDF 實例對應一份文件:Create、BeginDoc、draw、EndDoc、Free。

一個 THotPDF 實例就是一份文件,而不是文件工廠

一個誘人的思維模型是將 THotPDF 視為一個您啟動一次並向其提供文件的服務物件,就像您可能保持資料庫連線開啟並透過它執行一個又一個的查詢一樣。但事實並非如此。一個實例模擬正在建立的單一文件,其內部狀態機假設它只走一次這條路徑:從空白,到開啟文件,再到儲存檔案。BeginDoc 開啟該路徑並將實例標記為有正在進行的文件。EndDoc 將所有內容序列化到 FileName 並將其關閉。對同一個已完成的實例再次呼叫 BeginDoc,等於要求它重新進入一個它從未乾淨離開的狀態,而觸發的防線恰好是其訊息中提到載入的防線,因為在內部,「準備開始」和「已載入文件」的條件是一起檢查的

所以該訊息具有誤導性,但該防線正在發揮作用。它拒絕讓您在一個仍然認為自己處於文件建立中途的元件上開始一份新文件。解決方案不是要擊敗防線。而是停止重複使用已消耗的實例

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

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

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // opens the document
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // writes invoice.pdf, closes it out
  finally
    Pdf.Free;                            // one instance, one document
  end;
end;

將其視為工作單元。一個 Create,一個 BeginDoc,一個 EndDoc,一個 Free,磁碟上的一個檔案。當您想要第二個檔案的那一刻,您就開始了一個新的工作單元,這意味著一個新的實例

「重複使用」的真正含義:每個檔案一個新實例

會中斷的版本試圖在配置上節儉:建立元件一次,在批次上進行迴圈,在迴圈內部呼叫 BeginDocEndDoc。第二次迭代就會拋出例外。有效的版本將每個輸出視為其自己短暫的物件,而建立元件的配置成本與佈局和序列化 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);         // new instance each pass
    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
  // Temp file in the SAME directory as the target: a rename inside one
  // NTFS volume swaps the name atomically, while a cross-volume move
  // degrades to copy-plus-delete and loses that guarantee
  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;                    // the temp file is complete on disk here
    finally
      Pdf.Free;
    end;

    // Swap into place. TFile.Move refuses to overwrite, so clear a stale
    // target first; if a viewer still holds the old file, the delete is
    // what fails, loudly, before the good bytes are touched
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // or: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // never strand a half-written temp file
    raise;
  end;
end;

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

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

// One output path per request: two concurrent jobs can never contend
// for the same name, so no rename dance and no lock to lose
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

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

修復的輪廓

將這兩個問題追溯到它們的根源,它們都是關於尊重邊界。狀態機錯誤希望您遵守實例邊界:一個 THotPDF,一份文件,然後釋放它並建立另一個。檔案鎖定錯誤希望您遵守檔案邊界:在沒有其他東西讀取的地方寫入,然後將結果移動到位。兩者都不需要修補函式庫或編寫桌面指令碼。兩者都是將每份文件視為一個獨立的工作單元,重新建立、乾淨寫入並釋放的結果,這與使元件其餘部分具備可預測性是相同的模式

此處顯示的 BeginDocEndDocLoadFromFileSaveLoadedDocument 呼叫是適用於 Delphi 和 C++Builder 的 HotPDF 元件 的一部分