Technischer Artikel

HotPDF-Seitenextraktionsleistung in Delphi

Zwei Minuten, um drei Seiten aus einem 40-seitigen PDF zu kopieren, sind kein Problem des Leistungs-Tunings. Es ist ein Signal, dass der falsche API-Pfad verwendet wird. Als ich dieses Timing zum ersten Mal auf einem Seitenkopier-Beispiel der HotPDF-Komponente sah, war mein Instinkt, zuerst auf die Dokumentstruktur und dann auf den Code zu schauen. Diese Reihenfolge stellte sich als wichtig heraus

Was tatsächlich langsam war

Bei dem betreffenden PDF handelte es sich um ein 40-seitiges Referenzdokument mit einem nicht-trivialen Seitenbaum: mehrere zwischengeschaltete /Pages-Knoten anstelle eines einzigen flachen Arrays. Der ursprüngliche Beispielcode rief LoadFromFile auf, baute dann mit BeginDoc ein neues Dokument auf, durchlief ausgewählte Seitenzahlen in einer Schleife und lud bei jeder Iteration das Quelldokument erneut von der Festplatte, um eine Seite zu ziehen. Das sind die vollen Parsing-Kosten multipliziert mit der Anzahl der gewünschten Seiten. Eine 12-MB-Datei wurde für eine Drei-Seiten-Extraktion sechsmal auf die Festplatte geschrieben, weil niemand prüfte, ob die Datei über Iterationen hinweg geöffnet bleiben musste

Der zweite Mitwirkende war im Code unsichtbar: HotPDFs LoadFromFile löst beim Laden die gesamte Querverweistabelle auf und dekomprimiert jeden Objekt-Stream. Das ist das richtige Verhalten für ein Dokument, das Sie ändern möchten, aber es ist mehr Arbeit als nötig, wenn Sie nur die Seitenanzahl und eine Teilmenge von Seiten möchten. Für den schreibgeschützten Zugriff auf die Struktur vermeidet DAOpenFileReadOnly die Deserialisierung des gesamten Objektbaums, was bei komprimierten Dateien mit großen Bildressourcen von Bedeutung ist

Keines davon ist ein Bibliotheksfehler. In beiden Fällen wählen die Aufrufer die für eine Aufgabe konzipierte API und verwenden sie für eine andere

Verwendung von InsertPagesFromDocument zur Seitenextraktion

Der richtige Pfad zum Kopieren eines Seitenbereichs von einem HotPDF-Dokument in ein anderes ist InsertPagesFromDocument, aufgerufen nach LoadFromFile für die Quelle. Sie laden die Quelle einmal, laden oder erstellen das Ziel einmal, verschieben die Seiten und speichern. Die Quelle bleibt über alle Seiteneinfügungen hinweg im Speicher:

var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest := THotPDF.Create(nil);
  try
    Source.LoadFromFile('reference.pdf');
    Dest.BeginDoc;
    
    // Copy pages 1, 5, and 7 through 9
    Dest.InsertPagesFromDocument(Source, '1,5,7-9');
    
    Dest.EndDoc;
    Dest.SaveToFile('extract.pdf');
  finally
    Dest.Free;
    Source.Free;
  end;
end;

Der Parameter PageRange akzeptiert dasselbe Format wie das Befehlszeilenbeispiel: eine durch Kommas getrennte Liste von Seitenzahlen oder Bereichen wie '1-3' oder '1,5,7-9'. Seiten basieren auf 1. InsertPagesFromDocument kopiert Inhaltsströme, Ressourcen-Wörterbücher und Seitengeometrie, ohne Metadaten, Lesezeichen oder eingebettete Dateianhänge zu berühren, es sei denn, sie werden von den kopierten Seiten referenziert. Für eine Drei-Seiten-Extraktion aus einem 40-seitigen Dokument ist das ein kleiner Arbeitssatz

Das Timing für dieselbe 12-MB-Datei, die zuvor zwei Minuten dauerte: unter 1,5 Sekunden mit diesem Muster. Der Großteil dieser Zeit ist der einzelne Aufruf von LoadFromFile. Die Dokumentstruktur ist irrelevant, sobald die Objekttabelle das erste Mal aufgelöst wurde

Wenn LoadFromFile zu viel ist: die Direct File API

Wenn Sie nur Seiten zählen, Dokumentinformationen prüfen oder eine Datei kopieren müssen, ohne ihren Inhalt zu berühren, vermeidet die Direct File API das vollständige Parsen komplett. DAOpenFileReadOnly bildet die Querverweistabelle ab, ohne Objekt-Streams zu dekomprimieren, sodass die Seitenzahl O(xref-Größe) statt O(Dateigröße) beträgt:

var
  Count: Integer;
begin
  if Pdf.DAOpenFileReadOnly('reference.pdf', '') = 0 then
  begin
    Count := Pdf.DAGetPageCount;
    // count is available instantly, objects are unparsed
    Pdf.DACloseFile;
  end;
end;

Der Vorbehalt: DAOpenFileReadOnly akzeptiert einen Passwortparameter, greift aber bei verschlüsselten Eingaben auf einen vollständigen Parse-Vorgang zurück, da die Entschlüsselung den Objektbaum benötigt, um das Verschlüsselungswörterbuch aufzulösen. Wenn Ihre Quelldateien verschlüsselt sind, 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 einen direkten AES-256-Umschreibungspfad für Standardverschlüsselung und ist schneller als LoadFromFile gefolgt von SaveLoadedDocument für große Dateien, da sie nicht das vollständige In-Memory-Objektmodell aufbaut

Speicher während großer Batch-Verarbeitungen

Batch-Aufträge, die Dutzende von Dateien in einer Schleife verarbeiten, weisen ein Muster auf, das korrekt aussieht, aber Speicher ansammelt: Erstellen von THotPDF innerhalb der Schleife, Aufrufen von LoadFromFile, Erledigen der Arbeit, Aufrufen von Free. Das ist strukturell in Ordnung. Das Problem ist, wenn die innere Arbeit temporäre Objekte ("Scratch-Objekte") zuweist, Ausnahmen abfängt und diese temporären Objekte bei Fehlerpfaden am Leben lässt. Delphis Speichermanager komprimiert nicht, sodass hundert Speicherlecks bei Fehlerpfaden während eines Batch-Laufs den Speicher so weit in die Höhe treiben können, dass die Zuweisung für alles andere verlangsamt wird

Die Lösung ist nicht exotisch. Jedes THotPDF und jeder dazwischenliegende TStream oder jedes TBitmap, das an der PDF-Arbeit teilnimmt, gehört in einen try/finally-Block, bei dem Free die letzte Anweisung ist. Setzen Sie lokale Zeiger vor dem try auf nil, damit der finally-Zweig sicher if Assigned(x) then x.Free verwenden kann, wenn die Initialisierung auf halbem Weg fehlschlägt. Dies ist eine Standard-Delphi-Besitzer-Disziplin und es ist die ganze Geschichte für diese Art von Problem

Noch etwas ist in Batch-Kontexten zu prüfen: AddImage registriert Bilder in einer internen Liste, die für die Lebensdauer der THotPDF-Instanz bestehen bleibt. Wenn Sie eine einzelne Instanz über viele Dokumente hinweg wiederverwenden, indem Sie LoadFromFile wiederholt aufrufen, bleiben Bildregistrierungen aus früheren Dokumenten in der Liste erhalten. Erstellen Sie entweder pro Dokument eine neue Instanz oder rufen Sie zwischen den Dokumenten den Löschpfad der Bildliste auf

Messen, bevor man etwas ändert

Bevor Sie zu einem dieser Muster greifen, messen Sie. Delphis TStopwatch aus System.Diagnostics umhüllt QueryPerformanceCounter und ist genau genug für ein Wall-Clock-Profiling der Datei-E/A. Wickeln Sie nur LoadFromFile ein und sehen Sie, wie lange es dauert. Wenn es 90 % der Gesamtzeit ausmacht, ist die Lösung die Direct File API oder eine Reduzierung der Häufigkeit, mit der Sie dieselbe Datei parsen. Liegt sie unter 20 %, liegt der Engpass woanders, und Sie jagen dem falschen Ding nach

Die Zwei-Minuten-Extraktion, mit der dieser Beitrag begann, stellte sich als reines Muster für wiederholtes Laden heraus. Die Dokumentstruktur trug nichts dazu bei; ein flacher Seitenbaum wäre genauso gelaufen. Der Wechsel zu einem einzigen LoadFromFile gefolgt von einem Aufruf von InsertPagesFromDocument brachte es auf derselben Hardware auf 1,3 Sekunden, ohne dass irgendetwas anderes berührt wurde

Die hier gezeigte Seitenmanipulations-API ist Teil der HotPDF-Komponente für Delphi und C++Builder