技术文章

在 Delphi 中跨文档复用 THotPDF 实例

一个 THotPDF 实例可以接连写出任意多份文档:EndDoc 之后再 BeginDoc,开始的是一份全新、独立的文档。单个实例的多文档复用自 HotPDF v2.764.0 起可用。消息 Please load the document before using BeginDoc 属于隔壁的另一道防护:LoadFromFile 在 BeginDoc 开启的文档尚未用 EndDoc 关闭时会抛出它。两种行为都出自组件生命周期,把这一点想通,它们就都不神秘了

Delphi 中的 THotPDF 复用:一个实例在唯一一次 Create 与 Free 之间为每个输出文件跑一遍 BeginDoc、绘制和 EndDoc,或者由批处理循环为每个文件创建全新实例
每份文档都跑一遍 BeginDoc、绘制、EndDoc;一个 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 之前重新设置密码,否则它抛出异常,而不是拿空密码去保护文件。下面的版本走另一条路,把每个输出都当成自己的短生命周期对象。与排版并序列化一份 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,也正因如此,报错消息才会提到 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
  // 临时文件放在与目标相同的目录下:同一个
  // 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 rename,而只有当源和目标位于同一卷上时它才具备原子性,这也是为什么临时文件要放在目标目录,而不是 TPath.GetTempPath。另外,先删除再移动本身也不是单个原子步骤:中间会有一个短暂窗口,两个文件都不存在。对于桌面应用重新生成报表,这个窗口通常无关紧要;如果读者需要更强的同卷替换语义,可以直接调用 Win32 的 ReplaceFile 或带 MOVEFILE_REPLACE_EXISTING 的 MoveFileEx,把交换压缩成一次调用

对于会持续高频再生成文档的高吞吐服务器,更干净的纪律是让每个输出都带唯一名称,比如时间戳或任务 ID,这样两次运行永远不会争同一个路径,再由独立的保留策略清理旧文件。这种模式的本质,其实只是一条命名规则

// 每个请求一个独立输出路径:两个并发任务永远不会
// 争抢同一个名字,因此不需要重命名腾挪,也不会争锁
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

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

修复思路的整体轮廓

把这两个问题还原到根上,它们都在要求你尊重边界。状态机错误要你尊重文档边界:先用 EndDoc 结束一份文档,再去开始或加载下一份。文件锁错误要你尊重文件边界:写到没有别人在读的地方,再把结果移到正式位置。两者都不需要绕开组件,也不需要脚本化操控桌面。只要把每份文档都当成一个自洽的工作单元——全新创建、干净写出、完成后释放——这正是让组件其余部分保持可预测的那同一个模式

这里展示的 BeginDoc、EndDoc、LoadFromFile 和 SaveLoadedDocument 调用,都属于 Delphi 与 C++Builder 版本的 HotPDF Delphi Component