Ошибка гласит Please load the document before using BeginDoc, и почти всегда она возникает во второй раз. Первый документ записывается без проблем. Затем тот же экземпляр THotPDF просят начать второй, BeginDoc выбрасывает исключение, а сообщение указывает на загрузку документа, что прямо противоположно тому, что пытается сделать код. Именно несоответствие между симптомом и сообщением делает эту проблему запоминающейся. Настоящий предмет разговора — жизненный цикл компонента, и как только это становится понятно, ошибка перестает быть загадочной

Экземпляр THotPDF — это один документ, а не фабрика документов
Соблазнительная ментальная модель состоит в том, что THotPDF — это сервисный объект, который вы создаете один раз и в который подаете документы, подобно тому как вы могли бы держать открытым соединение с базой данных и выполнять через него запрос за запросом. Это не так. Экземпляр моделирует один создаваемый документ, и его внутренний конечный автомат несет предположение, что он проходит путь однократно: от пустоты через открытый документ к сохраненному файлу. BeginDoc открывает этот путь и помечает экземпляр как имеющий документ в работе. EndDoc сериализует все в FileName и закрывает его. Повторный вызов BeginDoc на том же завершенном экземпляре просит его снова войти в состояние, которое он так и не покинул начисто, и срабатывает та охранная проверка, чье сообщение случайно упоминает загрузку, потому что внутренне условия «готов начать» и «имеет загруженный документ» проверяются вместе
Итак, сообщение вводит в заблуждение, но охранная проверка делает свою работу. Она отказывается позволить вам начать новый документ поверх компонента, который все еще считает, что находится в середине документа. Исправление состоит не в том, чтобы обойти охранную проверку. Оно состоит в том, чтобы прекратить повторно использовать израсходованный экземпляр
Жизненный цикл в том порядке, в котором он должен происходить
Каждый документ, который HotPDF пишет с нуля, следует одним и тем же четырем тактам, и порядок не подлежит обсуждению. Create выделяет компонент. BeginDoc открывает документ и фиксирует структурные решения, поэтому все, что влияет на весь файл (размер страницы, сжатие, шифрование, имя выходного файла), должно быть задано между Create и BeginDoc. Затем вы рисуете. Затем 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, один файл на диске. В тот момент, когда вам нужен второй файл, вы начинаете новую единицу работы, а это означает новый экземпляр
Что должно означать «повторное использование»: новый экземпляр на каждый файл
Версия, которая ломается, пытается экономить на выделении памяти: создать компонент один раз, пройти циклом по пакету, вызывать BeginDoc и EndDoc внутри цикла. Вторая итерация выбрасывает исключение. Версия, которая работает, рассматривает каждый выходной файл как собственный короткоживущий объект, а стоимость выделения памяти на создание компонента ничтожна по сравнению с работой по компоновке и сериализации 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, — это неверный инстинкт. Он тянется через границы процессов, чтобы закрыть окна, которыми ваша программа не владеет, он угадывает просмотрщики по тексту заголовка, и он может выбросить несохраненные аннотации пользователя, не спрашивая. Относитесь ко всему этому подходу как к дурному запаху. Надежное исправление — никогда не записывать по пути, который может удерживать другой процесс. Сериализуйте во временный файл в том же каталоге, затем поменяйте его местами атомарным переименованием после того, как 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. И пара «удалить-затем-переместить» сама по себе не является одним атомарным шагом: есть краткое окно, в котором не существует ни одного файла. Для настольного приложения, перегенерирующего отчет, это окно не имеет значения; читатели, которым нужен более строгий контракт на одном томе, могут напрямую вызвать Win32 ReplaceFile или MoveFileEx с MOVEFILE_REPLACE_EXISTING, что схлопывает подмену в один вызов
Для высоконагруженного сервера, который постоянно перегенерирует документы, более чистая дисциплина — записывать каждый выходной файл под уникальным именем (метка времени или идентификатор задания), чтобы два прогона никогда не конкурировали за один путь, и предоставить отдельной политике хранения очищать старые файлы. Этот паттерн — одна строка дисциплины именования на запрос
// 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);
Идентификатор запроса или идентификатор задания работает так же хорошо, как GUID, когда окружающий фреймворк уже выдает вам такой, и это делает имя файла прослеживаемым обратно к строке журнала бесплатно. В любом случае принцип один и тот же: проектируйте так, чтобы файл, который вы записываете, принадлежал только вам в момент записи. Блокировка исчезает не потому, что вы принудительно закрыли окно, а потому, что ничто другое не касается байтов
Форма исправления
Сведите обе проблемы к их корням, и обе они про уважение границ. Ошибка конечного автомата хочет, чтобы вы соблюдали границу экземпляра: один THotPDF, один документ, затем отпустите его и создайте другой. Ошибка блокировки файла хочет, чтобы вы соблюдали границу файла: записывайте туда, где ничто другое не читает, затем переместите результат на место. Ни то, ни другое не требует патча библиотеки или скриптования рабочего стола. Обе проистекают из того, что каждый документ рассматривается как самодостаточная единица работы, создаваемая заново, записываемая чисто и освобождаемая, — это тот же паттерн, который делает остальную часть компонента предсказуемой
Вызовы BeginDoc, EndDoc, LoadFromFile и SaveLoadedDocument, показанные здесь, являются частью HotPDF Component для Delphi и C++Builder