Eine Merge-Schleife, die bei jedem Aufruf 1 als Einfügeposition übergibt, produziert eine Datei mit der richtigen Seitenzahl und den Dokumenten in umgekehrter Reihenfolge. Die letzte Datei der Liste landet auf Seite 1, denn Position 1 bedeutet "vor der aktuellen ersten Seite einfügen", und jede neue Quelle drückt die früheren weiter nach hinten. Nichts geht verloren, und genau deshalb übersteht der Fehler eine flüchtige Seitenzahl-Prüfung und taucht auf, wenn jemand das Bündel tatsächlich liest
Die seitenweise Schleife mit ImportPages
PDFium Component baut ein zusammengeführtes PDF in Delphi aus zwei TPdf-Instanzen: ein Ziel, das mit CreateDocument leer erzeugt wird, und eine Quelle, die jede Eingabedatei der Reihe nach öffnet. TPdf.ImportPages(Pdf, Range, PageNumber) kopiert die in Range genannten Seiten aus Pdf ins Ziel und gibt bei Erfolg True zurück. PageNumber ist 1-basiert und standardmäßig 1. Sie nennt die Zielposition, an der die erste importierte Seite landet, also fügt 1 vorne ein und PageCount + 1 hängt an
Am saubersten hält man die Reihenfolge der Liste, wenn man ganz auf einen Zähler verzichtet und das Ziel selbst nach seinem Ende fragt. Ein mitlaufender InsertAt-Zähler funktioniert auch, aber er muss um die Anzahl tatsächlich importierter Seiten weitergestellt werden, und diese Zahl ist leicht falsch, sobald Bereiche ins Spiel kommen. PdfDest.PageCount + 1 ist immer richtig:
procedure MergeFileList(const FileList: TStrings; const OutputPath: string);
var
PdfDest, PdfSrc: TPdf;
I: Integer;
begin
PdfDest := TPdf.Create(nil);
PdfSrc := TPdf.Create(nil);
try
PdfDest.CreateDocument;
for I := 0 to FileList.Count - 1 do
begin
PdfSrc.FileName := FileList[I];
PdfSrc.Active := True; // wirft bei Fehlschlag nicht
if not PdfSrc.Active then
raise Exception.CreateFmt('Cannot open %s', [FileList[I]]);
// 1-basierte Position: PageCount + 1 hängt nach der letzten Seite an
if not PdfDest.ImportPages(PdfSrc,
'1-' + IntToStr(PdfSrc.PageCount), PdfDest.PageCount + 1) then
raise Exception.CreateFmt('Page import failed for %s', [FileList[I]]);
PdfSrc.Active := False; // FileName lässt sich nur im inaktiven Zustand ändern
end;
if not PdfDest.SaveAs(OutputPath) then
raise Exception.Create('Cannot write ' + OutputPath);
finally
PdfSrc.Free;
PdfDest.Free;
end;
end;
Zwei Prüfungen in dieser Schleife tragen echtes Gewicht. Active := True zu setzen wirft nie: Fehlt die Datei, ist sie beschädigt oder braucht ein Passwort, fängt TPdf den Ladefehler ab und lässt Active auf False. Die Komponente bleibt in einem konsistenten geschlossenen Zustand, und ein Formular stürzt an einer schlechten Datei nicht ab. Eine Merge-Schleife, die die Prüfung überspringt, produziert allerdings einfach eine kürzere Ausgabe. Die zweite Prüfung ist das boolesche Ergebnis von ImportPages, das False zurückgibt, wenn die Bereichszeichenkette sich nicht parsen lässt oder die Position hinter PageCount + 1 liegt. Zu beachten außerdem: FileName und Password lassen sich nur zuweisen, während die Komponente inaktiv ist, weshalb die Quelle am Ende jedes Durchgangs geschlossen wird
Bereichszeichenketten: was der Parser akzeptiert
Das Bereichsargument ist eine kommaseparierte Liste aus Einzelseiten und geschlossenen Spannen, nummeriert ab 1 im Quelldokument. '1-3' nimmt die ersten drei Seiten, '2,4,6' drei konkrete, und '1,3-5' ein Deckblatt plus eine dreiseitige Zusammenfassung. Die Zahlen beziehen sich immer auf die Quelle; wo diese Seiten im Ziel landen, entscheidet allein das Positionsargument
Offene Spannen sind die Stolperfalle. Eine Zeichenkette wie '7-' sieht nach "Seite 7 bis zum Ende" aus, aber der PDFium-Bereichsparser verlangt beide Enden einer Spanne und weist die ganze Zeichenkette zurück. Dasselbe passiert mit einer umgekehrten Spanne ('5-3'), einer Seitenzahl 0 oder einer Seite jenseits der Seitenzahl der Quelle. Ein schlechter Eintrag lässt den gesamten Aufruf scheitern: ImportPages gibt False zurück, und keine Seite dieser Zeichenkette wird kopiert. Deshalb baut die Schleife oben '1-' + IntToStr(PdfSrc.PageCount), statt '1-' zu schreiben
Wer Seitenindizes ohnehin in einem Array hält, überspringt mit ImportPagesByIndex(Source, PageIndices, InsertAt) den Zeichenkettenschritt. Allerdings gilt eine andere Konvention: Sowohl die Indizes als auch InsertAt sind dort nullbasiert, Anhängen ist also InsertAt = PdfDest.PageCount, und die Deckblatt-plus-Zusammenfassung-Auswahl wird zu [0, 2, 3, 4]. Ein leeres Array importiert jede Seite
Gibt es ein Merge für eine Dateiliste in einem Aufruf?
Ja. TPdf.MergeFiles(Sources, Options, Report) nimmt ein Array aus TPdfBatchMergeSource-Records, jeder gebaut mit TPdfBatchMergeSource.FromFile(FileName, PageRange, Password), öffnet sie einzeln und füllt einen TPdfBatchMergeReport mit dem, was passiert ist. Ein leerer PageRange bedeutet jede Seite. Die Bereichssyntax ist dasselbe Komma-und-Spanne-Format, und ein fehlerhafter Bereich wird ein Fehler je Datei im Report statt einem stillschweigenden Überspringen
Die Voreinstellungen aus TPdfBatchMergeOptions.Default sollte man kennen, bevor man eine davon ändert. CreateDestination ist True, ein inaktives Ziel wird also für einen angelegt. InsertAt ist -1, also Anhängen; jeder andere Wert ist ein nullbasierter Index, passend zu ImportPagesByIndex und eben nicht zu ImportPages. ErrorPolicy ist bmepStop und RollbackOnCancelOrFailure ist True, also beendet eine unlesbare Datei den Lauf und entfernt jede Seite, die der Lauf bereits hinzugefügt hatte. CopyViewerPreferences kopiert die Viewer-Preferences der ersten Quelle, die erfolgreich importiert wird
procedure BuildBundle(const OutputPath: string; Log: TStrings);
var
Dest: TPdf;
Sources: TPdfBatchMergeSources;
Options: TPdfBatchMergeOptions;
Report: TPdfBatchMergeReport;
I: Integer;
begin
SetLength(Sources, 3);
Sources[0] := TPdfBatchMergeSource.FromFile('cover.pdf');
Sources[1] := TPdfBatchMergeSource.FromFile('annual-report.pdf', '1,3-5');
Sources[2] := TPdfBatchMergeSource.FromFile('appendix.pdf', '', 'user-password');
Options := TPdfBatchMergeOptions.Default;
Options.ErrorPolicy := bmepSkip; // über unlesbare Dateien hinweg weitermachen
Dest := TPdf.Create(nil); // inaktiv: MergeFiles legt es an
try
if not Dest.MergeFiles(Sources, Options, Report) then
raise Exception.Create('Merge failed: ' + Report.ErrorMessage);
for I := 0 to High(Report.Issues) do
Log.Add(Format('%s: %s',
[Report.Issues[I].FileName, Report.Issues[I].MessageText]));
Log.Add(Format('%d pages from %d of %d files',
[Report.ImportedPageCount, Report.ImportedSourceCount, Report.SourceCount]));
Dest.SaveAs(OutputPath);
finally
Dest.Free;
end;
end;
Welche Fehlerpolitik die richtige ist, hängt davon ab, was eine fehlende Datei für die Ausgabe bedeutet. Für einen Vertragsordner oder einen Rechnungslauf ist ein Bündel ohne einen seiner Bestandteile ein falsches Dokument: bei der Voreinstellung bmepStop mit Rollback bleiben. Auf bmepSkip nur für Best-Effort-Sammlungen wechseln, etwa einen Wochenrücklauf von Berichten, und dann bmrsCompletedWithErrors in Report.Status als etwas behandeln, das ein Mensch ansehen muss. Ganz gleich, was man wählt: Report.Issues lesen, jeder Eintrag trägt Quellenindex, Dateiname und Meldung, womit die Frage "welche Datei war der Übeltäter" beantwortet ist, die die handgeschriebene Schleife mit eigenen Exceptions beantworten muss
Zwei weitere Optionen zählen bei langen Läufen. OnProgress und CancellationToken lassen eine UI die aktuelle Datei zeigen und den Lauf stoppen, und ein abgebrochener Lauf wird wie ein gescheiterter zurückgerollt. ResourcePolicy steht standardmäßig auf bmrpSourceScoped, das importiert die ausgewählten Seiten jeder Quelle in einem nativen Aufruf, sodass Schriften und Bilder, die diese Seiten teilen, einmal kopiert werden. bmrpChunked importiert in Häppchen von PageChunkSize Seiten, was feinere Abbruchpunkte bringt zum Preis, dass geteilte Ressourcen je Häppchen erneut kopiert werden
Was eine importierte Seite mitbringt, und was in der Quelle bleibt
Eine importierte Seite bringt alles mit, was von ihrem Seiten-Dictionary aus erreichbar ist: den Content-Stream, die Schriften, Bilder und Form-XObjects in ihren Ressourcen und ihre Annotationen, Kommentare, Markierungen und Freihand eingeschlossen. Was auf Dokumentebene in der Quelle lebt, kommt nicht mit, denn ISO 32000-1 hält es außerhalb des Seitenbaums
- Das Document-Information-Dictionary (ISO 32000-1 §14.3.3) bleibt zurück, also hat die zusammengeführte Datei keinen der Title-, Author- oder Keywords-Einträge der Quellen. Die Eigenschaften
TitleundAuthoranTPdfsind schreibgeschützt, was sie handlich macht, um mitzuloggen, was jede Quelle deklariert hat, bevor man sie schließt - Outlines (§12.3.3) fallen unter der Voreinstellung
OutlinePolicyvonbmopDiscardweg;bmopRejectmacht aus einer Quelle mit Outline stattdessen einen Fehler je Datei. Um Bookmarks zu behalten, fügt die UnitFPdfMergeMergeFilesWithNavigationhinzu, das Outlines neu aufbaut, kollidierende Named Destinations standardmäßig mit einemImported_-Präfix umbenennt und Links zwischen Seiten ummappt; der Artikel zu Outline-Bearbeitung und Seiten-Remapping beschreibt, wie diese Destinations umgeschrieben werden - Verschlüsselung (§7.6) gehört zur Quelldatei. Eine geschützte Quelle öffnet mit ihrem
Password, aber ein mitCreateDocumenterzeugtes Ziel ist unverschlüsselt, und die hineinkopierten Seiten ebenso. Muss die zusammengeführte Datei geschützt sein, sie mitSaveAsEncryptedstattSaveAsschreiben
Warum funktionieren Formularfelder nach einem Merge nicht mehr?
Formularfelder verteilen sich auf zwei Ebenen eines PDF. Die Widget-Annotation, die ein Textfeld zeichnet, sitzt auf der Seite, während das Feld selbst mit Name und Wert im /Fields-Array des interaktiven Form-Dictionary des Katalogs gelistet ist (ISO 32000-1 §12.7.2). Ein nativer Seitenimport kopiert das Widget zusammen mit den anderen Seitenannotationen, aber nichts hängt es an den Feldbaum des Ziels an. Das Kästchen zeichnet weiterhin seine letzte Erscheinung. Ein Viewer, der sein Formularmodell aus /AcroForm baut, hat jedoch kein Feld, an dem er das Widget festmachen könnte, der Wert lässt sich also nicht bearbeiten, und jede Formularoperation, die den Feldbaum abläuft, überspringt es
TPdfBatchMergeOptions.FormPolicy gibt drei Antworten für ein schlichtes Merge. bmfpPreserveNative, die Voreinstellung, behält das oben beschriebene Ergebnis des nativen Imports. bmfpFlatten erzeugt Erscheinungsbilder und flattent die ausgewählten Seiten jeder formularführenden Quelle im Speicher, bevor sie importiert werden, die Werte werden zu gewöhnlichem Seiteninhalt; die Quelldatei auf der Platte wird nicht verändert. bmfpReject macht aus jeder Quelle mit AcroForm- oder XFA-Formular einen Fehler je Datei
Müssen die Felder ausfüllbar bleiben, kommt MergeFilesWithForms aus der Unit FPdfFormMerge zum Zug. Es importiert den Seiteninhalt, baut die Widgets aus dem Feldbaum jeder Quelle neu auf und pfropft diese Felder in das Zielformular. Feldnamen sind der harte Teil. Nach §12.7.3.2 sind zwei Felder mit demselben vollqualifizierten Namen dasselbe Feld und teilen sich einen Wert, also würde ein Total-Feld in einer Rechnung und ein Total-Feld in einem Lieferschein sich gegenseitig überschreiben. Die Voreinstellung CollisionPolicy ist pagcpReject, das stoppt das Merge beim ersten Zusammenprall:
uses
PDFium, FPdfCompress, FPdfFormMerge;
procedure MergeFillableForms(Dest: TPdf;
const Sources: TPdfBatchMergeSources; Log: TStrings);
var
Options: TPdfFormMergeOptions;
Report: TPdfFormMergeReport;
begin
Options := TPdfFormMergeOptions.Default;
// Kollidierende Namen bekommen das RenamePrefix ('Imported_', sofern nicht geändert)
Options.GraftOptions.CollisionPolicy := pagcpRename;
if Dest.MergeFilesWithForms(Sources, Options, Report) then
Log.Add(Format('%d fields grafted, %d renamed',
[Report.GraftedFieldCount, Report.RenamedFieldCount]))
else
Log.Add('Form merge failed: ' + Report.ErrorMessage);
end;
pagcpNamespace legt die Felder jeder Quelle stattdessen unter ein eigenes Präfix (Imported plus Quellennummer), und pagcpMergeCompatible vereint gleichnamige Felder nur, wenn ihre Typen und Optionen übereinstimmen. MergeFilesWithForms arbeitet auf AcroForm-Quellen und -Zielen ohne Signaturfelder. XFA-Formulare und signierte Dateien weist es rundherum zurück, denn das Umschreiben einer signierten Datei entwertet den in ISO 32000-1 §12.8.1 beschriebenen signierten Byte-Bereich. Ein offenes Ziel muss außerdem unverschlüsselt sein: Das Zusammenführen in eine passwortgeschützte Datei scheitert, also Schutz danach mit SaveAsEncrypted hinzufügen. Eine Seite, die in einer Quelle zweifach ausgewählt wird, wie in '1,1', braucht eine explizite RepeatedPagePolicy
Das Zieldokument wird erst ersetzt, nachdem das bereitgestellte Ergebnis erneut geöffnet und geprüft wurde. Feldnamen, Werte und Typen werden seitenweise verglichen, und ein gescheitertes Formular-Merge lässt Dest, wie es war. Der Artikel zum AcroForm-Pfropfen geht tiefer in die Kollisionsbehandlung
Das zusammengeführte Dokument speichern
SaveAs hat eine Datei-Überladung und eine TStream-Überladung, beide mit optionalem TSaveOption (Voreinstellung saNone) und TPdfVersion (Voreinstellung pvUnknown, was den Header der Bibliothek überlässt). Beide geben einen Boolean zurück, und in einem Stapellauf verdient dieses Ergebnis dieselbe Prüfung wie Active. Die Version nur dann mit etwas wie pv17 festnageln, wenn ein Downstream-System auf einem bestimmten Header besteht
Die Gegenrichtung, eine Datei in mehrere zu zerschneiden, nutzt dieselben Bereichszeichenketten und dieselben 1-basierten Positionen; der Artikel zum Aufteilen führt durch sie
ImportPages, MergeFiles und das hier beschriebene formularbewusste Merge kommen mit der PDFium Component, die diese Seitenoperationen in Delphi- und C++Builder-Anwendungen verfügbar macht