技术文章

在 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,等于要求它重新进入一个它从未真正清理干净的状态,而触发的那个防护恰好使用了提到 loading 的消息,因为在内部,“可以开始”与“已经加载文档”这两个条件是一起检查的

所以这条报错确实具有误导性,但防护本身并没有错。它是在阻止你把一份新文档压到一个仍然认为自己处于处理中途的组件实例上。修复方式不是绕过防护,而是不要复用已经用完的实例

生命周期,以及它必须遵守的顺序

每一份由 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 提到循环外面,你就会重新回到最初的 Bug,只是这次它包着一个批处理循环的外壳

修改已有文件,是另一条完全不同的入口

“复用”还有另一种完全合理的含义:你不是要创建空白文档,而是要打开一份已存在的 PDF 并修改它。这条路径根本不会经过 BeginDoc,也正因如此,报错消息才会提到 loading。正确流程是加载文件、修改它,然后按你想要的名字保存

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 试图写回相同路径,操作系统因为阅读器持有阻止写入的共享读锁而拒绝,最终你得到一个 access-denied 错误。这一次,问题确实属于 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;

关于这段代码,还有两个必须老实交代的脚注。TFile.Move 和经典的 RenameFile 最终都映射到同一个 Windows rename,而只有当源和目标位于同一卷上时它才具备原子性,这也是为什么临时文件要放在目标目录,而不是 TPath.GetTempPath。另外,先删除再移动本身也不是单个原子步骤:中间会有一个短暂窗口,两个文件都不存在。对于桌面应用重新生成报表,这个窗口通常无关紧要;如果读者需要更强的同卷替换语义,可以直接调用 Win32 的 ReplaceFile 或带 MOVEFILE_REPLACE_EXISTINGMoveFileEx,把交换压缩成一次调用

对于会持续高频再生成文档的高吞吐服务器,更干净的纪律是让每个输出都带唯一名称,比如时间戳或任务 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);

如果宿主框架本来就会给你 request id 或 job id,它和 GUID 一样适合拿来命名,而且还能让文件名天然追溯到日志。无论用哪种方式,原则都是一样的:设计成在写入那一刻,这个文件只属于你自己。文件锁之所以消失,不是因为你强行关掉了别人的窗口,而是因为没有其他东西再去碰这些字节

修复思路的整体轮廓

把这两个问题还原到根上,它们都在要求你尊重边界。状态机错误要求你尊重实例边界:一个 THotPDF 对应一份文档,用完就放手,再创建新的。文件锁错误要求你尊重文件边界:把内容写到没有其他读取者的地方,再把结果移到正式位置。两者都不需要修改库本身,也不需要脚本化桌面。只要把每份文档都当成一个自洽的工作单元,按全新实例创建、干净写出、完成后释放,整套组件的行为就会重新变得可预测

这里展示的 BeginDocEndDocLoadFromFileSaveLoadedDocument 调用,都属于 Delphi 与 C++Builder 版本的 HotPDF Component