Odborný článok

Opätovné použitie inštancie THotPDF vo viacerých dokumentoch v Delphi

Chyba znie Please load the document before using BeginDoc (Pred použitím BeginDoc načítajte dokument) a takmer vždy sa zobrazí na druhýkrát. Prvý dokument sa zapíše v poriadku. Potom je tá istá inštancia THotPDF požiadaná, aby začala druhý, vyvolá sa BeginDoc a správa ukazuje na načítanie dokumentu, čo je opak toho, o čo sa kód snaží. Nesúlad medzi symptómom a správou je to, kvôli čomu to zostane v pamäti. Skutočným predmetom je životný cyklus komponentu a keď vám to docvakne, chyba prestane byť záhadou

Životný cyklus dokumentu THotPDF, ktorý ukazuje Create, BeginDoc, EndDoc a Free pre každý výstupný súbor
Jedna inštancia THotPDF sa mapuje na jeden dokument: Create, BeginDoc, draw, EndDoc, Free.

Inštancia THotPDF je jeden dokument, nie továreň na dokumenty

Lákavým mentálnym modelom je, že THotPDF je servisný objekt, ktorý raz naštartujete a posielate doň dokumenty, podobne ako by ste mohli udržiavať otvorené pripojenie k databáze a spúšťať cez ňu jeden dopyt za druhým. Tak to nie je. Inštancia modeluje jediný dokument, ktorý sa práve vytvára, a jej interný stavový stroj nesie predpoklad, že prejde cestou len raz: z prázdneho stavu cez otvorený dokument k uloženému súboru. BeginDoc otvorí túto cestu a označí inštanciu, že má prebiehajúci dokument. EndDoc všetko serializuje do FileName a uzavrie to. Opätovné volanie BeginDoc na rovnakej dokončenej inštancii žiada, aby znovu vstúpila do stavu, z ktorého nikdy čisto nevyšla, a ochrana, ktorá sa spustí, je tá, ktorej správa náhodou spomína načítanie, pretože interne sa podmienky "pripravený začať" a "má načítaný dokument" kontrolujú spoločne

Takže správa je zavádzajúca, ale ochrana robí svoju prácu. Odmieta vám dovoliť začať nový dokument na vrchu komponentu, ktorý si stále myslí, že je uprostred dokumentu. Opravou nie je poraziť ochranu. Je potrebné prestať opätovne používať vyčerpanú inštanciu

Životný cyklus v poradí, v akom sa musí udiať

Každý dokument, ktorý HotPDF zapíše od nuly, sleduje rovnaké štyri kroky a poradie nie je predmetom vyjednávania. Create alokuje komponent. BeginDoc otvorí dokument a zafixuje štrukturálne voľby, takže všetko, čo ovplyvňuje celý súbor (veľkosť stránky, kompresia, šifrovanie, názov výstupného súboru), musí byť nastavené medzi Create a BeginDoc. Potom kreslíte. Potom EndDoc zapíše bajty na disk. Free uvoľní inštanciu. Volania kreslenia umiestnené pred BeginDoc nemajú žiadnu stránku, na ktorej by mohli pristáť; vlastnosti celého dokumentu priradené za ním sú ignorované bez sťažovania

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;

Prečítajte si to ako jednotku práce. Jedno Create, jedno BeginDoc, jedno EndDoc, jedno Free, jeden súbor na disku. V momente, keď chcete druhý súbor, začínate novú jednotku práce, čo znamená novú inštanciu

Čo by "opätovné použitie" malo znamenať: nová inštancia pre každý súbor

Verzia, ktorá sa zlomí, sa snaží byť šetrná k alokácii: vytvorí komponent raz, prejde cez dávku v slučke, zavolá BeginDoc a EndDoc vnútri slučky. Druhá iterácia zlyhá. Verzia, ktorá funguje, zaobchádza s každým výstupom ako s vlastným krátko žijúcim objektom a náklady na alokáciu vytvorenia komponentu sú triviálne v porovnaní s prácou na usporiadaní a serializácii PDF, takže neexistuje nič, čo by sa dalo ušetriť hromadením inštancií

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;

Blok try/finally sediaci vnútri slučky je tou časťou, ktorú sa oplatí brániť pri revízii. Ak BeginDoc alebo akékoľvek volanie na kreslenie vyvolá chybu v polovici jedného dokumentu, inštancia tejto iterácie je stále uvoľnená pred začiatkom ďalšej, takže jeden zlý záznam nezanechá napoly vytvorený komponent a neotrávi zvyšok behu. Ak presuniete Create nad slučku, aby ste to "optimalizovali", ste späť pri pôvodnej chybe, ktorá má teraz na sebe dávkovú slučku

Úprava existujúceho súboru je iný vstupný bod

Existuje druhé chápanie "opätovného použitia", ktoré je úplne legitímne: nechcete prázdny dokument, chcete otvoriť PDF, ktoré už existuje, a upraviť ho. Táto cesta vôbec neprechádza cez BeginDoc, čo je presne dôvod, prečo chybová správa spomína načítanie. Načítate súbor, upravíte ho a uložíte pod akýmkoľvek menom, ktoré si vyberiete

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 vráti počet stránok a hodnota nula alebo menej znamená, že načítanie zlyhalo, takže stojí za to skontrolovať to predtým, ako sa dotknete CurrentPage. Párovanie je dôležité: dokument, ktorý ste otvorili pomocou LoadFromFile, sa uloží pomocou SaveLoadedDocument, nie pomocou páru BeginDoc/EndDoc, ktorý patrí dokumentom, ktoré vytvárate z ničoho. Miešanie týchto dvoch postupov je najčastejším spôsobom, ako zmiasť ten istý stavový stroj, ktorý vyprodukoval pôvodnú chybu. Udržujte tieto dva toky mentálne oddelené: BeginDoc ... EndDoc vytvára, LoadFromFile ... SaveLoadedDocument upravuje

Problém so zámkom súboru je skutočný a riešením nie je zabíjanie okien prehliadača

Chyba z opätovného použitia často cestuje s druhou sťažnosťou a obe sa prepletajú, pretože sa objavujú v rovnakom pracovnom postupe pregenerovania súboru. Používateľ otvorí PDF, ktoré ste práve vytvorili, nechá ho otvorené v Acrobat alebo Foxit a potom spustí opätovné zostavenie. EndDoc sa pokúsi zapísať na rovnakú cestu, operačný systém to odmietne, pretože prehliadač drží zdieľanie pre čítanie, ktoré blokuje zapisovateľov, a dostanete chybu prístup odopretý. Toto je skutočne problém so zamykaním súborov vo Windows a nie problém so stavom komponentu, a zaslúži si skutočnú odpoveď namiesto obchádzky

Obchádzka, ktorá koluje, enumeruje okná najvyššej úrovne a posiela WM_CLOSE každému, koho nadpis vyzerá ako prehliadač PDF, čo je nesprávny inštinkt. Siaha to cez hranice procesov a zatvára okná, ktoré váš program nevlastní, háda prehliadače podľa textu nadpisu a môže zahodiť neuložené anotácie používateľa bez toho, aby sa ho spýtal. Považujte celý tento prístup za zlozvyk. Spoľahlivou opravou je nikdy nezapisovať na cestu, ktorú by mohol držať iný proces. Serializujte do dočasného súboru v rovnakom adresári a potom ho prehoďte na miesto s atomickým premenovaním po tom, ako uspeje EndDoc. Ak má prehliadač stále otvorený starý súbor, premenovanie buď čisto uspeje, alebo hlasno zlyhá, a vy zobrazíte jasnú správu namiesto boja so zámkom

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;

Dve úprimné poznámky pod čiarou k tomuto kódu. TFile.Move a klasické RenameFile obe mapujú na to isté premenovanie Windows, ktoré je atomické iba vtedy, keď zdroj a cieľ ležia na rovnakom zväzku, a presne preto ide dočasný súbor do cieľového adresára namiesto TPath.GetTempPath. A pár vymazať-potom-presunúť nie je sám o sebe jedným atomickým krokom: existuje krátke okno, v ktorom neexistuje ani jeden súbor. Pre desktopovú aplikáciu generujúcu report to okno nie je relevantné; čitatelia, ktorí potrebujú silnejší kontrakt na rovnakom zväzku, môžu volať Win32 API ReplaceFile alebo MoveFileEx s MOVEFILE_REPLACE_EXISTING priamo, čím sa prehodenie zredukuje do jedného volania

Pre server s vysokým objemom, ktorý neustále generuje dokumenty, je čistejšou disciplínou zapísať každý výstup pod jedinečným menom (časová pečiatka alebo ID úlohy), aby dve spustenia nikdy nesúperili o jednu cestu, a nechať oddelenú politiku uchovávania vyčistiť staré súbory. Vzorom je jeden riadok disciplíny pomenovania na požiadavku

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

ID požiadavky alebo ID úlohy funguje rovnako dobre ako GUID, keď vám ho okolitý rámec už odovzdá, a robí názov súboru sledovateľným späť do riadku protokolu zadarmo. Tak či onak, princíp je rovnaký: navrhnite to tak, aby súbor, ktorý zapisujete, patril v momente zápisu iba vám. Zámok nezmizne preto, že ste násilne zatvorili okno, ale preto, že sa bajtov nedotýka nič iné

Tvar opravy

Obnažte oba problémy až na ich korene a oba sú o rešpektovaní hraníc. Chyba stavového stroja chce, aby ste rešpektovali hranicu inštancie: jedno THotPDF, jeden dokument, a potom ho nechať ísť a vytvoriť ďalší. Chyba zámku súboru chce, aby ste rešpektovali hranicu súboru: zapisovať tam, kde nič iné nečíta, potom presunúť výsledok na miesto. Ani jedno nevyžaduje záplaty knižnice ani skriptovanie desktopu. Obe vyplynú z prístupu k prístupu ku každému dokumentu ako k samostatnej pracovnej jednotke, vytvorenej nanovo, zapísanej čisto a uvoľnenej, čo je ten istý vzorec, vďaka ktorému je zvyšok komponentu predvídateľný

Volania BeginDoc, EndDoc, LoadFromFile a SaveLoadedDocument zobrazené tu sú súčasťou komponentu HotPDF pre Delphi a C++Builder