Technischer Artikel

Performance der HotPDF-Seitenextraktion in Delphi

Zwei Minuten, um drei Seiten aus einer 40-seitigen PDF zu kopieren, sind kein Performance-Tuning-Problem. Sie sind ein Signal dafür, dass der falsche API-Pfad verwendet wird. Als ich diese Zeitmessung zum ersten Mal bei einem Seitenkopier-Beispiel der HotPDF Delphi Component sah, war mein Instinkt, zuerst auf die Dokumentstruktur und erst dann auf den Code zu schauen. Diese Reihenfolge erwies sich als wichtig

Was tatsächlich langsam war

Die fragliche PDF war ein 40-seitiges Referenzdokument mit einem nicht trivialen Seitenbaum: mehrere /Pages-Zwischenknoten statt eines einzigen flachen Arrays. Der ursprüngliche Beispielcode rief LoadFromFile auf, baute dann mit BeginDoc ein neues Dokument, iterierte über die ausgewählten Seitennummern und lud bei jeder Iteration das Quelldokument erneut von der Platte, um eine Seite herauszuziehen. Das sind die vollen Parsing-Kosten multipliziert mit der Anzahl der gewünschten Seiten. Eine 12-MB-Datei traf für eine dreiseitige Extraktion sechsmal auf die Platte, weil niemand geprüft hatte, ob die Datei über die Iterationen hinweg offen bleiben musste

Der zweite Faktor war im Code unsichtbar: LoadFromFile von HotPDF löst beim Laden die gesamte Querverweistabelle auf und dekomprimiert jeden Objektstream. Das ist das richtige Verhalten für ein Dokument, das Sie gleich ändern werden, aber es ist mehr Arbeit, als Sie brauchen, wenn Sie nur die Seitenanzahl und eine Teilmenge der Seiten wollen. Für den Nur-Lese-Zugriff auf die Struktur vermeidet DAOpenFileReadOnly das Deserialisieren des vollständigen Objektbaums, was bei komprimierten Dateien mit großen Bildressourcen zählt

Keines davon ist ein Fehler der Bibliothek. Beides sind Aufrufer, die die für eine Aufgabe entworfene API wählen und sie für eine andere verwenden

Diagramm, das wiederholte LoadFromFile-Aufrufe innerhalb einer Extraktionsschleife einem einzelnen Parsen mit anschließendem InsertPagesFromDocument 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 Parsen gefolgt von einem Masseneinfügen bringt dieselbe Arbeit unter zwei Sekunden

InsertPagesFromDocument für die Seitenextraktion verwenden

Der richtige Pfad, um einen Seitenbereich aus einem HotPDF-Dokument in ein anderes zu kopieren, ist InsertPagesFromDocument, aufgerufen nach LoadFromFile auf der Quelle. Sie laden die Quelle einmal, laden oder erzeugen das Ziel einmal, verschieben die Seiten und speichern. Die Quelle bleibt über alle Seiteneinfügungen hinweg im Speicher:

procedure ExtractPages(const SourceFile, DestFile: string;
  const PageRange: string);
var
  Source, Dest: THotPDF;
begin
  Source := THotPDF.Create(nil);
  Dest   := THotPDF.Create(nil);
  try
    // Quelle einmal laden: das vollständige Parsen passiert hier und nur hier
    Source.LoadFromFile(SourceFile);

    // Ein minimales Zieldokument aufbauen
    Dest.FileName := DestFile;
    Dest.BeginDoc;

    // Den angeforderten Bereich kopieren; '1-3' fügt die Seiten 1 bis 3
    // ab Position 1 im Zieldokument ein
    Dest.InsertPagesFromDocument(Source, PageRange, 1);

    Dest.EndDoc;
  finally
    Source.Free;
    Dest.Free;
  end;
end;

Der Parameter PageRange akzeptiert dasselbe Format wie das Kommandozeilenbeispiel: eine kommagetrennte Liste von Seitennummern oder Bereichen wie '1-3' oder '1,5,7-9'. Seiten sind 1-basiert. InsertPagesFromDocument kopiert Inhaltsstreams, Ressourcen-Dictionaries und Seitengeometrie, ohne Metadaten, Lesezeichen oder eingebettete Dateianhänge anzurühren, es sei denn, sie werden von den kopierten Seiten referenziert. Für eine dreiseitige Extraktion aus einem 40-seitigen Dokument ist das ein kleines Working Set

Zeitmessung auf derselben 12-MB-Datei, die zuvor zwei Minuten lief: unter 1,5 Sekunden mit diesem Muster. Der Großteil dieser Zeit entfällt auf den einzelnen 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 in Batch-Kontexten zu prüfen ist: 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 die Bildregistrierungen früherer Dokumente in der Liste. Erzeugen Sie entweder pro Dokument eine frische Instanz oder rufen Sie zwischen den Dokumenten den Pfad zum Leeren der Bildliste auf

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 Zwei-Minuten-Extraktion, mit der dieser Beitrag begann, erwies sich als vollständig dem Muster des wiederholten Ladens geschuldet. Die Dokumentstruktur trug nichts bei; ein flacher Seitenbaum wäre genauso gelaufen. Der Wechsel zu einem einzigen LoadFromFile gefolgt von einem InsertPagesFromDocument-Aufruf brachte sie auf derselben Hardware auf 1,3 Sekunden, ohne irgendetwas anderes anzurühren

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