Teknisk artikel

Återanvändning av en THotPDF-instans för flera dokument i Delphi

Felmeddelandet lyder Please load the document before using BeginDoc, och det dyker nästan alltid upp den andra gången. Det första dokumentet skrivs utan problem. Sedan ombeds samma THotPDF-instans att starta ett andra, BeginDoc utlöser ett undantag, och meddelandet pekar på att ladda ett dokument, vilket är motsatsen till vad koden försöker göra. Missmatchningen mellan symptomet och meddelandet är det som gör det svårt att förstå. Det verkliga ämnet är komponentens livscykel, och när det väl klickar till upphör felet att vara mystiskt

THotPDF-dokumentlivscykel som visar Create, BeginDoc, EndDoc och Free per utdatafil
En THotPDF-instans mappas till ett dokument: Create, BeginDoc, rita, EndDoc, Free.

En THotPDF-instans är ett dokument, inte en dokumentfabrik

Den frestande mentala modellen är att THotPDF är ett tjänsteobjekt som du snurrar igång en gång och matar dokument till, på samma sätt som du kan hålla en databasanslutning öppen och köra fråga efter fråga genom den. Så är det inte. En instans modellerar ett enskilt dokument som byggs, och dess interna tillståndsmaskin (state machine) bär antagandet att den går vägen en gång: från tom, genom ett öppet dokument, till en sparad fil. BeginDoc öppnar den vägen och markerar instansen som att den har ett dokument på gång. EndDoc serialiserar allt till FileName och stänger det. Att anropa BeginDoc igen på samma avslutade instans ber den att gå in i ett tillstånd den aldrig lämnade på ett rent sätt, och skyddet (guard) som aktiveras är det vars meddelande råkar nämna laddning (loading), eftersom villkoren för "redo att börja" och "har ett laddat dokument" kontrolleras tillsammans internt

Så meddelandet är vilseledande, men skyddet gör sitt jobb. Det vägrar låta dig starta ett nytt dokument ovanpå en komponent som fortfarande tror att den befinner sig mitt i ett dokument. Lösningen är inte att besegra skyddet. Det är att sluta återanvända en förbrukad instans

Livscykeln, i den ordning den måste ske

Varje dokument HotPDF skriver från grunden följer samma fyra steg, och ordningen är inte förhandlingsbar. Create allokerar komponenten. BeginDoc öppnar dokumentet och fixerar de strukturella valen, så allt som påverkar hela filen (sidstorlek, komprimering, kryptering, utdatafilnamn) måste ställas in mellan Create och BeginDoc. Sedan ritar du. Sedan skriver EndDoc byten till disken. Free frigör instansen. Ritanrop placerade före BeginDoc har ingen sida att landa på; dokumentomfattande egenskaper som tilldelas efter den ignoreras utan klagomål

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;

Läs det som enhet för arbete (unit of work). En Create, en BeginDoc, en EndDoc, en Free, en fil på disken. I samma ögonblick du vill ha en andra fil, startar du en ny enhet för arbete, vilket betyder en ny instans

Vad "återanvändning" bör betyda: en ny instans per fil

Den version som går sönder försöker vara sparsam med allokering: bygg komponenten en gång, loopa över en batch, anropa BeginDoc och EndDoc inuti loopen. Den andra iterationen utlöser ett fel. Den version som fungerar behandlar varje utdata som sitt eget kortlivade objekt, och allokeringskostnaden för att skapa en komponent är trivial jämfört med arbetet att layouta och serialisera en PDF, så det finns inget att spara på att hamstra instansen

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;

Det try/finally som sitter inuti loopen är den del som är värd att försvara i kodgranskning (review). Om BeginDoc eller något ritanrop utlöser ett fel halvvägs genom ett dokument, frigörs fortfarande den iterationens instans innan nästa börjar, så en dålig post strandar inte en halvbyggd komponent och förgiftar resten av körningen. Dra ut Create ovanför loopen för att "optimera" och du är tillbaka till den ursprungliga buggen, nu förklädd som en batch-loop

Att modifiera en befintlig fil är en annan ingångspunkt

Det finns en andra tolkning av "återanvändning" som är helt legitim: du vill inte ha ett tomt dokument, du vill öppna en PDF som redan existerar och ändra den. Den vägen går inte genom BeginDoc alls, vilket är exakt varför felmeddelandet nämner laddning (loading). Du laddar filen, redigerar den, och sparar under vilket namn du vill

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 returnerar antalet sidor, och ett värde på noll eller under betyder att inläsningen misslyckades, så det är värt att kontrollera innan du rör CurrentPage. Parningen (pairing) spelar roll: ett dokument du öppnade med LoadFromFile sparas med SaveLoadedDocument, inte med BeginDoc/EndDoc-paret, som hör till dokument du författar från ingenting. Att blanda de två är det vanligaste sättet att förvirra samma tillståndsmaskin (state machine) som producerade det ursprungliga felet. Håll de två flödena mentalt åtskilda: BeginDoc ... EndDoc skapar, LoadFromFile ... SaveLoadedDocument redigerar

Fillåsproblemet (The file-lock problem) är verkligt, och svaret är inte att döda visningsfönster

Återanvändningsfelet reser ofta med ett andra klagomål, och de två trasslar in sig i varandra eftersom de dyker upp i samma regenerera-filen-arbetsflöde. En användare öppnar PDF-filen du precis producerade, lämnar den öppen i Acrobat eller Foxit, och utlöser sedan en ombyggnad. EndDoc försöker skriva på samma sökväg, operativsystemet vägrar eftersom visaren håller en läsdelning (read share) som blockerar skrivare, och du får ett åtkomst nekad-fel (access-denied). Detta är genuint ett Windows-fillåsningsproblem snarare än ett komponenttillståndsproblem, och det förtjänar ett riktigt svar istället för en tillfällig lösning (workaround)

Den lösning (workaround) som cirkulerar, som räknar upp toppnivåfönster och postar WM_CLOSE till allt vars titel ser ut som en PDF-visare, är fel instinkt. Den sträcker sig över processgränser för att stänga fönster som ditt program inte äger, den gissar på visare via titeltext, och den kan kasta bort en användares osparade anteckningar utan att fråga. Behandla hela det tillvägagångssättet som en dålig lukt (code smell). Den pålitliga lösningen är att aldrig skriva till en sökväg som en annan process kan hålla. Serialisera till en temporär fil i samma katalog, byt sedan ut den på plats med ett atomärt namnbyte när EndDoc lyckas. Om en visare fortfarande har den gamla filen öppen, lyckas antingen namnbytet rent eller misslyckas högljutt, och du lyfter fram ett tydligt meddelande snarare än att bekämpa låset

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;

Två ärliga fotnoter om den koden. TFile.Move och den klassiska RenameFile mappar båda till samma Windows-namnbyte, som är atomärt endast när källan och destinationen sitter på samma volym, och det är exakt varför temporärfilen hamnar i destinationskatalogen snarare än TPath.GetTempPath. Och ta bort-sedan-flytta-paret (delete-then-move) är inte i sig ett enda atomärt steg: det finns ett kort fönster där ingen av filerna existerar. För en skrivbordsapp som regenererar en rapport är det fönstret irrelevant; läsare som behöver ett starkare kontrakt på samma volym kan anropa Win32:s ReplaceFile eller MoveFileEx med MOVEFILE_REPLACE_EXISTING direkt, vilket kollapsar bytet till ett enda anrop

För en högvolymsserver som regenererar dokument konstant, är den renare disciplinen att skriva varje utdata under ett unikt namn (en tidsstämpel eller ett jobb-id) så att två körningar aldrig strider om en sökväg, och låta en separat lagringspolicy (retention policy) rensa upp gamla filer. Mönstret är en rad av namngivningsdisciplin per förfrågan

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

Ett förfrågnings-id (request id) eller jobb-id fungerar precis lika bra som GUID när det omgivande ramverket redan ger dig ett, och det gör filnamnet spårbart tillbaka till en loggrad helt gratis. Oavsett vilket är principen densamma: designa så att filen du skriver är din ensam i det ögonblick du skriver den. Låset försvinner inte för att du tvingade igen ett fönster utan för att inget annat rör byten

Lösningens form (The shape of the fix)

Dra tillbaka de två problemen till sina rötter och de handlar båda om att respektera gränser. Tillståndsmaskinfelet (The state-machine error) vill att du respekterar instansgränsen: en THotPDF, ett dokument, släpp den sedan och gör en annan. Fillåsfelet (The file-lock error) vill att du respekterar filgränsen: skriv där inget annat läser, flytta sedan resultatet på plats. Inget av dem kräver att du patchar biblioteket eller skriptar skrivbordet. Båda faller ut av att behandla varje dokument som en fristående enhet av arbete (unit of work), skapad färsk, skriven rent och släppt (released), vilket är samma mönster som gör resten av komponenten förutsägbar

Anropen BeginDoc, EndDoc, LoadFromFile och SaveLoadedDocument som visas här är en del av HotPDF-komponenten för Delphi och C++Builder