Technischer Artikel

Performance der HotPDF-Seitenextraktion in Delphi

Zwei Minuten, um drei Seiten aus einem 40-seitigen PDF zu kopieren, ist kein Performance-Tuning-Problem. Es ist ein Signal, dass der falsche API-Pfad verwendet wird. Als ich dieses Timing zum ersten Mal bei Seitenkopier-Code sah, der auf der HotPDF Delphi Component aufbaute, war mein Instinkt, zuerst die Dokumentstruktur anzusehen und erst danach den Code. Diese Reihenfolge stellte sich als verkehrt heraus

Was tatsächlich langsam war

Das PDF war ein 40-seitiges Referenzdokument mit einem nicht-trivialen Seitenbaum: mehrere Zwischen-/Pages-Knoten statt eines einzelnen flachen Arrays. Der Code rief LoadFromFile auf, baute dann mit BeginDoc ein neues Dokument, lief über ausgewählte Seitennummern und lud bei jeder Iteration das Quelldokument erneut von der Festplatte, um eine Seite zu ziehen. Das sind die vollen Parse-Kosten multipliziert mit der Anzahl der Seiten, die man haben will. Eine 12-MB-Datei kam bei einer dreiseitigen Extraktion sechsmal auf die Festplatte, weil niemand prüfte, ob die Datei über die Iterationen hinweg offen bleiben muss

Der zweite Beitragende war im Code unsichtbar: HotPDFs LoadFromFile baut das In-Memory-Objektmodell auf, auf dem die Bearbeitungs-API arbeitet. Das ist das richtige Verhalten für ein Dokument, das man gleich ändern will, aber es ist mehr Arbeit als nötig, wenn man nur die Seitenzahl und eine Teilmenge von Seiten will. Für schreibgeschützten Zugriff auf die Struktur vermeidet DAOpenFileReadOnly die Deserialisierung des gesamten Objektbaums, was bei komprimierten Dateien mit großen Bildressourcen zählt

Beides läuft darauf hinaus, eine API, die für eine Aufgabe entworfen wurde, für eine andere einzusetzen

Diagramm, das wiederholte LoadFromFile-Aufrufe innerhalb einer Extraktionsschleife einem einzelnen Parsen gefolgt von ExtractPagesToFile für schnelle PDF-Seitenextraktion in Delphi gegenüberstellt
Das Neuladen der Quelle bei jeder Iteration vervielfacht die vollen Parsing-Kosten und macht aus einer dreiseitigen Kopie einen Zwei-Minuten-Job. Ein einzelnes Parsen gefolgt von einem einzigen Extraktionsaufruf bringt dieselbe Arbeit auf etwa 1,3 Sekunden

Seitenextraktion mit ExtractPagesToFile

Der richtige Weg, einen Bereich von Seiten aus einem geladenen HotPDF-Dokument in eine neue Datei zu kopieren, ist ExtractPagesToFile, aufgerufen nach LoadFromFile der Quelle. Sie laden die Quelle einmal, und der Aufruf baut das Zieldokument, kopiert die ausgewählten Seiten hinein und schreibt es auf die Festplatte, alles aus der bereits im Speicher befindlichen Kopie:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source: THotPDF;
begin
  Source := THotPDF.Create(nil);
  try
    // Quelle einmal laden: das vollständige Parsen passiert hier und nur hier
    if Source.LoadFromFile(SourceFile) <= 0 then
      raise Exception.Create('Source PDF has no pages');

    // Den gewünschten Bereich ('1-3', '1,5,7-9') direkt in eine neue Datei schreiben
    Source.ExtractPagesToFile(DestFile, PageRange);
  finally
    Source.Free;
  end;
end;

Der Parameter PageRange akzeptiert dasselbe Format wie das Kommandozeilen-Beispiel: eine kommaseparierte Liste von Seitennummern oder Bereichen wie '1-3' oder '1,5,7-9'. Seiten sind 1-basiert. ExtractPagesToFile kopiert jede ausgewählte Seite zusammen mit den Content-Streams und Ressourcen, auf die sie verweist, sodass bei einer dreiseitigen Extraktion aus einem 40-seitigen Dokument der Arbeitssatz klein bleibt

Timing auf derselben 12-MB-Datei, die zuvor zwei Minuten lief: etwa 1,3 Sekunden mit diesem Muster. Der Großteil dieser Zeit ist der einzelne LoadFromFile-Aufruf. Die Dokumentstruktur ist irrelevant, sobald die Objekttabelle beim ersten Mal aufgelöst ist

Wenn LoadFromFile zu viel ist: die Direct File API

Wenn Sie nur Seiten zählen, Dokumentinformationen inspizieren oder eine Datei kopieren müssen, ohne ihren Inhalt anzurühren, vermeidet die Direct File API das vollständige Parsen gänzlich. DAOpenFileReadOnly bildet die Querverweistabelle ab, ohne Objektstreams zu dekomprimieren, sodass die Seitenanzahl O(xref-Größe) statt O(Dateigröße) kostet:

procedure InspectPDF(const FileName: string);
var
  Pdf: THotPDF;
  Handle, PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Handle := Pdf.DAOpenFileReadOnly(FileName, '');
    if Handle <= 0 then
      Exit;
    try
      PageCount := Pdf.DAGetPageCount(Handle);
      Writeln('Pages: ', PageCount);

      // DACopyFile ist eine byteerhaltende Kopie, keine Neuserialisierung
      Pdf.DACopyFile(FileName, 'archive-copy.pdf');
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;

Der Vorbehalt: DAOpenFileReadOnly akzeptiert einen Passwortparameter, fällt aber bei verschlüsselten Eingaben auf ein vollständiges Parsen zurück, weil die Entschlüsselung den Objektbaum braucht, um das Verschlüsselungs-Dictionary aufzulösen. Sind Ihre Quelldateien verschlüsselt, entschlüsseln Sie sie zuerst mit DecryptFile, um eine unverschlüsselte Kopie zu erhalten, und öffnen Sie diese dann mit der Direct File API. Die Funktion DecryptFile auf Dateiebene nimmt für die Standardverschlüsselung einen direkten AES-256-Umschreibpfad und ist bei großen Dateien schneller als LoadFromFile gefolgt von SaveLoadedDocument, weil sie nicht das vollständige Objektmodell im Speicher aufbaut

Entscheidungsdiagramm zur Wahl zwischen dem vollständigen HotPDF-Bearbeitungspfad und der Nur-Lese-Direct-File-API, wobei verschlüsselte Eingaben zuerst entschlüsselt werden
Nur-Lese-Aufgaben gehören auf den Pfad des direkten Dateizugriffs, wo das Seitenzählen mit der Querverweistabelle statt mit der Dateigröße skaliert. Verschlüsselte Eingaben fallen auf ein vollständiges Parsen zurück, sofern sie nicht zuerst entschlüsselt werden

Speicher bei der Verarbeitung großer Batches

Batch-Jobs, die Dutzende Dateien in einer Schleife verarbeiten, haben ein Muster, das korrekt aussieht, aber Speicher ansammelt: THotPDF innerhalb der Schleife erzeugen, LoadFromFile aufrufen, Arbeit erledigen, Free aufrufen. Das ist strukturell in Ordnung. Das Problem entsteht, wenn die innere Arbeit Hilfsobjekte anlegt, Exceptions abfängt und diese Hilfsobjekte auf Fehlerpfaden am Leben lässt. Der Speichermanager von Delphi kompaktiert nicht, sodass hundert Fehlerpfad-Lecks über einen Batch-Lauf den Speicher hoch genug treiben können, um die Allokation für alles andere zu verlangsamen

Die Abhilfe ist nicht exotisch. Jedes THotPDF und jeder zwischengeschaltete TStream oder TBitmap, der an PDF-Arbeit beteiligt ist, gehört in einen try/finally-Block, in dem Free die letzte Anweisung ist. Setzen Sie lokale Zeiger vor dem try auf nil, damit der finally-Zweig if Assigned(x) then x.Free sicher verwenden kann, wenn die Initialisierung auf halbem Weg fehlschlägt. Das ist Standard-Delphi-Besitzdisziplin und die ganze Geschichte für diese Problemklasse

Noch etwas, das man im Batch-Kontext wissen muss: AddImage registriert jedes Bild bei dem Dokument, das gerade gebaut wird, und HotPDF gibt diese Registrierungen frei, wenn EndDoc diese Datei abschließt. Die Registrierung folgt dem BeginDoc/EndDoc-Zyklus, nicht LoadFromFile, daher ist das saubere Muster für einen Batch eine THotPDF-Instanz pro Dokument: erzeugen, Arbeit erledigen, speichern, freigeben

Messen, bevor irgendetwas geändert wird

Bevor Sie zu einem dieser Muster greifen, messen Sie. Delphis TStopwatch aus System.Diagnostics kapselt QueryPerformanceCounter und ist für das Wanduhr-Profiling von Datei-E/A genau genug. Umhüllen Sie LoadFromFile allein und sehen Sie, wie viel Zeit darauf entfällt. Sind es 90 % der Gesamtzeit, ist die Abhilfe die Direct File API oder eine Verringerung der Häufigkeit, mit der Sie dieselbe Datei parsen. Sind es unter 20 %, liegt der Engpass woanders und Sie jagen dem Falschen hinterher

Die zweiminütige Extraktion, die diesen Beitrag auslöste, erwies sich als vollständig das wiederholte Lade-Muster. Die Dokumentstruktur trug nichts dazu bei; ein flacher Seitenbaum wäre genauso gelaufen. Der Wechsel zu einem einzelnen LoadFromFile gefolgt von einem ExtractPagesToFile-Aufruf brachte sie auf etwa 1,3 Sekunden auf derselben Hardware, ohne irgendetwas anderes anzufassen

Die hier gezeigte API zur Seitenmanipulation ist Teil der HotPDF Delphi Component für Delphi und C++Builder