Technischer Artikel

PDF-Dokumente in Delphi mit PDFium Component aufteilen

PDFium Component gibt Ihnen genau eine Methode zum Aufteilen von PDF-Dateien: ImportPages. Alles andere, ob Sie nun eine einzelne Seite herauslösen, an beliebigen Grenzen schneiden oder der Lesezeichenstruktur des Dokuments folgen, sind nur unterschiedliche Arten zu entscheiden, welche Seitenzahlen in welche Ausgabedatei wandern. Der Mechanismus bleibt derselbe. Das früh zu verstehen erspart viele Umwege

Wie die Aufteilungsschleife funktioniert

Das Muster ist dasselbe, unabhängig davon, wie Sie das Quelldokument unterteilen. Erzeugen Sie eine frische TPdf-Instanz, rufen Sie darauf CreateDocument auf, um ein leeres PDF im Speicher zu initialisieren, importieren Sie die gewünschten Seiten mit ImportPages, speichern Sie das Ergebnis und setzen Sie dann Active vor dem nächsten Durchlauf auf False zurück. Diesen letzten Schritt übersehen die Leute: CreateDocument schließt das noch im Speicher befindliche Dokument nicht implizit, also müssen Sie Ihre Ausgabe speichern und Active := False ausdrücklich zurücksetzen, bevor Sie es erneut aufrufen; zuerst zurückzusetzen hält den Zustand sauber und wohldefiniert. Die äußere TPdf-Instanz wird über alle Durchläufe hinweg wiederverwendet, was den Allokationsdruck bei großen Aufträgen niedrig hält

Diagramm der Aufteilungsschleife von PDFium Component in Delphi: CreateDocument, ImportPages aus der schreibgeschützten Quelle, ein geprüftes SaveAs und der Active-Reset vor jedem neuen Durchlauf
Was auch immer die Gruppen bestimmt, die Schleife bleibt identisch: Seiten importieren, mit geprüftem Ergebnis speichern, dann Active zurücksetzen, damit das nächste CreateDocument aus einem sauberen Zustand startet

So sieht das seitenweise Aufteilen auf das Wesentliche reduziert aus:

procedure SplitIntoPages(Source: TPdf; const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 1 to Source.PageCount do
    begin
      PdfOut.CreateDocument;

      // Range ist eine 1-basierte Seitenzahl-Zeichenkette; Einfügeposition 1 = erste Position
      if not PdfOut.ImportPages(Source, IntToStr(I), 1) then
        raise Exception.CreateFmt('Failed to import page %d', [I]);

      OutFile := OutputDir + '\page_' + Format('%.4d', [I]) + '.pdf';
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;   // Zurücksetzen vor dem nächsten CreateDocument
    end;
  finally
    PdfOut.Free;
  end;
end;

Der Parameter Range von ImportPages hat dasselbe Zeichenkettenformat, das PDFium intern verwendet: eine kommagetrennte Liste von Seitenzahlen oder mit Bindestrich getrennten Bereichen, alle 1-basiert. '3' importiert Seite 3. '1-5' importiert die Seiten 1 bis 5 in dieser Reihenfolge. '2,5,8' importiert diese drei Seiten. Der dritte Parameter ist die 1-basierte Einfügeposition im Zieldokument; die Übergabe von 1 platziert importierte Seiten immer am Anfang einer ansonsten leeren Datei, und genau das wollen Sie hier

Nach Seitenbereichen aufteilen

Wenn der Aufrufer eine Liste wie 1-12,13-24,25-36 liefert, zerlegen Sie sie in Start-/Endpaare und lassen dieselbe Schleife laufen, wobei Sie die Bereichszeichenkette aus jedem Paar bilden:

procedure SplitByRanges(Source: TPdf; const RangeList: array of string;
  const OutputDir: string);
var
  I: Integer;
  PdfOut: TPdf;
  OutFile: string;
begin
  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(RangeList) do
    begin
      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeList[I], 1) then
        raise Exception.Create('Invalid page range: ' + RangeList[I]);
      OutFile := Format('%s\section_%d.pdf', [OutputDir, I + 1]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);
      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

Die Prüfung, bevor Sie bei ImportPages ankommen, zählt hier. ImportPages gibt False zurück, wenn eine Seitenzahl in der Bereichszeichenkette Source.PageCount übersteigt, aber es löst keine Ausnahme aus und erzeugt keine unvollständige Ausgabedatei, die Sie am Namen allein erkennen könnten. Prüfen Sie den Rückgabewert von SaveAs und protokollieren Sie Fehlschläge gesondert; ein Bereich, der eine leere Ausgabedatei erzeugt, fällt erst auf, wenn jemand sie öffnet

An Lesezeichengrenzen aufteilen

Der dritte Ansatz nutzt die Struktur des Dokuments selbst statt einer extern gelieferten Liste. Jedes Lesezeichen der obersten Ebene trägt eine Zielseitenzahl; der Abschnitt, den es definiert, reicht von dieser Seite bis eine Seite vor der Seite des nächsten Lesezeichens, beim letzten Eintrag bis zum Ende des Dokuments

Diagramm, das PDF-Lesezeichen der obersten Ebene beim Aufteilen mit PDFium Component in Delphi auf berechnete Seitenbereiche und Ausgabedateien abbildet, einschließlich eines übersprungenen Lesezeichens außerhalb des Bereichs
Ein Abschnitt reicht von der Seite jedes Lesezeichens der obersten Ebene bis eine Seite vor das nächste Lesezeichen, und Einträge, die über das Ende hinaus zeigen, werden übersprungen, statt leere Dateien zu erzeugen
procedure SplitByBookmarks(Source: TPdf; const OutputDir: string);
var
  Bm: TBookmarks;
  I, StartPage, EndPage: Integer;
  PdfOut: TPdf;
  RangeStr, OutFile, SafeTitle: string;
begin
  Bm := Source.Bookmarks;
  if Length(Bm) = 0 then
    Exit;

  PdfOut := TPdf.Create(nil);
  try
    for I := 0 to High(Bm) do
    begin
      StartPage := Bm[I].PageNumber;
      if I < High(Bm) then
        EndPage := Bm[I + 1].PageNumber - 1
      else
        EndPage := Source.PageCount;

      if (StartPage < 1) or (EndPage < StartPage) then
        Continue;

      RangeStr := Format('%d-%d', [StartPage, EndPage]);

      PdfOut.CreateDocument;
      if not PdfOut.ImportPages(Source, RangeStr, 1) then
      begin
        PdfOut.Active := False;
        Continue;   // Fehlerhaften Abschnitt überspringen, statt eine leere Datei zu schreiben
      end;

      SafeTitle := StringReplace(Bm[I].Title, '/', '_', [rfReplaceAll]);
      SafeTitle := StringReplace(SafeTitle, ':', '_', [rfReplaceAll]);
      OutFile := Format('%s\%02d_%s.pdf', [OutputDir, I + 1, SafeTitle]);
      if not PdfOut.SaveAs(OutFile) then
        raise Exception.Create('Failed to save ' + OutFile);

      PdfOut.Active := False;
    end;
  finally
    PdfOut.Free;
  end;
end;

Ein Dokument ohne Lesezeichen ist kein Fehlerzustand, den man dem Benutzer als solchen zeigen müsste; es bedeutet nur, dass dieser Aufteilungsmodus nichts hat, worauf er aufbauen kann. Die Absicherung Length(Bm) = 0 erledigt das stillschweigend. Zeigen sollte man dagegen, wenn die Seitenzahl eines Lesezeichens außerhalb des Dokumentbereichs liegt, was in fehlerhaften Dateien vorkommt, deren Gliederung nach dem Löschen von Seiten nie aktualisiert wurde. Die Bereichsprüfung von StartPage und EndPage überspringt diese Einträge, statt einen unsinnigen Bereich an ImportPages zu übergeben

Benennung der Ausgabedateien und der Active-Reset

Die Dateinamensicherheit bei aus Lesezeichen abgeleiteten Namen braucht ausdrückliche Aufmerksamkeit. Lesezeichentitel können Zeichen enthalten, die in einer PDF-Zeichenkette gültig sind, in einem Dateisystempfad aber nicht. Ersetzen Sie mindestens Schrägstrich, Rückwärtsschrägstrich und Doppelpunkt, bevor Sie den Ausgabepfad bilden. Unter Windows sind außerdem *, ?, ", <, > und | verboten; eine einfache Schleife über eine feste Menge deckt sie ab, ohne dass Sie einen regulären Ausdruck hinzuziehen müssen

Die Zeile Active := False am Ende jedes Durchlaufs verdient Betonung, weil sie die einzige nicht offensichtliche Anforderung des Musters ist. CreateDocument schließt nicht implizit, was gerade offen ist. Ist Active noch True, wenn CreateDocument erneut läuft, wurde das noch im Speicher liegende Dokument nie ordentlich geschlossen oder gespeichert, und in diesem Zustand können Sie sich nicht auf wohldefiniertes Verhalten verlassen, also speichern und zurücksetzen Sie ausdrücklich, bevor Sie das nächste Dokument beginnen. Betrachten Sie es als Gegenstück zu try/finally: Der finally-Block gibt das äußere Objekt frei; das Active := False setzt den inneren Dokumentzustand zwischen den Schleifendurchläufen zurück

Der Speicherverbrauch bleibt bei diesem Ansatz über einen großen Aufteilungsauftrag hinweg flach, weil Sie nie mehr als ein Ausgabedokument gleichzeitig im Speicher halten. Das Quelldokument bleibt durchgehend geöffnet und schreibgeschützt; ImportPages kopiert Seitendaten in das neue Dokument, ohne die Quelle zu verändern. Ist die Quelle verschlüsselt, öffnen Sie sie vor der Schleife mit ihrem Passwort, und die kopierten Seiten in jeder Ausgabedatei sind unverschlüsselt, was für an verschiedene Empfänger verteilte Teilausgaben meist das richtige Verhalten ist

Noch etwas zu SaveAs: Es gibt einen Boolean zurück. Ein nicht vorhandenes Ausgabeverzeichnis, ein Pfad mit Zeichen, die das Betriebssystem ablehnt, oder eine volle Festplatte führen alle dazu, dass SaveAs ohne Ausnahme False zurückgibt. In einem Stapelauftrag, der ein 200-seitiges Dokument in 200 Einzelseitendateien zerlegt, wird ein stiller Fehlschlag bei Seite 147 leicht übersehen. Prüfen Sie den Rückgabewert bei jedem Aufruf und gleichen Sie am Ende der Schleife die Zahl der Erfolge mit dem erwarteten Gesamtwert ab

Die hier gezeigten Methoden ImportPages und CreateDocument sind Teil von PDFium Component für Delphi und C++Builder