Artykuł techniczny

Ponowne użycie THotPDF dla wielu dokumentów w Delphi

Jedna instancja THotPDF może zapisywać dowolną liczbę dokumentów jeden po drugim: BeginDoc po EndDoc otwiera nowy, niezależny dokument. Ponowne użycie pojedynczej instancji dla wielu dokumentów jest dostępne od HotPDF v2.764.0. Komunikat Please load the document before using BeginDoc należy do sąsiedniego strażnika: LoadFromFile zgłasza go, gdy dokument rozpoczęty przez BeginDoc nie został jeszcze zamknięty przez EndDoc. Oba zachowania wynikają z cyklu życia komponentu, a gdy to zaskoczy, żadne nie jest już tajemnicze

Ponowne użycie THotPDF w Delphi: jedna instancja wykonuje BeginDoc, rysowanie i EndDoc dla każdego pliku wyjściowego między pojedynczym Create a Free, albo pętla wsadowa tworzy świeżą instancję dla każdego pliku
Każdy dokument wykonuje BeginDoc, rysowanie, EndDoc; jedna instancja THotPDF może powtórzyć ten cykl przed Free, albo każdy plik wyjściowy dostaje własną świeżą instancję

Czy instancja THotPDF to jeden dokument, czy fabryka dokumentów

Instancja THotPDF to fabryka dokumentów: możesz utworzyć ją raz, na przykład jako komponent upuszczony na formularz, i karmić ją dokumentami, podobnie jak trzymasz otwarte połączenie z bazą danych i wykonujesz w nim zapytanie za zapytaniem. BeginDoc otwiera dokument i oznacza instancję jako mającą dokument w toku. EndDoc serializuje wszystko do FileName, a potem porzuca wszystko zarejestrowane dla tego pliku, w tym jego obrazy, czcionki, pola formularzy, konspekty, JavaScript dokumentu, nazwane cele, etykiety stron, profile ICC i output intents. Strony i obiekty zapisanego dokumentu pozostają czytelne, dopóki następne BeginDoc ich nie zwolni, więc ValidatePDFUA2Structure, CurrentPageNumber i PagesCount działają dalej po EndDoc. Przy włączonym ReproducibleOutput drugi dokument z użytej ponownie instancji jest bajt w bajt taki sam jak ten dokument z nowej instancji

Cykl życia we właściwej kolejności

Każdy dokument, który HotPDF tworzy od podstaw, przechodzi przez te same cztery etapy i ich kolejność nie podlega negocjacjom. Create alokuje pamięć na komponent. BeginDoc otwiera dokument i ustala jego strukturę, więc wszystko, co wpływa na cały plik (rozmiar strony, kompresja, szyfrowanie, nazwa pliku wyjściowego), musi być ustawione między Create i BeginDoc. Potem wykonujesz rysowanie. Następnie EndDoc zapisuje bajty na dysku. Free zwalnia instancję. Wywołania poleceń rysowania wykonane przed BeginDoc nie mają strony, na której można je umieścić; właściwości obejmujące cały dokument przypisane po nich są ignorowane bez żadnego komunikatu. W użytej ponownie instancji ustawienia właściwości, takie jak FileName, Compression, PDFACompliance, Author i Title, przenoszą się na kolejny dokument, więc ustaw je ponownie przed każdym BeginDoc, gdy mają być inne

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // otwiera dokument
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // zapisuje invoice.pdf, zamyka go
  finally
    Pdf.Free;                            // jedna instancja, jeden dokument
  end;
end;

Czytaj to jako jednostkę pracy: jedno BeginDoc, jedno EndDoc, jeden plik na dysku. Drugi plik to druga para BeginDoc i EndDoc, na tej samej instancji albo na nowej

Co oznacza "ponowne użycie": jedna instancja albo świeża instancja na plik

Oszczędna wersja działa: zbuduj komponent raz, przejdź pętlą po partii i wywołuj BeginDoc oraz EndDoc wewnątrz pętli. Każda iteracja produkuje niezależny plik, i nic zarejestrowane dla jednego dokumentu nie sięga następnego. Jedna pułapka pozostaje przy chronionym wyjściu: EndDoc czyści UserPassword i OwnerPassword, więc przy wciąż włączonym ActivateProtection musisz ustawić hasła ponownie przed następnym BeginDoc, które inaczej zgłosi wyjątek zamiast chronić plik pustymi hasłami. Wersja poniżej idzie drugą drogą i traktuje każde wyjście jako własny krótkotrwały obiekt. Koszt alokacji przy tworzeniu komponentu jest trywialny wobec pracy związanej z układaniem i serializacją PDF, więc niewiele da się zaoszczędzić, trzymając instancję przy sobie w obu wariantach

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // nowa instancja w każdym przebiegu
    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 siedzące wewnątrz pętli to część warta obrony podczas przeglądu kodu. Jeśli BeginDoc albo dowolne wywołanie rysowania zgłosi wyjątek w połowie jednego dokumentu, instancja tej iteracji nadal zostaje zwolniona, zanim następna się zacznie, więc jeden zły rekord nie uwięzi półzbudowanego komponentu i nie zatruje reszty przebiegu. Wyciągnięcie Create nad pętlę też działa, ale dokument porzucony w pół drogi przez wyjątek to dobry powód, by dać następnemu rekordowi świeżą instancję

Modyfikacja istniejącego pliku to inny punkt wejścia

Istnieje drugie, w pełni zasadne podejście do koncepcji "ponownego użycia": nie potrzebujesz tworzyć pustego dokumentu, ale zamiast tego chcesz otworzyć istniejący dokument PDF, a następnie nałożyć na niego modyfikacje. Ta konkretna ścieżka w ogóle nie wykorzystuje instrukcji BeginDoc i to właśnie z tego powodu komunikat błędu wyraźnie wskazuje na kwestię ładowania danych. Najpierw ładujesz określony plik, potem nanosisz zmiany, po czym możesz zapisać dokument z wykorzystaniem dowolnej nazwy

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 zwraca liczbę stron, a wartość zero lub mniejsza oznacza, że wczytywanie zawiodło, więc warto to sprawdzić, zanim dotkniesz CurrentPage. Parowanie ma znaczenie: dokument otwarty przez LoadFromFile jest zapisywany przez SaveLoadedDocument, a nie przez parę BeginDoc/EndDoc, która należy do dokumentów tworzonych od zera. Zmieszanie obu to najczęstszy sposób na wywołanie strażnika cyklu życia opisanego na początku tego artykułu. Trzymaj oba przepływy rozdzielone w głowie: BeginDoc ... EndDoc tworzy, LoadFromFile ... SaveLoadedDocument edytuje

Problem z blokadą pliku jest realny, a rozwiązaniem nie jest wymuszanie zamknięcia przeglądarek PDF

Błędy cyklu życia często podróżują z drugą skargą, a obie splątują się ze sobą, bo wychodzą na wierzchu w tym samym przepływie regenerowania pliku. Użytkownik otwiera PDF, który właśnie wyprodukowałeś, zostawia go otwartego w Acrobat albo Foxit, a potem wyzwala przebudowę. EndDoc próbuje zapisać tę samą ścieżkę, system operacyjny odmawia, bo przeglądarka trzyma read share blokujący pisarzy, a ty dostajesz porażkę odmowy dostępu. To jest autentyczny problem blokowania plików przez Windows, a nie problem stanu komponentu, i zasługuje na prawdziwą odpowiedź zamiast obejścia

Obejście polegające na wyliczaniu okien najwyższego poziomu i wysyłaniu WM_CLOSE do wszystkiego, czego tytuł wygląda jak przeglądarka PDF, jest błędem. Przekracza to granice procesów, aby zamknąć okna, których twój program nie posiada, zgaduje co jest przeglądarką po tytule i może usunąć niezapisane adnotacje użytkownika bez pytania. Traktuj to całe podejście jako niepożądane (code smell). Niezawodnym rozwiązaniem jest, aby nigdy nie zapisywać w ścieżce, którą inny proces może blokować. Dokonaj serializacji do pliku tymczasowego w tym samym katalogu, a następnie zamień go w docelowe miejsce za pomocą atomowej zmiany nazwy (atomic rename), gdy EndDoc zakończy się sukcesem. Jeśli przeglądarka nadal ma otwarty stary plik, zmiana nazwy albo powiedzie się czysto, albo głośno zawiedzie, i będziesz mógł wyświetlić wyraźny komunikat, zamiast walczyć z blokadą

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // Plik tymczasowy w TYM SAMYM katalogu co cel: zmiana nazwy w obrębie
  // jednego woluminu NTFS zamienia nazwę atomowo, podczas gdy przeniesienie
  // między woluminami degraduje się do kopiuj-i-usuń i traci tę gwarancję
  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;                    // plik tymczasowy jest tu kompletny na dysku
    finally
      Pdf.Free;
    end;

    // Zamień na miejscu. TFile.Move odmawia nadpisania, więc najpierw wyczyść
    // nieaktualny cel; jeśli przeglądarka wciąż trzyma stary plik, to usunięcie
    // zawiedzie, głośno, zanim dobre bajty zostaną dotknięte
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // albo: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // nigdy nie zostawiaj osieroconego, na wpół zapisanego pliku tymczasowego
    raise;
  end;
end;

Dwa szczere przypisy do tego kodu. TFile.Move i klasyczna funkcja RenameFile mapują do tej samej operacji zmiany nazwy w systemie Windows, która jest atomowa tylko wtedy, gdy źródło i cel znajdują się na tym samym woluminie, i to właśnie dlatego plik tymczasowy trafia do katalogu docelowego, a nie do TPath.GetTempPath. A sama para operacji usuń-i-przenieś nie jest jednym atomowym krokiem: istnieje krótkie okno czasowe, w którym żaden z plików nie istnieje. W przypadku aplikacji komputerowej regenerującej raport to okno jest bez znaczenia; czytelnicy, którzy potrzebują silniejszego kontraktu na tym samym woluminie, mogą wywołać bezpośrednio funkcje Win32 ReplaceFile lub MoveFileEx z flagą MOVEFILE_REPLACE_EXISTING, co sprowadza zamianę do jednego wywołania

W przypadku serwera o dużej przepustowości, który stale regeneruje dokumenty, czystszym podejściem jest zapisywanie każdego wyniku pod unikalną nazwą (znacznik czasu lub identyfikator zadania), tak aby dwa uruchomienia nigdy nie rywalizowały o jedną ścieżkę, a następnie pozwolenie oddzielnej polityce przechowywania na sprzątanie starych plików. Ten wzorzec to w zasadzie jedna linijka dyscypliny w nazewnictwie dla każdego żądania:

// Jedna ścieżka wyjściowa na żądanie: dwa równoległe zadania nigdy nie mogą
// rywalizować o tę samą nazwę, więc nie ma tańca ze zmianą nazwy ani blokady do stracenia
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Identyfikator żądania lub identyfikator zadania działa równie dobrze jak GUID, gdy otaczający framework już ci go udostępnia, i sprawia, że nazwę pliku można za darmo powiązać ze wpisem w dzienniku logów. W każdym przypadku zasada jest ta sama: projektuj tak, aby plik, który zapisujesz, był tylko i wyłącznie twój w momencie jego zapisywania. Blokada znika nie dlatego, że wymusiłeś zamknięcie okna, ale dlatego, że nic innego nie dotyka tych bajtów

Kształt rozwiązania

Zdejmij oba problemy do samych korzeni, a okażą się oba kwestią poszanowania granic. Błąd maszyny stanów każe ci uszanować granicę dokumentu: dokończ jeden dokument przez EndDoc, zanim rozpoczniesz albo wczytasz następny. Błąd blokady pliku każe ci uszanować granicę pliku: zapisuj tam, gdzie nikt inny nie czyta, a potem przenieś wynik na miejsce. Żaden nie wymaga obchodzenia komponentu ani skryptowania pulpitu. Oba wynikają z traktowania każdego dokumentu jako samodzielnej jednostki pracy, tworzonej od nowa, czysto zapisywanej i zwalnianej — to ten sam wzorzec, który sprawia, że reszta komponentu jest przewidywalna

Wywołania metod BeginDoc, EndDoc, LoadFromFile oraz SaveLoadedDocument pokazane w tym przykładzie, należą do komponentu HotPDF dla środowisk programistycznych Delphi i C++Builder