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
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
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