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