Technischer Artikel

PDF-Seiten in Delphi ersetzen, ohne Lesezeichen zu zerstören

Das Ersetzen von Seite 3 eines abgezeichneten Vertrags sollte das Inhaltsverzeichnis nicht verschieben. Löscht man die alte Seite und fügt die neue ein, landet jedes Lesezeichen, das dorthin verwies, plötzlich woanders. Die PDFlibPas Delphi PDF Library vermeidet das, indem sie das Zielseitenobjekt selbst behält und nur die Einträge überträgt, die visuellen Inhalt tragen

Warum brechen Lesezeichen nach dem Ersetzen einer PDF-Seite?

Lesezeichen brechen, weil ein PDF-Ziel eine Seite über eine indirekte Objektreferenz benennt, nicht über eine Seitennummer. ISO 32000-1 §12.3.2.2 definiert ein explizites Ziel als Array, dessen erstes Element eine indirekte Referenz auf das Seitenobjekt ist. Löscht man dieses Objekt und hängt einen Ersatz an, ist die Referenz baumelnd: Die meisten Viewer reagieren, indem sie den Leser auf Seite 1 absetzen, genau das Symptom, das man nach einem Ersetzen per Löschen-dann-Einfügen berichtet. Der Seitenbaum sieht tadellos aus, die Seitenzahl stimmt, das Rendering stimmt, und die gesamte Navigationsebene ist still falsch

Auch benannte Ziele retten einen nicht. §12.3.2.3 leitet einen Namen über den /Dests-Namensbaum im Dokumentkatalog, aber das Blatt, zu dem der Name aufgelöst wird, ist weiterhin ein explizites Zielarray mit derselben Seitenreferenz. Benennen fügt eine Ebene der Indirektion über der Seitenreferenz hinzu, nicht um sie herum. Dieselbe Überlegung gilt für den Rest der in §12.5 beschriebenen interaktiven Ebene: Eine Link-Annotation trägt ein /Dest oder eine /A-GoTo-Aktion, deren /D dieses Array ist, jede Annotation kann einen /P-Eintrag tragen, der eine indirekte Referenz auf ihre Seite ist, und ein Formularfeld-Widget ist eine Annotation auf genau derselben Grundlage. Ein naiver Seitentausch trennt vier Subsysteme auf einmal, und wer sie an einer echten Datei aufgezählt sehen möchte: Genau diesen Objektgraphen durchläuft die Introspektion von Gliederung, Annotationen und Aktionen

Welche Seiteneinträge tragen Identität, welche tragen Erscheinung

Ein Seiten-Dictionary mischt zwei Arten von Einträgen, und ein In-Place-Ersatz gelingt genau dann, wenn man sie trennt. Die Erscheinungsseite ist endlich und aufzählbar: /Contents, /Resources, die fünf Seitenboxen /MediaBox, /CropBox, /BleedBox, /TrimBox und /ArtBox, dazu /Rotate, /Group, /UserUnit und /BoxColorInfo. Diese elf Einträge entscheiden alles, was ein Rasterisierer für die Seite erzeugt, und nichts sonst in der Datei verweist namentlich auf sie

Die Identitätsseite ist das, woran der Rest des Dokuments sich gebunden hat: die Objektnummer und Generation der Seite, der /Parent-Rückverweis in den Seitenbaum und /Annots. PDFlibPas lässt jeden dieser Einträge unangetastet. ReplacePageRanges entfernt die elf visuellen Einträge aus dem Ziel-Seiten-Dictionary und fügt sie aus der importierten Quellseite neu ein, sodass das Zielseitenobjekt an Ort und Stelle verändert wird, statt ersetzt zu werden. Auch die von §7.7.3 geforderte Seitenbaumstruktur bleibt in ihrer Form byte-identisch: /Kids-Reihenfolge, /Count und jeder überlebende /Parent sind vorher und nachher dieselben, weil nie ein Knoten ausgehängt wurde

Wie ersetzt PDFlibPas eine Seite, ohne Objekte umzunummerieren?

Der Aufruf nimmt ein Quelldokument, eine 1-basierte Zielstartseite, einen Quellbereichsausdruck und ein Optionen-Flag entgegen. Beide Dokumente müssen in derselben Instanz geöffnet sein, und das Zieldokument ist das ausgewählte. Weil sich die Zielseitenzahl nie ändert, muss der angeforderte Bereich ab TargetStartPage in das Dokument passen, und das wird geprüft, bevor überhaupt etwas erzeugt wird

var
  Lib: TPDFlib;
  TargetDoc, SourceDoc: Integer;
begin
  Lib := TPDFlib.Create;
  try
    // The document whose bookmarks and links must survive
    if Lib.LoadFromFile('contract-final.pdf', '') <> 1 then
      Exit;
    TargetDoc := Lib.SelectedDocument;

    // The revised clause page, rendered by whatever produced it
    if Lib.LoadFromFile('clause-7-revised.pdf', '') <> 1 then
      Exit;
    SourceDoc := Lib.SelectedDocument;

    Lib.SelectDocument(TargetDoc);
    // Source page 1 overwrites the visuals of target page 3.
    // Page count, page 3 object number, bookmarks and annotations are kept.
    if Lib.ReplacePageRanges(SourceDoc, 3, '1', 0) = 1 then
      Lib.SaveToFile('contract-final.pdf');
  finally
    Lib.Free;
  end;
end;

Intern lassen sich die Quellseiten nicht einfach über Dokumentgrenzen hinweg lesen, weil jede darin enthaltene indirekte Referenz zur Objektnumerierung des Quelldokuments gehört. Also wird der Quellbereich zunächst auf gewöhnlichem Weg importiert, als temporäre Seiten nach der letzten echten Seite angehängt, was das vollständige Remapping des Objektgraphen durchführt: Content-Streams, Schriften, XObjects, Shadings und Farbräume werden allesamt in das Zieldokument umnummeriert. Erst danach werden die elf visuellen Einträge von jeder temporären Seite auf ihre Zielseite kopiert, und erst danach werden die temporären Seiten aus dem Seitenbaum ausgehängt. Die Remapping-Arbeit findet dort statt, wo sie günstig und sicher ist, und die destruktive Bearbeitung reduziert sich auf einen Dictionary-Tausch auf bereits existierenden Seiten

Der Löschpfad, der zerstören würde, was man gerade übertragen hat

Das Entfernen dieser temporären Seiten ist der Schritt, der trivial aussieht und es nicht ist. Der gewöhnliche Seitenlöschpfad der Bibliothek tut mehr, als einen Knoten auszuhängen: Er fasst die Ebenen jeder gelöschten Seite zusammen, leert den ersten Content-Stream und gibt Ressourcen frei, die keine andere Seite teilt. Das ist korrektes Verhalten für eine echte Löschung und hier katastrophal, denn zu dem Zeitpunkt, an dem die temporären Seiten entfernt werden, referenzieren die Zielseiten bereits genau jene Content-Streams und Ressourcenobjekte. Sie zu leeren würde die gerade ersetzte Seite leer machen, und der Ressourcen-Sweep würde Schriften und Bilder einsammeln, die nun einen lebenden Besitzer haben

Die Lösung ist ein Modus zum Erhalten referenzierter Objekte auf dem internen Löschpfad. Ist er gesetzt, überspringt die Löschung sowohl den Sweep nicht geteilter Ressourcen als auch das Leeren des Content-Streams und tut nichts, außer die Seiten vom Seitenbaum abzukoppeln und dessen Buchführung zu korrigieren. Die übertragenen Objekte überleben mit einem neuen Besitzer, und die Objektzugehörigkeit nach der Operation ist genau das, was man an eine Tafel zeichnen würde: ein Content-Stream, eine besitzende Seite, eine Objektnummer, die sich nie bewegt hat. Die zugehörigen Lebenszyklusregeln für das Erstellen, Löschen und Umordnen von Seiten werden separat in den Notizen zu Dokument- und Seitenlebenszyklus-Operationen behandelt

Reihenfolge, Duplikate und Alles-oder-nichts-Fehlschlag

Das Optionen-Flag wählt, wie der Quellbereich interpretiert wird. 0 sortiert die geparsten Seitennummern und entfernt Duplikate, was die vernünftige Voreinstellung ist, wenn der Aufrufer etwas wie '4-6,2' übergibt und damit schlicht diese vier Seiten meint. 1 bewahrt die geschriebene Reihenfolge und erlaubt eine Seitenwiederholung, sodass '2,1,2' tatsächlich drei Ersetzungen bedeutet, die aus zwei Quellseiten stammen. Die Validierung läuft zuerst und läuft vollständig: die Bereichssyntax, jede Seitennummer gegen die Quellseitenzahl, der Optionswert selbst und die Zielkapazität werden alle geprüft, bevor auch nur ein Objekt erzeugt wird. Ein abgelehnter Aufruf setzt LastErrorCode auf 412, stellt die zuvor gewählte Seite wieder her und lässt das Dokument exakt so, wie es war

var
  Replaced: Integer;
begin
  Lib.SelectDocument(TargetDoc);
  // Options = 1: source order is preserved and repeats are allowed, so
  // target pages 5, 6 and 7 receive source pages 2, 1 and 2 respectively
  Replaced := Lib.ReplacePageRanges(SourceDoc, 5, '2,1,2', 1);
  if Replaced = 0 then
    raise Exception.CreateFmt('Replacement rejected, LastErrorCode = %d',
      [Lib.LastErrorCode]);
  // On success the selection is the first replaced page
  Assert(Lib.SelectedPage = 5);
end;

Atomarität reicht über die Validierung hinaus bis in die Übertragung selbst. Bevor die erste Quellseite importiert wird, werden die elf visuellen Einträge jeder Zielseite im Bereich als kodierte Werte fotografisch festgehalten. Schlägt der Import fehl, oder stimmt die importierte Seitenzahl nicht mit der angeforderten überein, werden die Schnappschüsse zurück auf die Zielseiten dekodiert und die temporären Seiten entfernt, sodass ein mitten im Ablauf auftretender Fehler die ursprünglichen Visuals dennoch auf ihren ursprünglichen Objekten belässt. Das ist wichtiger, als es klingt: Ein halb ersetzter Seitenbereich in einem Vertrag ist schlimmer als ein fehlgeschlagener Aufruf, weil nichts in der Datei ihn als halb erledigt kennzeichnet

// Post-conditions worth asserting in a regression test
Lib.SelectPage(3);
// Geometry now comes from the source page
WriteLn(Format('%.2f x %.2f', [Lib.PageWidth, Lib.PageHeight]));
// Annotations that were already on target page 3 are still attached
WriteLn(Lib.AnnotationCount);
// The bookmark created before the replacement still resolves to page 3
WriteLn(Lib.GetOutlinePage(OutlineID));
// And the document is still the same length
WriteLn(Lib.PageCount);

Was ein In-Place-Ersatz weiterhin nicht für einen erledigt

Quell-Annotationen, Quell-Formularfelder und Quell-Gliederungen werden bewusst nicht importiert. Ein Widget ohne seinen /AcroForm-Feldeintrag herüberzuholen, oder eine Annotation mit Marked-Content ohne ihre Structure-Tree-Zugehörigkeit, erzeugt ein halb importiertes interaktives Objekt, mit dem kein Viewer etwas anfangen kann, also überträgt die Operation nur die Erscheinung. Die praktische Konsequenz: Soll die Ersatzseite neue Formularfelder oder neue Links tragen, fügt man sie anschließend zur Zielseite hinzu, gegen das Zielseitenobjekt, das immer noch dort sitzt und darauf wartet

Zwei weitere Grenzen lohnen sich, an den eigenen Dateien zu prüfen. Erstens: /Annots bleibt erhalten, aber die Seitengeometrie nicht, sodass das Ersetzen einer 220-mm-Seite durch eine 320-mm-Seite die Annotationsrechtecke an ihren alten Koordinaten innerhalb einer anders bemessenen /MediaBox belässt; ändert sich die Geometrie, müssen die behaltenen Annotationen neu positioniert werden. Zweitens: Einträge außerhalb der elf visuellen Schlüssel bleiben absichtlich bei der Zielseite, was für /Trans oder /AA richtig und für /Thumb veraltet ist, also sollte man Thumbnails nach einem Ersatz neu erzeugen. Getaggte Dokumente verdienen einen zusätzlichen Gedanken: Die Strukturelemente zeigen über /Pg weiterhin auf das korrekte Seitenobjekt, aber ihre Marked-Content-Kennungen beschreiben Inhalt, der nicht mehr da ist, sodass ein Seitentausch innerhalb eines PDF/UA-Workflows ebenso eine Bearbeitung des Structure Tree wie eine Inhaltsbearbeitung ist. Geht es bei der Aufgabe eigentlich um Compositing statt Tausch, also um das Auflegen von Artwork auf behaltene Seiten, ist der Ansatz mit Page-Stitching und Vorlagen das günstigere Werkzeug

Alles hier Beschriebene, einschließlich der Syntax des Bereichsausdrucks, der Optionswerte und der umgebenden Seitenbearbeitungs-API, ist Teil der Standard-PDFlibPas Delphi PDF Library für Delphi und C++Builder, deren Referenzdokumentation den vollständigen Eintrag für den Seitenersatzaufruf und seine Fehlercodes führt