Technischer Artikel

THotPDF-Instanz in Delphi für mehrere Dokumente nutzen

Eine THotPDF-Instanz kann beliebig viele Dokumente nacheinander schreiben: BeginDoc nach EndDoc startet ein neues, unabhängiges Dokument. Die Mehrdokument-Wiederverwendung einer einzelnen Instanz ist seit HotPDF v2.764.0 verfügbar. Die Meldung Please load the document before using BeginDoc gehört zu einer benachbarten Absicherung: LoadFromFile wirft sie, wenn ein mit BeginDoc gestartetes Dokument noch nicht mit EndDoc geschlossen wurde. Beide Verhalten folgen aus dem Lebenszyklus der Komponente, und sobald der klickt, ist keins von beiden mehr rätselhaft

THotPDF-Wiederverwendung in Delphi: Eine Instanz fährt BeginDoc, Zeichnen und EndDoc für jede Ausgabedatei zwischen einem einzigen Create und Free, oder eine Batch-Schleife erzeugt eine frische Instanz je Datei
Jedes Dokument durchläuft BeginDoc, Zeichnen, EndDoc; eine THotPDF-Instanz kann diesen Zyklus vor dem Free wiederholen, oder jede Ausgabedatei bekommt eine eigene frische Instanz

Ist eine THotPDF-Instanz ein Dokument oder eine Dokumentfabrik?

Eine THotPDF-Instanz ist eine Dokumentfabrik: Sie können sie einmal erzeugen, etwa als auf ein Formular gezogene Komponente, und ihr Dokumente füttern, so wie man eine Datenbankverbindung offen hält und Abfrage um Abfrage durch sie schickt. BeginDoc öffnet ein Dokument und markiert die Instanz als mit einem Dokument in Arbeit. EndDoc serialisiert alles nach FileName und wirft danach alles weg, was für diese Datei registriert war: ihre Bilder, Fonts, Formularfelder, Outlines, Dokument-JavaScript, Named Destinations, Page Labels, ICC-Profile und Output Intents. Die Seiten und Objekte des geschriebenen Dokuments bleiben lesbar, bis das nächste BeginDoc sie freigibt, ValidatePDFUA2Structure, CurrentPageNumber und PagesCount funktionieren nach EndDoc also weiter. Mit ReproducibleOutput ist ein zweites Dokument einer wiederverwendeten Instanz Byte für Byte dasselbe wie dieses Dokument von einer neuen Instanz

Der Lebenszyklus, in der Reihenfolge, in der er ablaufen muss

Jedes Dokument, das HotPDF von null an schreibt, folgt denselben vier Takten, und die Reihenfolge ist nicht verhandelbar. Create allokiert die Komponente. BeginDoc öffnet das Dokument und fixiert die strukturellen Entscheidungen, alles, was die ganze Datei betrifft (Seitengröße, Kompression, Verschlüsselung, Ausgabedateiname), muss also zwischen Create und BeginDoc gesetzt werden. Dann zeichnen Sie. Dann schreibt EndDoc die Bytes auf die Disk. Free gibt die Instanz frei. Zeichenaufrufe vor BeginDoc haben keine Seite, auf der sie landen könnten; Dokument-eigene Properties, die danach zugewiesen werden, werden kommentarlos ignoriert. Auf einer wiederverwendeten Instanz tragen sich Property-Einstellungen wie FileName, Compression, PDFACompliance, Author und Title ins nächste Dokument fort, setzen Sie sie also vor jedem BeginDoc erneut, wenn sie sich unterscheiden

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // öffnet das Dokument
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // schreibt invoice.pdf und schließt es ab
  finally
    Pdf.Free;                            // eine Instanz, ein Dokument
  end;
end;

Lesen Sie das als Arbeitseinheit: ein BeginDoc, ein EndDoc, eine Datei auf der Disk. Eine zweite Datei ist ein zweites BeginDoc-und-EndDoc-Paar, entweder auf derselben Instanz oder auf einer neuen

Was „Wiederverwendung“ bedeutet: eine Instanz oder eine frische Instanz je Datei

Die sparsame Variante funktioniert: die Komponente einmal bauen, über einen Batch loopen und BeginDoc und EndDoc innerhalb der Schleife aufrufen. Jede Iteration produziert eine unabhängige Datei, und nichts, was für ein Dokument registriert war, erreicht das nächste. Eine Falle bleibt bei geschützter Ausgabe: EndDoc löscht UserPassword und OwnerPassword, bei noch eingeschaltetem ActivateProtection müssen Sie die Passwörter also vor dem nächsten BeginDoc erneut setzen, das sonst eine Exception wirft, statt die Datei mit leeren Passwörtern zu schützen. Die Variante unten nimmt den anderen Weg und behandelt jede Ausgabe als ihr eigenes kurzlebiges Objekt. Die Allokationskosten einer Komponente sind trivial verglichen mit der Arbeit, ein PDF zu layouten und zu serialisieren; die Instanz zu horten spart also sowieso kaum etwas

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // neue Instanz je Durchlauf
    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;

Das try/finally innerhalb der Schleife ist der Teil, den man im Review verteidigt. Wirft BeginDoc oder ein Zeichenaufruf mitten in einem Dokument, wird die Instanz dieser Iteration trotzdem freigegeben, bevor die nächste beginnt, ein schlechter Datensatz kann also keine halb gebaute Komponente stranden und den Rest des Laufs vergiften. Das Create über die Schleife zu ziehen funktioniert auch, aber ein von einer Exception halb aufgegebenes Dokument ist ein guter Grund, dem nächsten Datensatz eine frische Instanz zu gönnen

Eine bestehende Datei ändern ist ein anderer Einstiegspunkt

Es gibt eine zweite Lesart von „Wiederverwendung“, die völlig legitim ist: Sie wollen kein leeres Dokument, sondern ein bereits existierendes PDF öffnen und ändern. Dieser Pfad läuft gar nicht durch BeginDoc, weshalb die Fehlermeldung ja das Laden nennt. Sie laden die Datei, editieren sie und sichern unter dem Namen Ihrer Wahl

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 liefert die Seitenzahl zurück, ein Wert von null oder darunter bedeutet gescheitertes Laden, ein Blick darauf lohnt sich also, bevor Sie CurrentPage anfassen. Das Zusammenspiel zählt: Ein mit LoadFromFile geöffnetes Dokument wird mit SaveLoadedDocument gesichert, nicht mit dem BeginDoc/EndDoc-Paar, das zu Dokumenten gehört, die Sie von null an schreiben. Die beiden zu mischen ist der häufigste Weg, die am Anfang dieses Artikels beschriebene Lifecycle-Absicherung zu triggern. Halten Sie die beiden Flüsse gedanklich getrennt: BeginDoc ... EndDoc erzeugt, LoadFromFile ... SaveLoadedDocument editiert

Das Dateisperren-Problem ist real, und die Antwort ist nicht, Viewer-Fenster zu killen

Lifecycle-Fehler reisen oft mit einer zweiten Beschwerde, und die beiden verfilzen, weil sie im selben Datei-neu-erzeugen-Workflow auftauchen. Ein Nutzer öffnet das PDF, das Sie gerade produziert haben, lässt es in Acrobat oder Foxit offen und stößt dann einen Rebuild an. EndDoc versucht, denselben Pfad zu schreiben, das Betriebssystem verweigert das, weil der Viewer eine Read-Share hält, die Schreiber blockiert, und Sie bekommen einen Access-Denied-Fehler. Das hier ist wirklich ein Windows-Dateisperren-Problem und keines des Komponentenzustands, und es verdient eine echte Antwort statt eines Workarounds

Der Workaround, der kursiert — Top-Level-Fenster aufzählen und allem, dessen Titel wie ein PDF-Viewer aussieht, ein WM_CLOSE zu schicken — ist der falsche Instinkt. Er greift über Prozessgrenzen hinweg nach Fenstern, die Ihr Programm nicht besitzt, rät Viewer anhand des Titeltexts und kann ungespeicherte Annotationen eines Nutzers wegwerfen, ohne zu fragen. Betrachten Sie diesen ganzen Ansatz als Code Smell. Der verlässliche Fix ist, nie in einen Pfad zu schreiben, den ein anderer Prozess halten könnte. Serialisieren Sie in eine temporäre Datei im selben Verzeichnis und tauschen Sie sie nach erfolgreichem EndDoc per atomarem Rename an ihren Platz. Hat ein Viewer die alte Datei noch offen, scheitert das Rename entweder sauber oder laut, und Sie melden eine klare Botschaft, statt mit der Sperre zu kämpfen

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // Temp-Datei im SELBEN Verzeichnis wie das Ziel: ein Rename innerhalb
  // eines NTFS-Volumes tauscht den Namen atomar, während ein Move über
  // Volume-Grenzen zu Copy-plus-Delete verkommt und diese Garantie verliert
  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;                    // hier ist die Temp-Datei vollständig auf der Disk
    finally
      Pdf.Free;
    end;

    // Tausch an den Platz. TFile.Move überschreibt nicht, also räumen
    // Sie zuerst ein veraltetes Ziel weg; hält ein Viewer die alte Datei
    // noch, scheitert das Delete laut, bevor die guten Bytes angefasst werden
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // oder: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // nie eine halb geschriebene Temp-Datei stranden lassen
    raise;
  end;
end;

Zwei ehrliche Fußnoten zu dem Code. TFile.Move und das klassische RenameFile münden beide in denselben Windows-Rename, der nur atomar ist, wenn Quelle und Ziel auf demselben Volume liegen, und genau deshalb landet die Temp-Datei im Zielverzeichnis statt in TPath.GetTempPath. Und das Delete-then-Move-Paar ist nicht selbst ein atomarer Schritt: Es gibt ein kurzes Fenster, in dem keine der Dateien existiert. Für eine Desktop-App, die einen Report neu erzeugt, ist das Fenster irrelevant; Leser, die einen stärkeren Vertrag auf demselben Volume brauchen, rufen direkt das Win32-ReplaceFile oder MoveFileEx mit MOVEFILE_REPLACE_EXISTING auf, was den Tausch zu einem einzigen Aufruf zusammenfallen lässt

Für einen High-Volume-Server, der ständig Dokumente neu erzeugt, ist die sauberere Disziplin, jede Ausgabe unter einem eindeutigen Namen zu schreiben (ein Zeitstempel oder eine Job-ID), damit zwei Läufe nie um einen Pfad konkurrieren, und alte Dateien von einer separaten Retention-Policy aufräumen zu lassen. Das Muster ist eine Zeile Namensdisziplin je Anfrage

// Ein Ausgabepfad je Anfrage: zwei gleichzeitige Jobs konkurrieren nie
// um denselben Namen, also kein Rename-Ballett und keine Sperre zu verlieren
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Eine Request-ID oder Job-ID taugt genauso gut wie die GUID, wenn das umgebende Framework ohnehin eine mitliefert, und sie macht den Dateinamen gratis bis zur Logzeile rückverfolgbar. In beiden Fällen ist das Prinzip dasselbe: so entwerfen, dass die Datei, die Sie schreiben, im Moment des Schreibens allein Ihre ist. Die Sperre verschwindet nicht, weil Sie ein Fenster zwangsweise geschlossen haben, sondern weil nichts anderes die Bytes anfasst

Die Form des Fixes

Nimmt man beide Probleme auf ihre Wurzeln zurück, gehen beide um die Achtung von Grenzen. Der Zustandsautomaten-Fehler will, dass Sie die Dokumentgrenze ehren: ein Dokument mit EndDoc abschließen, bevor Sie das nächste starten oder laden. Der Dateisperren-Fehler will, dass Sie die Dateigrenze ehren: dort schreiben, wo nichts anderes liest, und das Ergebnis dann an seinen Platz bewegen. Keins von beiden verlangt, um die Komponente herumzuarbeiten oder den Desktop zu skripten. Beide ergeben sich fast von selbst daraus, jedes Dokument als in sich geschlossene Arbeitseinheit zu behandeln: frisch erzeugt, sauber geschrieben und freigegeben — dasselbe Muster, das den Rest der Komponente berechenbar macht

Die hier gezeigten Aufrufe BeginDoc, EndDoc, LoadFromFile und SaveLoadedDocument sind Teil der HotPDF Delphi Component für Delphi und C++Builder