Техническая статья

Повторное использование THotPDF для многих документов

Один экземпляр THotPDF может записывать сколько угодно документов подряд: BeginDoc после EndDoc начинает новый, независимый документ. Повторное использование одного экземпляра для нескольких документов доступно начиная с HotPDF v2.764.0. Сообщение Please load the document before using BeginDoc принадлежит соседней охранной проверке: LoadFromFile выбрасывает его, когда документ, начатый BeginDoc, ещё не закрыт EndDoc. Оба поведения следуют из жизненного цикла компонента, и как только он укладывается в голове, ни то, ни другое не выглядит загадочным

Повторное использование THotPDF в Delphi: один экземпляр выполняет BeginDoc, рисование и EndDoc для каждого выходного файла между единственными Create и Free, либо пакетный цикл создаёт свежий экземпляр на каждый файл
Каждый документ проходит BeginDoc, рисование, EndDoc; один экземпляр THotPDF может повторять этот цикл до Free, а каждый выходной файл может получить и собственный свежий экземпляр

Экземпляр THotPDF — один документ или фабрика документов?

Экземпляр THotPDF — это фабрика документов: вы можете создать его один раз, например как компонент, брошенный на форму, и подавать ему документы, подобно тому как вы могли бы держать открытым соединение с базой данных и выполнять через него запрос за запросом. BeginDoc открывает документ и помечает экземпляр как имеющий документ в работе. EndDoc сериализует всё в FileName, а затем сбрасывает всё, зарегистрированное для этого файла, включая его изображения, шрифты, поля форм, закладки, документный JavaScript, именованные назначения, метки страниц, ICC-профили и output intents. Страницы и объекты записанного документа остаются читаемыми, пока следующий 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, и именно поэтому сообщение об ошибке называет загрузку. Вы загружаете файл, редактируете его и сохраняете под любым именем по вашему выбору

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, — это неверный инстинкт. Он тянется через границы процессов, чтобы закрыть окна, которыми ваша программа не владеет, он угадывает просмотрщики по тексту заголовка, и он может выбросить несохраненные аннотации пользователя, не спрашивая. Относитесь ко всему этому подходу как к дурному запаху. Надежное исправление — никогда не записывать по пути, который может удерживать другой процесс. Сериализуйте во временный файл в том же каталоге, затем поменяйте его местами атомарным переименованием после того, как 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. И пара «удалить-затем-переместить» сама по себе не является одним атомарным шагом: есть краткое окно, в котором не существует ни одного файла. Для настольного приложения, перегенерирующего отчет, это окно не имеет значения; читатели, которым нужен более строгий контракт на одном томе, могут напрямую вызвать Win32 ReplaceFile или MoveFileEx с MOVEFILE_REPLACE_EXISTING, что схлопывает подмену в один вызов

Для высоконагруженного сервера, который постоянно перегенерирует документы, более чистая дисциплина — записывать каждый выходной файл под уникальным именем (метка времени или идентификатор задания), чтобы два прогона никогда не конкурировали за один путь, и предоставить отдельной политике хранения очищать старые файлы. Этот паттерн — одна строка дисциплины именования на запрос

// Один путь вывода на запрос: два одновременных задания никогда не конкурируют
// за одно имя, поэтому не нужен танец с переименованием и нечего терять из-за блокировки
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Идентификатор запроса или идентификатор задания работает так же хорошо, как GUID, когда окружающий фреймворк уже выдает вам такой, и это делает имя файла прослеживаемым обратно к строке журнала бесплатно. В любом случае принцип один и тот же: проектируйте так, чтобы файл, который вы записываете, принадлежал только вам в момент записи. Блокировка исчезает не потому, что вы принудительно закрыли окно, а потому, что ничто другое не касается байтов

Форма исправления

Сведите обе проблемы к их корням, и обе они про уважение границ. Ошибка конечного автомата хочет, чтобы вы соблюдали границу документа: завершайте один документ EndDoc, прежде чем начать или загрузить следующий. Ошибка блокировки файла хочет, чтобы вы соблюдали границу файла: записывайте туда, где ничто другое не читает, затем переместите результат на место. Ни одна из них не требует обхода компонента или скриптования рабочего стола. Обе проистекают из того, что каждый документ рассматривается как самодостаточная единица работы, создаваемая заново, записываемая чисто и освобождаемая, — это тот же паттерн, который делает остальную часть компонента предсказуемой

Вызовы BeginDoc, EndDoc, LoadFromFile и SaveLoadedDocument, показанные здесь, являются частью HotPDF Delphi Component для Delphi и C++Builder