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

Повторно използване на екземпляр на THotPDF в множество документи в Delphi

Грешката гласи Please load the document before using BeginDoc (Моля, заредете документа, преди да използвате BeginDoc) и почти винаги се появява втория път. Първият документ се записва добре. След това същият екземпляр на THotPDF е помолен да стартира втори, BeginDoc се задейства (raises) и съобщението сочи към зареждане на документ, което е обратното на това, което кодът се опитва да направи. Несъответствието между симптома и съобщението е това, което прави тази грешка упорита. Истинската тема е жизненият цикъл на компонента и щом това се разбере, грешката престава да бъде мистериозна

THotPDF document lifecycle showing Create, BeginDoc, EndDoc, and Free per output file
Един екземпляр на THotPDF отговаря на един документ: Create, BeginDoc, чертаене, EndDoc, Free.

Един екземпляр на THotPDF е един документ, а не фабрика за документи

Изкушаващият мисловен модел е, че THotPDF е сервизен обект, който стартирате веднъж и му подавате документи, по начина, по който бихте могли да държите отворена връзка с база данни и да изпълнявате заявка след заявка през нея. Това не е така. Един екземпляр (instance) моделира един единствен документ, който се изгражда, и неговият вътрешен автомат на състоянията (state machine) носи предположението, че той върви по пътя веднъж: от празен, през отворен документ, до запазен файл. BeginDoc отваря този път и маркира екземпляра като имащ документ в процес на работа. EndDoc сериализира всичко към FileName и го затваря. Извикването на BeginDoc отново върху същия завършен екземпляр го моли да влезе отново в състояние, от което никога не е излизал чисто, и охраната (guard), която се задейства, е тази, чието съобщение случайно споменава зареждане, защото вътрешно условията "готов за започване" и "има зареден документ" се проверяват заедно

Така че съобщението е подвеждащо, но охраната върши своята работа. Тя отказва да ви позволи да стартирате нов документ върху компонент, който все още вярва, че е по средата на документа. Поправката не е да победите охраната. Тя е да спрете да използвате повторно вече изразходван екземпляр

Жизненият цикъл, в реда, в който трябва да се случи

Всеки документ, който HotPDF записва от нулата, следва едни и същи четири стъпки и редът не подлежи на договаряне. Create разпределя (allocates) компонента. 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, един файл на диска. В мига, в който поискате втори файл, вие започвате нова единица работа, което означава нов екземпляр (instance)

Какво би трябвало да означава "повторно използване": нов екземпляр за всеки файл

Версията, която се чупи, се опитва да бъде пестелива с разпределянето: изгражда компонента веднъж, цикли върху партида (batch), извиква BeginDoc и EndDoc вътре в цикъла. Втората итерация хвърля грешка. Версията, която работи, третира всеки изход (output) като свой собствен краткотраен обект, а цената за разпределяне (allocation) при създаването на компонент е тривиална в сравнение с работата по оформлението и сериализирането на 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, която принадлежи на документи, които създавате от нищото. Смесването на двете е най-често срещаният начин да се обърка същият автомат на състоянията (state machine), който е произвел първоначалната грешка. Дръжте двата потока мислено разделени: BeginDoc ... EndDoc създава, LoadFromFile ... SaveLoadedDocument редактира

Проблемът със заключването на файлове е реален и отговорът не е да убивате прозорци на програми за преглед (viewer)

Грешката с повторното използване често пътува с второ оплакване и двете се заплитат, защото се появяват в същия работен процес за регенериране на файл. Потребител отваря PDF-а, който току-що сте произвели, оставя го отворен в Acrobat или Foxit, след което задейства повторно изграждане (rebuild). EndDoc се опитва да запише същия път, операционната система отказва, защото програмата за преглед държи дял за четене (read share), който блокира писателите, и получавате грешка за отказан достъп (access-denied). Това е истински проблем със заключването на файлове (file-locking) в Windows, а не проблем със състоянието на компонента, и заслужава истински отговор, а не заобиколно решение (workaround)

Заобиколното решение, което циркулира – изброяване на прозорците от най-високо ниво и публикуване на WM_CLOSE към всичко, чието заглавие прилича на PDF четец – е грешен инстинкт. То преминава границите на процесите, за да затваря прозорци, които вашата програма не притежава, отгатва четците по текста на заглавието и може да изхвърли незапазени анотации на потребител без да попита. Третирайте целия този подход като лоша миризма в кода (smell). Надеждната поправка е никога да не се пише в път, който друг процес може да държи. Сериализирайте във временен файл в същата директория, след което го заменете на място с атомарно преименуване, след като 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, което е атомарно само когато източникът и дестинацията седят на един и същ том (volume), и точно затова временният файл отива в директорията на дестинацията, а не в TPath.GetTempPath. Освен това двойката изтриване-след-което-преместване сама по себе си не е една атомарна стъпка: има кратък прозорец, в който нито един от двата файла не съществува. За десктоп приложение, регенериращо отчет, този прозорец е без значение; читателите, които се нуждаят от по-силен договор на същия том, могат директно да извикат Win32 ReplaceFile или MoveFileEx с MOVEFILE_REPLACE_EXISTING, което свива размяната (swap) в едно единствено извикване

За сървър с голям обем, който регенерира документи постоянно, по-чистата дисциплина е всеки изход (output) да се записва под уникално име (времеви печат (timestamp) или идентификатор на задача (job id)), така че две изпълнения никога да не се състезават за един път, и да се позволи на отделна политика за задържане (retention) да почиства старите файлове. Моделът е един ред дисциплина за именуване на всяка заявка:

// 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, когато обкръжаващата рамка (framework) вече ви предоставя такъв, и това прави името на файла проследимо до ред в лога (log line) безплатно. И в двата случая принципът е един и същ: проектирайте така, че файлът, който пишете, да е само ваш в момента, в който го пишете. Заключването изчезва не защото сте принудили затварянето на прозорец, а защото нищо друго не докосва байтовете

Формата на поправката

Оголете двата проблема до техните корени и ще видите, че и двата се отнасят до спазването на границите. Грешката на автомата на състоянията (state machine) изисква да спазвате границата на екземпляра: един THotPDF, един документ, след това го освободете и направете друг. Грешката със заключването на файловете изисква да спазвате границата на файла: пишете там, където нищо друго не чете, след което преместете резултата на място. Нито едно от двете не изисква закърпване (patching) на библиотеката или писане на скриптове за десктопа. И двете се разрешават, като третирате всеки документ като самостоятелна единица работа, създадена наново, написана чисто и освободена, което е същият модел, който прави останалата част от компонента предсказуема

Извикванията BeginDoc, EndDoc, LoadFromFile и SaveLoadedDocument, показани тук, са част от компонента HotPDF за Delphi и C++Builder