技術記事

Delphiでドキュメント全体にTHotPDFインスタンスを再利用する

エラーはPlease load the document before using BeginDocと表示され、ほぼ常に2回目に表示されます。最初のドキュメントは正常に書き込まれます。その後、同じTHotPDFインスタンスが2つ目のドキュメントを開始するように要求され、BeginDocが発生し、メッセージはドキュメントのロードを指し示しますが、これはコードが試みていることの逆です。この症状とメッセージの不一致が、これを解決困難にしています。実際の主題はコンポーネントのライフサイクルであり、これが理解できればエラーは謎ではなくなります

出力ファイルごとのCreate、BeginDoc、EndDoc、およびFreeを示すTHotPDFドキュメントライフサイクル
1つのTHotPDFインスタンスが1つのドキュメントにマッピングされます:Create、BeginDoc、描画、EndDoc、Free。

THotPDFインスタンスは1つのドキュメントであり、ドキュメントファクトリではありません

魅力的なメンタルモデルは、データベース接続を開いたままにしてクエリを次々と実行するように、THotPDFを1回スピンアップしてドキュメントを供給するサービスオブジェクトであるというものです。しかし、そうではありません。インスタンスは構築中の単一のドキュメントをモデル化しており、その内部ステートマシンは、空から開いたドキュメント、そして保存されたファイルへと、パスを1回だけ歩むという仮定を持っています。BeginDocはそのパスを開き、インスタンスに進行中のドキュメントがあることをマークします。EndDocはすべてをFileNameにシリアル化し、それを閉じます。同じ完了したインスタンスで再びBeginDocを呼び出すことは、きれいに離れたことのない状態に再入するように要求することであり、発生するガードはメッセージにロードについて言及しているものです。なぜなら、内部的には「開始の準備ができている」条件と「ロードされたドキュメントがある」条件が一緒にチェックされるからです

したがって、メッセージは誤解を招く可能性がありますが、ガードはその役割を果たしています。ドキュメントの途中であると信じているコンポーネントの上に新しいドキュメントを開始することを拒否しています。修正はガードを破ることではありません。それは使い古されたインスタンスの再利用をやめることです

ライフサイクル、それが起こるべき順序で

HotPDFが最初から書き込むすべてのドキュメントは同じ4つのビートに従い、順序は交渉の余地がありません。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;

これを作業の単位として読んでください。1つのCreate、1つのBeginDoc、1つのEndDoc、1つのFree、ディスク上の1つのファイル。2つ目のファイルが必要になった瞬間に、新しい作業単位を開始することになり、それは新しいインスタンスを意味します

「再利用」が意味すべきこと:ファイルごとの新しいインスタンス

壊れるバージョンは、割り当てを節約しようとします。コンポーネントを1回構築し、バッチをループし、ループ内でBeginDocEndDocを呼び出します。2回目の反復でスローされます。機能するバージョンは、各出力を独自の短命なオブジェクトとして扱い、コンポーネントを作成する割り当てコストは、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または描画の呼び出しが1つのドキュメントの途中で発生した場合でも、その反復のインスタンスは次が始まる前に解放されるため、1つの不良レコードが半分構築されたコンポーネントを立ち往生させ、残りの実行を台無しにすることはありません。「最適化」するためにループの上にCreateを引き出すと、元のバグに戻り、今はバッチループを着用しています

既存のファイルの変更は別のエントリポイントです

完全に正当な「再利用」の2つ目の読み方があります。空白のドキュメントではなく、すでに存在する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で開いたドキュメントは、BeginDoc/EndDocペアではなく、SaveLoadedDocumentで保存されます。BeginDoc/EndDocペアは、何もないところから作成するドキュメントに属しています。この2つを混在させることは、元のエラーを生成したのと同じステートマシンを混乱させる最も一般的な方法です。2つのフローを精神的に分離してください:BeginDoc ... EndDocは作成し、LoadFromFile ... SaveLoadedDocumentは編集します

ファイルロックの問題は現実であり、答えはビューアウィンドウを強制終了することではありません

再利用エラーはしばしば2つ目の不満と一緒に移動し、これら2つは同じファイル再生成ワークフローで表面化するため、絡み合います。ユーザーが作成したばかりのPDFを開き、AcrobatまたはFoxitで開いたままにし、再構築をトリガーします。EndDocは同じパスを書き込もうとしますが、ビューアがライターをブロックする読み取り共有を保持しているため、オペレーティングシステムはそれを拒否し、アクセス拒否エラーが発生します。これはコンポーネント状態の問題ではなく、純粋にWindowsのファイルロックの問題であり、回避策ではなく実際の答えが必要です

出回っている回避策、つまりトップレベルのウィンドウを列挙し、タイトルがPDFビューアのように見えるものにWM_CLOSEを投稿することは、間違った本能です。それはプロセスの境界を越えてプログラムが所有していないウィンドウを閉じ、タイトルテキストによってビューアを推測し、ユーザーの保存されていない注釈を尋ねることなく破棄する可能性があります。そのアプローチ全体を匂いとして扱ってください。信頼できる修正は、別のプロセスが保持している可能性のあるパスに決して書き込まないことです。同じディレクトリ内のテンポラリファイルにシリアル化し、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;

そのコードに関する2つの正直な脚注。TFile.Moveと古典的なRenameFileは両方とも同じWindowsのrenameにマップされ、ソースと宛先が同じボリューム上にある場合にのみアトミックです。これがまさに一時ファイルがTPath.GetTempPathではなく宛先ディレクトリに移動する理由です。また、delete-then-moveのペア自体は1つのアトミックステップではありません:どちらのファイルも存在しない短いウィンドウがあります。レポートを再生成するデスクトップアプリの場合、そのウィンドウは無関係です。同じボリューム上でより強力な契約を必要とする読者は、Win32のReplaceFileまたはMoveFileExMOVEFILE_REPLACE_EXISTINGで直接呼び出すことができ、これによりスワップが1回の呼び出しに折りたたまれます

ドキュメントを常に再生成する大量のサーバーの場合、よりクリーンな規律は、各出力を一意の名前(タイムスタンプまたはジョブID)で書き込み、2つの実行が同じパスを競合しないようにし、個別の保持ポリシーによって古いファイルをクリーンアップすることです。パターンは、リクエストごとの命名規律の1行です

// 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と同じように機能し、ファイル名をログ行に無料でトレースバックできるようにします。いずれにせよ、原則は同じです。書き込んでいるファイルが、書き込む瞬間にあなただけのものであるように設計します。ウィンドウを無理やり閉じたからではなく、他の何かがバイトに触れていないため、ロックは消えます

修正の形

2つの問題を根本まで取り除くと、どちらも境界を尊重することに関するものです。ステートマシンのエラーは、インスタンスの境界を尊重することを望んでいます:1つのTHotPDF、1つのドキュメント、そしてそれを手放して別のものを作ります。ファイルロックのエラーは、ファイルの境界を尊重することを望んでいます:他の何も読んでいない場所に書き込み、その結果を所定の場所に移動します。どちらもライブラリのパッチ適用やデスクトップのスクリプト作成を求めていません。どちらも、各ドキュメントを自己完結型の作業単位として扱い、新しく作成し、きれいに書き込み、解放することから生じます。これは、コンポーネントの残りの部分を予測可能にするのと同じパターンです

ここに示すBeginDocEndDocLoadFromFile、およびSaveLoadedDocumentの呼び出しは、DelphiおよびC++Builder用のHotPDF Componentの一部です