Technischer Artikel

PDF-Lesezeichen und Seiten-Remapping in Delphi

Löst man sieben Seiten aus einem 200-Seiten-Handbuch heraus, landet jedes Lesezeichen irgendwo falsch. Die Lösung ist nicht, die Gliederung aus einer flachen Titelliste neu zu bauen. PDFiumPas stellt TPdfOutlineEditor bereit, der den echten Gliederungsbaum lädt, das Verschieben und Umzielen von Einträgen erlaubt und dann ApplyPageMap laufen lässt, um jede explizite Destination durch den Seitenplan zu schieben

Warum das Löschen von Seiten jedes Lesezeichen bricht

Weil ein Gliederungseintrag keine Seitennummer speichert. Er speichert eine Referenz auf ein Seitenobjekt, und wenn sich die Seitenobjekte ändern, zeigt die Referenz entweder auf eine Seite, die verschoben wurde, oder auf gar nichts. ISO 32000-1 §12.3.2.2 definiert eine explizite Destination als Array, dessen erstes Element eine indirekte Referenz auf ein Seitenverzeichnis ist, gefolgt von einem Fit-Namen wie /Fit oder /XYZ. Löscht man die Seite, bleibt eine hängende Referenz; sortiert man die Seiten um, ist die Referenz weiterhin gültig, beschreibt aber nun ein anderes Kapitel. PDFiumPas löst dieses Array beim Laden zurück in eine Seitennummer auf, sodass TPdfOutlineItem.PageNumber einen 1-basierten Seitenindex liefert, der zur öffentlichen TPdf-API passt, statt einer Objektnummer. Das ist der ganze Sinn der Abstraktion: Die eigene Remapping-Logik arbeitet im selben Koordinatensystem wie der Seitenplan, den man beim Aufteilen, Umsortieren oder Ausschießen des Dokuments bereits gebaut hat. Wer diesen Plan erstellt, findet dieselbe 1-basierte Konvention unter PDF-Dokumente in mehrere Dateien aufteilen und unter n-up-Ausschießen und Seitenumordnung

Die Gliederung ist ein doppelt verketteter Baum, keine Liste

Der Grund, warum man nicht einfach ein flaches Array von Titeln serialisieren kann, ist, dass ISO 32000-1 §12.3.3 jeden Gliederungseintrag in fünf separate Verweise einhängt: /Parent, /Prev, /Next, /First und /Last. Das Verschieben eines einzelnen Teilbaums schreibt deshalb den alten Elternknoten, den neuen Elternknoten, die Nachbargeschwister beidseits von Schnitt und Einfügestelle sowie den Elternzeiger des verschobenen Knotens selbst neu. Setzt man einen davon falsch, zeigen konforme Reader einen abgeschnittenen Baum oder eine Schleife. PDFiumPas hält den Bearbeitungszustand als Tiefensuche-Array von TPdfOutlineItem-Datensätzen mit stabiler ganzzahliger Id, sodass ein Teilbaum ein zusammenhängender Ausschnitt ist und die Geschwisterkette abgeleitet wird, nie von Hand gepflegt. TPdfOutlineEditor.Move hebt diesen Ausschnitt an, fügt ihn unter dem neuen Elternknoten am gewünschten Geschwisterindex wieder ein und weist nur die Wurzel des Blocks neu zu. Es verweigert außerdem die beiden Verschiebungen, die den Graphen korrumpieren würden: einen Eintrag in seinen eigenen Teilbaum zu verschieben und einen Elternknoten zu benennen, der nicht existiert

PDFiumPas-Gliederungsbearbeitung in Delphi: Kapitel 3 aus Teil I herauszulösen und unter die Dokumentwurzel zu hängen, schreibt den /Parent-Zeiger des verschobenen Knotens sowie die /First- und Geschwister-/Prev- und /Next-Verweise um, und zwar um den Schnitt und die Einfügestelle herum
Ein Move-Aufruf schreibt den Elternzeiger des angehobenen Teilbaums und die Geschwisterverweise beidseits von Schnitt und Einfügestelle neu

Warum ist /Count vorzeichenbehaftet?

Weil das Vorzeichen den Ausgeklappt-Zustand trägt, nicht die Größe. Ein positiver /Count bedeutet, der Eintrag ist offen, und die Zahl sagt, wie viele Nachfahren gerade sichtbar sind; ein negativer /Count bedeutet, der Eintrag ist eingeklappt. PDFiumPas schreibt die Nachfahrenanzahl für jeden Eintrag mit Kindern und negiert sie, wenn IsOpen False ist, und liest beim Laden den Zustand als IsOpen := HasCount and (CountValue > 0) zurück. Das ist der häufigste handgestrickte Fehler in Gliederungs-Schreibern: einen vorzeichenlosen Count auszugeben und stillschweigend den ganzen Baum aufzuklappen

Wie PDFiumPas in Delphi den Ausklapp-Zustand der Gliederung kodiert: Ein positiver /Count bedeutet, der Eintrag ist offen und zählt sichtbare Nachfahren, ein negativer /Count bedeutet eingeklappt, und ein vorzeichenloser Count zwingt jeden Reader, den ganzen Baum auszuklappen
Das Vorzeichen von /Count ist der Ausklapp-Zustand und der Betrag die sichtbare Nachfahrenanzahl, also zwingt ein vorzeichenloser Count stillschweigend den ganzen Baum auf
var
  Source, Dest: TMemoryStream;
  Editor: TPdfOutlineEditor;
  Options: TPdfOutlineEditOptions;
  Report: TPdfOutlineValidationReport;
  RootId, ChapterId: Integer;
begin
  Source := TMemoryStream.Create;
  Dest := TMemoryStream.Create;
  Editor := nil;
  try
    Source.LoadFromFile('handbook.pdf');
    Options := TPdfOutlineEditOptions.Default;   // MaxItems 100000, MaxDepth 64
    if not TPdfOutlineEditor.TryLoad(Source, Options, Editor, Report) then
      raise Exception.Create(Report.ErrorMessage);

    RootId := Editor[0].Id;
    ChapterId := Editor[2].Id;

    Editor.Move(ChapterId, RootId, 1);           // wird zweites Kind der Wurzel
    Editor.SetTitle(ChapterId, 'Appendix B');
    Editor.SetStyle(ChapterId, [posBold, posItalic]);
    Editor.SetColor(ChapterId, 0.25, 0.5, 0.75);
    Editor.SetExpanded(RootId, False);           // schreibt einen negativen /Count
    Editor.Retarget(ChapterId, 12, '/XYZ 10 20 1');

    if not Editor.SaveIncremental(Source, Dest, Report) then
      raise Exception.Create(Report.ErrorMessage);
    Dest.SaveToFile('handbook-edited.pdf');
  finally
    Editor.Free;
    Dest.Free;
    Source.Free;
  end;
end;

Retarget behandelt beide Formen, die die Spezifikation erlaubt. Übergibt man DestinationInAction als False, schreibt PDFiumPas ein direktes /Dest-Array; übergibt man True, schreibt es eine Go-To-Action, /A << /S /GoTo /D [ page ref suffix ] >>, gemäß ISO 32000-1 §12.6.4.2. In beiden Fällen entfernt es zuerst jedes vorhandene /Dest und /A vom Eintrag, damit beide nicht koexistieren und sich widersprechen können. Das Suffix ist standardmäßig /Fit und muss mit einem PDF-Namen beginnen, weshalb ein leeres oder fehlerhaftes Suffix sofort wirft, statt ein Ziel-Array zu produzieren, das kein Reader parsen kann

Wie nimmt ApplyPageMap einen Seitenplan auf?

ApplyPageMap nimmt exakt das Array, das der Seitenplan bereits validiert hat: NewPageNumbers, indexiert über alte Seite minus eins, mit der neuen 1-basierten Seitennummer oder null, wenn diese Seite nicht überlebt hat. Es läuft das Eintragsarray rückwärts ab, damit das Löschen eines Teilbaums nie einen noch nicht besuchten Index invalidiert, und meldet über RemappedDestinationCount und RemovedDanglingItemCount, was es tat

var
  NewPageNumbers: array of Integer;
  Report: TPdfOutlineValidationReport;
  I: Integer;
begin
  // Ein Eintrag je Seite des ORIGINALDOKUMENTS
  SetLength(NewPageNumbers, OriginalPageCount);
  for I := 0 to OriginalPageCount - 1 do
    NewPageNumbers[I] := 0;              // 0 == diese Seite wurde entfernt

  NewPageNumbers[0] := 1;                // alte Seite 1 -> neue Seite 1
  NewPageNumbers[1] := 2;
  NewPageNumbers[9] := 3;                // alte Seite 10 -> neue Seite 3

  // True: den ganzen hängenden Teilbaum löschen. False: den Eintrag behalten, sein Ziel entfernen
  if not Editor.ApplyPageMap(NewPageNumbers, True, Report) then
    raise Exception.Create(Report.ErrorMessage);

  WriteLn(Format('%d remapped, %d dangling items removed',
    [Report.RemappedDestinationCount, Report.RemovedDanglingItemCount]));
end;

Das DeleteDangling-Flag entscheidet die Politik für eine Destination, die auf null abgebildet wurde, und beide Zweige sind bewusst gewählt. Mit True löscht PDFiumPas den Eintrag und seinen gesamten Teilbaum, denn ein Gliederungsknoten, dessen Ziel verschwand, steht üblicherweise über einem Kapitel, das mit verschwand. Mit False überlebt der Eintrag mit Titel und Hierarchie, aber ohne sein /Dest und /A, was man will, wenn ein Mensch ihn in der Prüfung neu zielen soll. Wirklich fehlerhafte Eingabe scheitert weiterhin laut, statt geflickt zu werden: ein negativer Eintrag oder eine Destination hinter das Ende der gelieferten Map gibt False zurück, mit IssueKind auf poviInvalidPageMap

Wie PDFiumPas ApplyPageMap in Delphi PDF-Lesezeichen umleitet: Eine über alte Seite minus eins indexierte Seitenmap schickt überlebende Destinations zu ihren neuen Seitennummern, während Einträge, die auf null mappen, entweder samt Teilbaum gelöscht oder ihres Ziels beraubt werden
Die Seitenmap ist über alte Seite minus eins indexiert, und ein Null-Eintrag löscht entweder den hängenden Teilbaum oder lässt den Eintrag ohne sein Ziel zurück

Opake Einträge und der ehrliche Kompromiss

Nicht jeder Gliederungseintrag hat eine Seitennummer, über die PDFiumPas nachdenken kann. Drei Arten werden unangetastet durchgereicht: benannte Destinations, Actions, die nicht /S /GoTo sind, und unbekannte Dictionary-Schlüssel, die der Erzeuger der Datei ergänzt hat. Diese laden mit PageNumber gleich null, behalten ihre Original-Bytes im Eintrag und werden wörtlich zurückgeschrieben, sofern man nicht explizit Retarget auf ihnen aufruft

  • Eine benannte Destination ist ein Schlüssel in den Namensbaum des Dokuments, sie korrekt umzumappen bedeutet also, den Baum aufzulösen und den Zieleintrag umzuschreiben, nicht auf Gliederungsebene zu raten
  • Eine /URI-, /Launch- oder JavaScript-Action hat überhaupt keine Seitensemantik und darf nicht stillschweigend in eine Go-To umgewandelt werden
  • Herstellerspezifische Schlüssel und Struktur-Destinations werden bewahrt, denn Unverstandenes wegzuwerfen ist der Weg, auf dem Round-Trips Daten verlieren

Der Preis ist real und wert, klar benannt zu werden: ApplyPageMap überspringt diese Einträge vollständig, also kommt ein Dokument, dessen Lesezeichen alle benannte Destinations nutzen, mit strukturell gültiger, semantisch veralteter Gliederung durch eine Seitenlöschung. Das ist die bewusste Wahl — ein veralteter Verweis, den ein Prüfer finden kann, schlägt einen selbstbewusst falschen, den niemand bemerkt. Wer eingehende Dateien sichtet, bevor er sie bearbeitet, erfährt in einem Inventurdurchlauf in einer PDF-Intake-Review-Workbench, welche Dokumente in diese Schublade fallen

Speichern: inkrementelle Revision, dann ein unabhängiges Neuladen

TPdfOutlineEditor.SaveIncremental hängt eine sparsame inkrementelle Revision an, statt die Datei umzuschreiben. Geladene Einträge behalten ihre Original-Referenz auf das indirekte Objekt einschließlich der exakten Generation, also bleiben bestehende Querverweise gültig; nur hinzugefügte Einträge ziehen eine frische Nummer, alloziert ab einer hinter der Maximalobjektnummer der Revision. Das Catalog wird in derselben Revision aktualisiert, und ein fehlender /Outlines-Eintrag wird ergänzt, wenn die Quelle gar keine Gliederung hatte

Was nach dem Schreiben passiert, ist der Teil, der sich zum Übernehmen lohnt. PDFiumPas öffnet den Ziel-Stream mit einem vollständig unabhängigen Editor wieder und vergleicht den neu geladenen Baum mit dem im Speicher — Eintragsanzahl, Titel, Seitennummern, Destination-Suffixe, Action-gegen-direkte-Destination-Form, Stile, Ausklapp-Zustand und Elternbeziehungen. Jede Abweichung oder jeder Ladefehler löscht den Ziel-Stream und gibt poviVerificationFailure zurück, statt eine plausibel aussehende Datei zu übergeben. Verschlüsselte Quellen werden vorab mit poviEncryptedInput abgelehnt, denn neue Titel und Destinations erzeugen String-Inhalte, die nicht durch Vorwärtskopieren des /Encrypt-Trailers entstehen können

if not Editor.SaveIncremental(Source, Dest, Report) then
  case Report.IssueKind of
    poviEncryptedInput:
      Log('Source is encrypted; outline editing needs an unprotected copy');
    poviInvalidDestination:
      Log(Format('Item %d %d targets a missing page',
        [Report.ObjectNumber, Report.Generation]));
    poviVerificationFailure:
      Log('Reload check rejected the written revision: ' + Report.ErrorMessage);
  else
    Log(Report.ErrorMessage);
  end;

Behandelt man die Gliederung als das, was sie ist — einen verketteten Objektgraphen mit eigenen Invarianten — hört Seitenlöschung auf, eine Lesezeichen-Katastrophe zu sein, und wird zu einer Seitenmap, die man einem einzigen Methodenaufruf übergibt. TPdfOutlineEditor, ApplyPageMap und der verifizierte inkrementelle Schreiber kommen ab v3.98.0 in PDFiumPas für Delphi, C++Builder und Lazarus; die vollständige API kann man auf der PDFium-Delphi-Komponenten-Produktseite einsehen und eine Testversion herunterladen