Technisch artikel

Een THotPDF-instantie hergebruiken voor meerdere documenten in Delphi

De foutmelding luidt Please load the document before using BeginDoc, en deze verschijnt bijna altijd de tweede keer. Het eerste document wordt prima geschreven. Vervolgens wordt dezelfde THotPDF instantie gevraagd om een tweede te starten, BeginDoc werpt een uitzondering op, en de melding wijst op het laden van een document, wat het tegenovergestelde is van wat de code probeert te doen. De discrepantie tussen het symptoom en de melding is wat deze zo hardnekkig maakt. Het eigenlijke onderwerp is de levenscyclus van de component, en zodra dat kwartje valt, is de foutmelding niet langer mysterieus

THotPDF documentlevenscyclus met Create, BeginDoc, EndDoc en Free per uitvoerbestand
Eén THotPDF instantie verwijst naar één document: Create, BeginDoc, tekenen, EndDoc, Free.

Een THotPDF-instantie is één document, geen documentfabriek

Het verleidelijke mentale model is dat THotPDF een serviceobject is dat u eenmaal opstart en waaraan u documenten voedt, op de manier waarop u een databaseverbinding open zou kunnen houden en er zoekopdracht na zoekopdracht doorheen zou kunnen sturen. Dat is het niet. Een instantie modelleert een enkel document dat wordt gebouwd, en de interne statusmachine draagt de aanname met zich mee dat het het pad één keer bewandelt: van leeg, via een open document, naar een opgeslagen bestand. BeginDoc opent dat pad en markeert de instantie als hebbende een document in uitvoering. EndDoc serialiseert alles naar FileName en sluit het af. Het opnieuw aanroepen van BeginDoc op dezelfde voltooide instantie vraagt om opnieuw een status binnen te gaan die het nooit netjes heeft verlaten, en de bewaker die in werking treedt is degene wiens bericht toevallig het laden vermeldt, omdat intern de voorwaarden "klaar om te beginnen" en "heeft een geladen document" samen worden gecontroleerd

Dus de melding is misleidend, maar de bewaker doet zijn werk. Hij weigert u een nieuw document te laten starten bovenop een component die nog steeds gelooft dat hij midden in een document zit. De oplossing is niet om de bewaker te omzeilen. Het is om te stoppen met het hergebruiken van een verbruikte instantie

De levenscyclus, in de volgorde waarin deze moet plaatsvinden

Elk document dat HotPDF vanaf nul schrijft, volgt dezelfde vier stappen, en de volgorde is niet onderhandelbaar. Create wijst de component toe. BeginDoc opent het document en legt de structurele keuzes vast, dus alles wat het hele bestand beïnvloedt (paginaformaat, compressie, encryptie, uitvoerbestandsnaam) moet worden ingesteld tussen Create en BeginDoc. Daarna tekent u. Dan schrijft EndDoc de bytes naar schijf. Free geeft de instantie vrij. Tekenaanroepen geplaatst vóór BeginDoc hebben geen pagina om op te landen; documentbrede eigenschappen die daarna worden toegewezen, worden zonder klagen genegeerd

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;

Lees dat als de werkeenheid. Eén Create, één BeginDoc, één EndDoc, één Free, één bestand op schijf. Op het moment dat u een tweede bestand wilt, start u een nieuwe werkeenheid, wat een nieuwe instantie betekent

Wat "hergebruik" zou moeten betekenen: een nieuwe instantie per bestand

De versie die stukloopt, probeert zuinig te zijn met toewijzing: bouw de component eenmaal, loop over een batch, roep BeginDoc en EndDoc aan binnen de lus. De tweede iteratie werpt een fout op. De versie die werkt, behandelt elke uitvoer als zijn eigen kortlevende object, en de toewijzingskosten voor het maken van een component zijn triviaal vergeleken met het werk van het opmaken en serialiseren van een PDF, dus er is niets te besparen door de instantie op te potten

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;

De try/finally die binnen de lus zit, is het deel dat het verdedigen waard is tijdens een beoordeling. Als BeginDoc of een tekenaanroep halverwege een document een uitzondering opwerpt, wordt de instantie van die iteratie nog steeds vrijgegeven voordat de volgende begint, zodat één foutief record geen halfgebouwde component achterlaat en de rest van de run vergiftigt. Haal de Create naar boven de lus om te "optimaliseren" en u bent terug bij de oorspronkelijke bug, dit keer vermomd in een batchlus

Het wijzigen van een bestaand bestand is een ander instappunt

Er is een tweede interpretatie van "hergebruik" die volkomen legitiem is: u wilt geen blanco document, u wilt een reeds bestaande PDF openen en wijzigen. Dat pad loopt helemaal niet via BeginDoc, wat precies is waarom de foutmelding het laden noemt. U laadt het bestand, bewerkt het en slaat het op onder de naam die u kiest

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 retourneert het aantal pagina's, en een waarde van nul of lager betekent dat het laden is mislukt, dus dit is het controleren waard voordat u CurrentPage aanraakt. De koppeling is belangrijk: een document dat u hebt geopend met LoadFromFile wordt opgeslagen met SaveLoadedDocument, niet met het BeginDoc/EndDoc-paar, dat hoort bij documenten die u vanuit het niets ontwerpt. Het mixen van de twee is de meest voorkomende manier om dezelfde statusmachine te verwarren die de oorspronkelijke fout veroorzaakte. Houd de twee stromen mentaal gescheiden: BeginDoc ... EndDoc creëert, LoadFromFile ... SaveLoadedDocument bewerkt

Het bestandsblokkeringsprobleem is echt, en het antwoord is niet om viewervensters te sluiten

De hergebruiksfout gaat vaak gepaard met een tweede klacht, en de twee raken met elkaar verstrikt omdat ze opduiken in dezelfde bestand-opnieuw-genereren workflow. Een gebruiker opent de PDF die u zojuist hebt geproduceerd, laat deze open in Acrobat of Foxit, en triggert vervolgens een rebuild. EndDoc probeert naar hetzelfde pad te schrijven, het besturingssysteem weigert dit omdat de viewer een read-share vasthoudt die schrijvers blokkeert, en u krijgt een toegang-geweigerd-fout. Dit is oprecht een Windows bestandsblokkeringsprobleem in plaats van een component-statusprobleem, en het verdient een echt antwoord in plaats van een tijdelijke oplossing

De tijdelijke oplossing die circuleert, namelijk het opsommen van top-level vensters en het verzenden van WM_CLOSE naar alles waarvan de titel op een PDF-viewer lijkt, is het verkeerde instinct. Het overschrijdt procesgrenzen om vensters te sluiten waarvan uw programma geen eigenaar is, het raadt naar viewers op basis van titeltekst, en het kan niet-opgeslagen aantekeningen van een gebruiker weggooien zonder te vragen. Behandel die hele benadering als ongewenst. De betrouwbare oplossing is om nooit naar een pad te schrijven dat een ander proces mogelijk vasthoudt. Serialiseer naar een tijdelijk bestand in dezelfde map en verwissel het vervolgens met een atomaire hernoeming zodra EndDoc slaagt. Als een viewer nog steeds het oude bestand geopend heeft, zal de hernoeming ofwel netjes slagen ofwel luidruchtig falen, en toont u een duidelijke melding in plaats van te vechten tegen de blokkering

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;

Twee eerlijke voetnoten bij die code. TFile.Move en het klassieke RenameFile verwijzen beide naar dezelfde Windows rename, die alleen atomair is wanneer bron en doel zich op hetzelfde volume bevinden, en dat is precies de reden waarom het tijdelijke bestand in de doelmap terechtkomt in plaats van in TPath.GetTempPath. En de verwijderen-dan-verplaatsen-combinatie is zelf niet één atomaire stap: er is een kort venster waarin geen van beide bestanden bestaat. Voor een desktop-app die een rapport hergenereert, is dat venster irrelevant; lezers die een sterker contract nodig hebben op hetzelfde volume kunnen direct de Win32 ReplaceFile of MoveFileEx met MOVEFILE_REPLACE_EXISTING aanroepen, wat de wissel tot één enkele aanroep samenvoegt

Voor een hoogvolumebestandsserver die constant documenten genereert, is de schonere discipline om elke uitvoer onder een unieke naam te schrijven (een tijdstempel of een taak-id), zodat twee runs nooit strijden om één pad, en een afzonderlijk bewaarbeleid oude bestanden te laten opruimen. Het patroon is één regel naamgevingsdiscipline per aanvraag

// 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);

Een aanvraag-id of taak-id werkt net zo goed als de GUID wanneer het omliggende raamwerk u er al één overhandigt, en het maakt de bestandsnaam gratis herleidbaar naar een logregel. Hoe dan ook, het principe is hetzelfde: ontwerp zodanig dat het bestand dat u schrijft alleen van u is op het moment dat u het schrijft. De blokkering verdwijnt niet omdat u een venster geforceerd sloot, maar omdat niets anders de bytes aanraakt

De vorm van de oplossing

Breng de twee problemen terug naar hun essentie en het gaat bij beide om het respecteren van grenzen. De statusmachinefout wil dat u de grens van de instantie respecteert: één THotPDF, één document, laat het dan los en maak een nieuwe. De bestandsblokkeringsfout wil dat u de grens van het bestand respecteert: schrijf waar niets anders leest, verplaats het resultaat dan naar zijn plaats. Geen van beide vereist het patchen van de bibliotheek of het scripten van het bureaublad. Beide vloeien voort uit het behandelen van elk document als een opzichzelfstaande werkeenheid, vers gemaakt, netjes geschreven en vrijgegeven, wat hetzelfde patroon is dat de rest van de component voorspelbaar maakt

De hier getoonde BeginDoc, EndDoc, LoadFromFile en SaveLoadedDocument aanroepen maken deel uit van de HotPDF Component voor Delphi en C++Builder