Bei einem 40-seitigen Benutzerhandbuch, dessen erstes Top-Level-Lesezeichen auf Seite 5 zeigt, schreibt ein Splitter, der "von diesem Lesezeichen bis zum nächsten" schneidet, Kapiteldateien, die zusammen 36 Seiten ergeben. Deckblatt, Copyright-Seite und ein zweiseitiges Inhaltsverzeichnis sitzen vor jedem Outline-Eintrag, sie landen also in keiner Datei. Ein Outline markiert nur, wo Abschnitte beginnen. Es deckt nicht ab, was vor dem ersten Eintrag kommt, und nichts im Format verlangt, dass die Einträge in Seitenreihenfolge gelistet sind, also sammelt ein verlässlicher Splitter die Lesezeichen-Seiten als Schnittstellen ein, sortiert sie und ergänzt eine explizite Vorspann-Range
Eine Schleife hinter jedem Split-Modus
PDFium Component teilt ein Dokument mit denselben vier Schritten auf einem zweiten TPdf, was auch immer die Grenzen entscheidet: CreateDocument startet ein leeres PDF im Speicher, ImportPages kopiert einen Seitenbereich aus der Quelle hinein, SaveAs schreibt die Datei, und Active := False schließt die Ausgabe wieder. Seitenweises Splitten, Chunks fester Größe und Lesezeichen-Abschnitte unterscheiden sich nur im Range-String, der an ImportPages geht
Der Reset am Ende jedes Durchlaufs ist nicht optional. CreateDocument verweigert sich auf einer noch aktiven Komponente und wirft eine EPdfError, deren Meldung mit "Cannot perform this operation on an active" beginnt, also stoppt die zweite Iteration einer Schleife, die Active := False vergisst, mit einer Exception, nachdem exakt eine Datei geschrieben wurde. Den Reset in einen finally-Block zu legen verhindert, dass ein gescheiterter Import oder Save die Ausgabe für den nächsten Durchlauf offen lässt. Eine TPdf-Instanz bedient den ganzen Lauf, und nur ein Ausgabe-Dokument ist jemals im Speicher
Das Öffnen der Quelle verdient seine eigene Prüfung. Active := True zuzuweisen wirft nie: Fehlt die Datei, ist sie beschädigt oder braucht ein Passwort, das nicht übergeben wurde, fängt TPdf den Ladefehler ab und lässt Active auf False. ImportPages versucht zudem, die Quelle selbst zu aktivieren, eine ungeöffnete Quelle zeigt sich also nur als False-Rückgabe bei der ersten Seite und verrät die Ursache nicht. Sie explizit zu öffnen und Active zu prüfen macht daraus einen Fehler, der die Datei benennt, bevor irgendeine Ausgabe existiert:
procedure SplitIntoPages(const SourceFile, OutputDir: string);
var
Source, PdfOut: TPdf;
I: Integer;
OutFile: string;
begin
Source := TPdf.Create(nil);
PdfOut := TPdf.Create(nil);
try
Source.FileName := SourceFile;
Source.Active := True; // ein Ladefehler lässt Active = False
if not Source.Active then
raise Exception.Create('Cannot open ' + SourceFile);
for I := 1 to Source.PageCount do
begin
PdfOut.CreateDocument; // wirft, solange PdfOut noch aktiv ist
try
// Range ist 1-basiert; Position 1 ist der Anfang des leeren Dokuments
if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
raise Exception.CreateFmt('Cannot import page %d', [I]);
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('page_%.4d.pdf', [I]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False; // die Ausgabe schließen, vor dem nächsten Durchlauf
end;
end;
finally
PdfOut.Free;
Source.Free;
end;
end;
Das dritte Argument von ImportPages ist die 1-basierte Position im Ziel, an der die erste importierte Seite landet. In einem leeren Dokument ist 1 die einzige Position, die es gibt. Die Range-Syntax selbst (einzelne Seiten, geschlossene Spannen wie '5-10', Kommalisten) und die Positionsfehler, die einen zusammengeführten Bundle umdrehen, behandelt mehrere PDF-Dateien zu einem Dokument zusammenführen, dieselbe API in der anderen Richtung
Chunks fester Größe und Ranges vom Aufrufer
Ein Dokument alle N Seiten zu schneiden ist eine Zählaufgabe, und der einzige Fall, der Sorge braucht, ist der Rest. Ein 230-seitiger Scan in 25-Seiten-Teile ergibt neun volle Teile und einen zehnten mit fünf Seiten, also wird das Ende jeder Spanne an Source.PageCount geklemmt:
procedure SplitEvery(Source: TPdf; ChunkSize: Integer; const OutputDir: string);
var
PdfOut: TPdf;
FirstPage, LastPage, Part: Integer;
Range, OutFile: string;
begin
if ChunkSize < 1 then
raise Exception.Create('ChunkSize must be at least 1');
PdfOut := TPdf.Create(nil);
try
Part := 0;
FirstPage := 1;
while FirstPage <= Source.PageCount do
begin
LastPage := FirstPage + ChunkSize - 1;
if LastPage > Source.PageCount then
LastPage := Source.PageCount; // der letzte Teil ist meist kürzer
if FirstPage = LastPage then
Range := IntToStr(FirstPage) // ein einseitiger Rest als schlichte Zahl
else
Range := Format('%d-%d', [FirstPage, LastPage]);
Inc(Part);
PdfOut.CreateDocument;
try
if not PdfOut.ImportPages(Source, Range, 1) then
raise Exception.Create('Cannot import pages ' + Range);
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('part_%.3d.pdf', [Part]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False;
end;
FirstPage := LastPage + 1;
end;
finally
PdfOut.Free;
end;
end;
Von einem Nutzer getippte Ranges sind eine andere Sache. ImportPages meldet einen schlechten Range über seinen Rückgabewert: eine Seitenzahl über Source.PageCount hinaus, eine Null, eine umgedrehte Spanne oder Text, der nicht parst, lässt den Aufruf False zurückgeben, und keine der Seiten in diesem String wird kopiert. In einer Schleife kommt dieser Fehlschlag am vierten Eintrag an, während drei Abschnittsdateien schon auf der Platte liegen. Jeden Eintrag vor dem ersten CreateDocument gegen Source.PageCount zu prüfen kostet ein paar Zeilen Parsing und bedeutet, dass ein Tippfehler in Eintrag vier gar nichts schreibt, was das Verhalten ist, das jemand erwartet, der den Job erneut anstößt
Liegt die Auswahl schon in einem Integer-Array vor, etwa in einer Thumbnail-Ansicht gewählte Seiten, überspringt ImportPagesByIndex(Source, PageIndices, InsertAt) den String-Schritt. Sowohl die Indizes als auch InsertAt sind dort nullbasiert, dieselbe Auswahl, die als Range '1,3-5' liest, wird also [0, 2, 3, 4]
Warum kommen Lesezeichen-Abschnitte zu kurz oder fehlen ganz?
Lesezeichen-Abschnitte gehen schief, wenn der Code dem Outline Sauberkeit zutraut. TPdf.Bookmarks liefert die Top-Level-Einträge in der Ordnung, die der Autor angelegt hat, je als TBookmark-Record mit einem Title und einer 1-basierten PageNumber. Vier Dinge in echten Dateien brechen die naive Regel "dieser Eintrag bis zum nächsten":
- Ein Outline-Eintrag kann sein Ziel als
/Dest-Eintrag oder als GoTo-Aktion in/Atragen (ISO 32000-1 §12.3.3 und §12.6.4.2).PageNumberwird aus/Destgefüllt; bei einem aktionsbasierten Eintrag bleibt es0und die Seite steht stattdessen inActionPageNumber - Outline-Ordnung ist Lese-Ordnung, wie der Autor sie sah. Ein "Index"-Eintrag an zweiter Stelle oder ein Anhang vor den Kapiteln macht "nächstes Lesezeichen minus eins" kleiner als den aktuellen Start, und dieser Abschnitt verschwindet
- Zwei Top-Level-Einträge können auf dieselbe Seite zeigen, typischerweise ein Teil-Titel und sein erstes Kapitel
- Ein Eintrag kann hinter die letzte Seite zeigen, wenn Seiten mit einem Tool gelöscht wurden, das das Outline in Ruhe ließ
Die Version unten behandelt das Outline als Quelle von Schnittstellen. Sie löst die Seite jedes Eintrags auf, verwirft Einträge, die nirgendwohin oder hinter das Ende zeigen, behält den ersten Titel auf einer geteilten Seite, sortiert nach Seitenzahl und ergänzt einen "Front matter"-Abschnitt, wenn der erste Schnitt nach Seite 1 liegt. Der Vorspann bekommt hier eine eigene Datei, statt auf Kapitel 1 geklebt zu werden, denn wer Kapitel 1 bekommt, erwartet, dass es auf der Seite aufschlägt, die das Lesezeichen nannte, nicht auf einem Inhaltsverzeichnis
procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
Bm: TBookmarks;
Starts: array of Integer;
Titles: array of string;
I, J, K, N, P, EndPage: Integer;
Known: Boolean;
PdfOut: TPdf;
OutFile: string;
begin
Bm := Source.Bookmarks; // nur Top-Level-Einträge
if Length(Bm) = 0 then
Exit; // nichts zum Schneiden
SetLength(Starts, Length(Bm) + 1); // +1 lässt Platz für den Vorspann
SetLength(Titles, Length(Bm) + 1);
N := 0;
for I := 0 to High(Bm) do
begin
P := Bm[I].PageNumber; // aufgelöst aus /Dest
if P < 1 then
P := Bm[I].ActionPageNumber; // aufgelöst aus einer GoTo-Aktion in /A
if (P < 1) or (P > Source.PageCount) then
Continue; // unaufgelöst oder hinter der letzten Seite
Known := False;
for J := 0 to N - 1 do
if Starts[J] = P then
Known := True; // der erste Titel auf einer Seite gewinnt
if Known then
Continue;
J := N; // Insertion Sort nach Seitenzahl
while (J > 0) and (Starts[J - 1] > P) do
begin
Starts[J] := Starts[J - 1];
Titles[J] := Titles[J - 1];
Dec(J);
end;
Starts[J] := P;
Titles[J] := Bm[I].Title;
Inc(N);
end;
if N = 0 then
Exit;
if Starts[0] > 1 then // Deckblatt, Inhaltsverzeichnis und alles davor
begin
for J := N downto 1 do
begin
Starts[J] := Starts[J - 1];
Titles[J] := Titles[J - 1];
end;
Starts[0] := 1;
Titles[0] := 'Front matter';
Inc(N);
end;
PdfOut := TPdf.Create(nil);
try
for K := 0 to N - 1 do
begin
if K < N - 1 then
EndPage := Starts[K + 1] - 1
else
EndPage := Source.PageCount;
PdfOut.CreateDocument;
try
if not PdfOut.ImportPages(Source,
Format('%d-%d', [Starts[K], EndPage]), 1) then
raise Exception.CreateFmt('Cannot import pages %d-%d',
[Starts[K], EndPage]);
// SafeFileName (weiter unten gezeigt) vor dieser Routine deklarieren
OutFile := IncludeTrailingPathDelimiter(OutputDir) +
Format('%.2d_%s.pdf', [K + 1, SafeFileName(Titles[K])]);
if not PdfOut.SaveAs(OutFile) then
raise Exception.Create('Cannot save ' + OutFile);
finally
PdfOut.Active := False;
end;
end;
finally
PdfOut.Free;
end;
end;
Weil die Starts nach dem Sortieren streng monoton steigen, ist jede berechnete Spanne gültig, und die Absicherung an ImportPages sollte bei einer lesbaren Quelle nie feuern. Ein Dokument ohne Lesezeichen erzeugt in diesem Modus schlicht keine Dateien; ob das auf Chunking zurückfällt, entscheidet der Aufrufer. Um eine Ebene tiefer zu schneiden, etwa an Kapiteln innerhalb von Teilen, liefert BookmarkChildren[Bm[I]] die Kinder eines Top-Level-Eintrags in derselben TBookmarks-Form, und dieselbe Schnittstellen-Logik gilt für die kombinierte Liste
Was jede Ausgabedatei trägt
Jede Ausgabedatei trägt die Seiten selbst und alles, worauf ihre Seiten-Dictionarys verweisen: Content-Streams, Fonts, Bilder und die Annotation-Liste jeder Seite. Form-Widgets kommen als Annotationen mit, aber die neue Datei hat kein AcroForm, sie sind also keine funktionierenden Felder mehr. Die Quelle wird nur gelesen. ImportPages kopiert Objekte in das neue Dokument und lässt die offene Quelle unangetastet, dieselbe Source-Instanz speist also jede Iteration
Das Outline bleibt zurück. Lesezeichen leben im Dokumentkatalog unter /Outlines (ISO 32000-1 §7.7.2), außerhalb jeder Seite, eine aus importierten Seiten gebaute Datei beginnt also ohne jede Lesezeichen. Für Kapiteldateien ist das oft in Ordnung. Braucht jeder Teil seine eigene Navigation, muss das Outline für die neue Seitennummerierung gebaut werden, was das Thema von einem PDF-Outline bearbeiten und seine Seiten ummappen ist. Interne Links verhalten sich genauso: Ein "siehe Kapitel 7"-Link auf Seite 12 zeigt auf eine Seite, die in eine andere Datei gewandert ist, und in dieser Datei hat er nichts, worauf er landen kann
Verschlüsselte Quellen brauchen das Passwort, bevor die Schleife startet. Source.Password zuweisen, während die Komponente inaktiv ist, dann Active := True setzen; mit dem falschen Passwort bleibt die Komponente inaktiv, was die Prüfung in SplitIntoPages in einen Fehler verwandelt. Die von SaveAs geschriebene Ausgabe ist ein neues Dokument ohne Encryption-Dictionary. Das passt zu Split-Dateien für verschiedene Empfänger, und wenn jeder Teil wieder geschützt werden soll, nimmt SaveAsEncrypted(FileName, Options) einen TPdfEncryptOptions-Record mit eigenen Nutzer- und Eigentümer-Passwörtern
Dateinamen, gescheiterte Saves und Wiederholungen
Lesezeichen-Titel sind PDF-Text-Strings und können alles enthalten, auch Zeichen, die Windows in Dateinamen ablehnt. Die Regeln stammen aus dem Dateisystem, nicht aus PDF: < > : " / \ | ? * und Steuerzeichen sind ungültig, nachlaufende Leerzeichen und Punkte strippen die Win32-Datei-APIs, und Namen wie CON, PRN oder COM1 sind reservierte Geräte. Der nummerierte Präfix in SplitByBookmarks erledigt die Gerätenamen bereits, hält die Dateien in einer Verzeichnisauflistung in Lese-Ordnung und verhindert, dass zwei Kapitel, beide "Zusammenfassung" betitelt, sich gegenseitig überschreiben. Der Rest ist eine kleine Funktion, die Sie selbst schreiben:
// Reader-seitiger Helper, kein Teil von PDFium Component
function SafeFileName(const Title: string): string;
const
Forbidden = '<>:"/\|?*';
var
I: Integer;
begin
Result := Title;
if Length(Result) > 80 then
SetLength(Result, 80); // volle Pfade weit unter MAX_PATH halten
for I := 1 to Length(Result) do
if (Ord(Result[I]) < 32) or (Pos(Result[I], Forbidden) > 0) then
Result[I] := '_';
while (Result <> '') and
((Result[Length(Result)] = '.') or (Result[Length(Result)] = ' ')) do
SetLength(Result, Length(Result) - 1);
if Result = '' then
Result := 'section';
end;
SaveAs mit Dateinamen schreibt in eine temporäre Datei im Zielordner und tauscht sie erst nach gelingendem Save an ihren Platz. Das Ziel ist also entweder die vorherige Datei oder eine vollständige neue, nie halb geschrieben, einen Split über einen bestehenden Ausgabeordner erneut zu fahren ist also harmlos. Fehlschläge kommen auf zwei Wegen zurück. SaveAs gibt False zurück, wenn das Dokument nicht serialisiert werden kann, und die temporäre Datei wird entfernt. Probleme im Dateisystem, ein Ausgabeordner, der nicht existiert, ein Pfad, den das OS ablehnt, oder ein Ziel, das ein Viewer sperrt, werfen eine Exception aus der Datei-Schicht
Wie ein Batch auf diese Fehlschläge reagiert, ist eine Entscheidung, und fürs Splitten unterscheidet sie sich vom Mergen. Ein zusammengeführtes Bundle mit fehlendem Teil ist ein falsches Dokument, Stoppen ist dort also korrekt. Split-Ausgaben sind unabhängig voneinander: Lässt sich section_147.pdf nicht schreiben, weil ein Kollege sie offen hat, sind die anderen Abschnitte trotzdem gut. Für Split-Jobs: je-Datei-Exceptions innerhalb der Schleife fangen, Dateinamen und Meldung protokollieren, weitermachen, und am Ende der Schleife die Zahl der geschriebenen Dateien mit der Zahl der geplanten Ranges vergleichen. Die Beispiele oben werfen beim ersten Fehlschlag, nur um kurz zu bleiben. Ein Log, das 199 von 200 Dateien meldet und die gesperrte benennt, braucht einen Rerun, während ein Lauf, der bei Datei 147 abbrach, jemanden rechnen lässt, welche 53 Dateien fehlen
Die Split-Schleife, die Lesezeichen-Records und der verschlüsselte Save von hier kommen alle mit PDFium Component, der PDFium-basierten PDF-Bibliothek für Delphi und C++Builder