Technischer Artikel

PDF Page Labels in Delphi: /Kids-Number-Trees reparieren

PDF Library for Delphi schreibt Page-Label-Bereiche mit AddPageLabels, und seit v3.539.10 funktioniert dieser Aufruf auch auf geladenen Dateien, deren /PageLabels-Number-Tree in /Kids-Knoten aufgeteilt ist: Die Wurzel wird zu einem einzigen /Nums-Blatt flach gemacht, bevor der neue Bereich hineinkommt, sodass das Label tatsächlich im Viewer auftaucht, statt stillschweigend ignoriert zu werden. Das typische Opfer ist ein Buch-PDF aus einem Layout-Tool, mit römischen Ziffern im Vorspann, arabischer Nummerierung im Hauptteil und einem Anhang mit A-1, A-2 – Sie wollten nur den Anhang umlabeln, und nichts veränderte sich

Was sind PDF-Page-Labels und wie werden sie gespeichert?

Page Labels sind die Zeichenketten, die ein Viewer in seiner Seitenbox zeigt, statt des physischen Seitenindex, und ISO 32000-1 §12.4.2 speichert sie als Number Tree unter dem Katalog-Key /PageLabels. Jeder Key ist ein 0-basierter Seitenindex, der einen Labeling-Bereich startet, und jeder Wert ist ein Page-Label-Dictionary mit bis zu drei Einträgen: /S für den Nummerierungsstil (D, R, r, A oder a), /P für einen Prefix-String und /St für den numerischen Wert der ersten Seite im Bereich, der standardmäßig 1 ist. Ein Bereich läuft bis zum nächsten Key, und die Spezifikation verlangt, dass der Tree einen Wert für Seitenindex 0 enthält, sodass jede Seite von irgendeinem Bereich abgedeckt ist

Page-Label-Speicherung in PDFlibPas-Begriffen: Der /PageLabels-Number-Tree keyed jeden Bereich über seine 0-basierte Startseite, jeder Wert ist ein Label-Dictionary mit /S-Stil, /P-Prefix und /St-Erster-Nummer, und das Buch-Beispiel mappt römischen Vorspann, arabische Hauptseiten und einen A--Anhang auf drei Bereiche
Ein Bereich läuft bis zum nächsten Key, die Spezifikation verlangt einen Wert für Seitenindex 0, und GetPageLabel wendet den letzten Bereich an, dessen Key auf oder unter der Seite liegt, sodass jede Seite zu irgendetwas auflöst
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
      Exit;
    // Seiten 1-4: i, ii, iii, iv (kleine römische Ziffern)
    Lib.AddPageLabels(1, 3, 1, '');
    // Seiten 5-120: 1, 2, 3 ... (dezimal)
    Lib.AddPageLabels(5, 1, 1, '');
    // Seiten ab 121: A-1, A-2 ... (dezimal mit Prefix)
    Lib.AddPageLabels(121, 1, 1, 'A-');
    WriteLn(Lib.GetPageLabel(5));    // 1
    WriteLn(Lib.GetPageLabel(122));  // A-2
    Lib.SaveToFile('handbook-labeled.pdf');
  finally
    Lib.Free;
  end;
end;

TPDFlib.AddPageLabels(Start, Style, Offset, Prefix) mappt seine Argumente ohne Überraschungen auf dieses Dictionary, sobald Sie drei Regeln kennen. Start ist 1-basiert wie jedes andere Seitenargument in der Bibliothek und wird als Start - 1 in den Tree geschrieben. Style läuft von 0 bis 5, wobei 0 nur den Prefix bedeutet und 1 bis 5 zu /S-Werten D, R, r, A und a werden; alles außerhalb davon gibt 0 zurück und fasst nichts an. Offset wird nur dann zu /St, wenn es größer als null ist, bei 0 wird der Key einfach weggelassen und der Viewer fällt auf den Default 1 zurück. Weil Page Labels erst in PDF 1.3 kamen, führt der Aufruf auch EnsureMinVersion('1.3', '/PageLabels') aus, was die Ausgabeversion einer älteren Datei anhebt, sofern Sie die Save-Version nicht explizit festgenagelt haben

Warum verschwinden neue Page Labels, wenn der Tree /Kids hat?

Weil ISO 32000-1 §7.9.7 (Tabelle 37) festlegt, dass die Wurzel eines Number Trees entweder /Kids oder /Nums trägt, nie beides, und der frühere NumTreeSet-Helfer nur nach /Nums suchte. Producer, die lange Dokumente ausgeben, teilen den Tree oft in Zwischenknoten, jeweils mit einem /Limits-Paar, und hängen sie an eine Wurzel, die nur /Kids hat. Der alte Code fand keine /Nums an dieser Wurzel, legte ein frisches neben das bestehende /Kids und steckte den neuen Bereich dort hinein. Das Ergebnis war eine Wurzel mit zwei sich gegenseitig ausschließenden Einstiegspunkten. Viewer steigen über /Kids hinab und schauen das verirrte Array nie an, auch der eigene EnumNumTree der Bibliothek prüft zuerst /Kids, und NumTreeLookup verweigert einen Knoten, bei dem HasKids xor HasNums falsch ist. AddPageLabels gab trotzdem 1 zurück, und die gespeicherte Datei öffnete trotzdem sauber – das ist die schlimmste Art des Scheiterns: nichts beschwert sich, die Labels bleiben einfach dieselben

Der Fix in NumTreeSet macht die Wurzel zu einem Blatt, bevor irgendetwas eingefügt wird. Trägt die Wurzel /Kids, läuft EnumNumTree jedes Blatt der Reihe nach ab und sammelt jedes Key-Value-Paar ein, aus dieser Liste wird ein neues flaches /Nums-Array gebaut, und /Kids, /Limits und veraltete /Nums werden aus der Wurzel entfernt, bevor das flache Array angehängt wird. Das Wegwerfen von /Limits ist nicht kosmetisch, denn Tabelle 37 erlaubt diesen Eintrag nur an Zwischen- und Blattknoten, nie an der Wurzel. Von da an ist das Einfügen ein gewöhnlicher sortierter Insert in ein Array, und bestehende Bereiche überleben mit ihren Original-Label-Dictionaries. Der Trade-off ist Absicht: Der Tree wird danach nicht in balancierte /Kids-Knoten zurückgebaut. Bei Page Labels kostet das nichts, denn selbst ein großes Referenzhandbuch hat selten mehr als ein paar Dutzend Bereiche, und ein einziges Blatt ist es, was die meisten Producer ohnehin schreiben

Number-Tree-Reparatur in PDFlibPas: Eine Wurzel mit /Kids und einem verirrten /Nums-Array ist für Viewer unsichtbar, weil ISO 32000-1 nur eines von beiden erlaubt, also flacht NumTreeSet jedes Blatt in ein einziges /Nums-Array und entfernt /Kids und /Limits, was Tabelle 37 an einer Wurzel nie erlaubt
Nichts hat sich beschwert, weil jede Prüfung bestand: AddPageLabels gab 1 zurück, die gespeicherte Datei öffnete sauber, und nur ein Reader, der zuerst in /Kids hinabsteigt – so wie Viewer und die Bibliothek selbst – findet den neuen Bereich nie
// Den Anhang umlabeln in einer Datei, deren /PageLabels-Wurzel /Kids nutzt
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // z. B. A-1
  // Den Bereich ab Seite 121 ersetzen: App-a, App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // Bestehende römische und dezimale Bereiche bleiben im flachen Blatt
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii, unverändert
end;

Wie kann ein /Nums-Array als Keys fehlgelesen werden?

Ein /Nums-Array wird fehlgelesen, wenn Code es Element für Element abläuft, denn das Array ist ein flacher Zug abwechselnder Paare, [key0 value0 key1 value1 ...], und nur die geraden Positionen sind Keys. Die alte NumTreeSet-Schleife testete jedes Element auf einen numerischen Typ, also wurde ein Wert, der zufällig eine Zahl war, verglichen, als wäre er ein Key; ein Kleiner-Treffer konnte den Insert-Punkt auf einen ungeraden Index setzen und das neue Paar mitten in ein bestehendes werfen, sodass alle späteren Paare aus dem Takt gerieten. EnumNumTree hatte denselben Einzelschritt-Walk. Beide iterieren jetzt Paare mit Schrittweite zwei, lesen den Key bei X * 2 und den Wert bei X * 2 + 1, und ein exakter Key-Treffer ersetzt den Wert und steigt mit Break aus. Um fair zu bleiben: Page-Label-Werte sind Dictionaries, dieser zweite Bug feuerte auf /PageLabels selbst also selten, aber ein Number-Tree-Helfer, der die falsche Schrittweite liest, ist korrumpiert, sobald irgendein Wert numerisch ist – und er wurde im selben Durchgang gefixt

Pair-Stride-Fix in PDFlibPas-Number-Trees: Ein /Nums-Array ist ein flacher Zug abwechselnder Key- und Value-Einträge, ein Walk, der jedes Element testet, konnte ein neues Paar an einem ungeraden Index einfügen und spätere Paare aus dem Takt bringen, während der korrigierte Walk den Key bei X*2 und den Wert bei X*2+1 liest
Der Bug feuerte auf /PageLabels selten, weil Label-Werte Dictionaries sind, aber ein Number-Tree-Helfer mit falscher Schrittweite korrumpiert, sobald irgendein Wert numerisch ist, also gehen beide Walks jetzt paarweise

Labels zurücklesen und als Round Trip führen

TPDFlib.GetPageLabel(Page) gibt das Label für eine 1-basierte Seite zurück und hat zwei Fallbacks, die man kennen sollte. Ohne jeglichen /PageLabels-Eintrag gibt es die dezimale Seitennummer zurück, ein Aufrufer kann es also bedenkenlos immer verwenden. Mit vorhandenem Tree, aber keinem Bereich, der die Seite abdeckt, gibt es einen leeren String zurück – genau das passiert, wenn eine Datei den zwingenden Index-0-Eintrag überspringt; die Referenzdokumentation sagt, dass ein Bereich ab Seite 1 existieren muss, damit Labels korrekt angezeigt werden, und der Code macht diese Anforderung sichtbar. Buchstaben-Stile folgen der Spezifikation, nicht Tabellenkalkulations-Spalten: Nach Z kommt AA, dann BB – der Buchstabe wiederholt sich, statt zu übertragen

var
  P: Integer;
  Data: WideString;
begin
  // Kurzer Audit dessen, was ein Viewer in seiner Seitenbox zeigen wird
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // Optionswert 4 exportiert nur Label-Bereiche als PageLabelBegin-Records
  Data := Lib.ExportDocumentData(4);
  // Der Import spielt sie über ClearPageLabels + AddPageLabels ab
  Lib.ImportDocumentData(Data, 0);
end;

Für Massen-Edits schreibt ExportDocumentData mit Optionswert 4 jeden Bereich als PageLabelBegin-Block mit PageLabelNewIndex-, PageLabelStart-, PageLabelPrefix- und PageLabelNumStyle-Zeilen, und ImportDocumentData behandelt den ersten Label-Record, den es sieht, als vollständige Ersetzung: Es ruft einmal ClearPageLabels auf und füttert dann jeden Record an AddPageLabels. Das macht einen Text-Round-Trip deterministisch, selbst wenn die Originaldatei einen /Kids-Tree hatte, denn das Leeren entfernt den kompletten Katalog-Eintrag, und der neu gebaute Tree ist von Anfang an ein einziges Blatt

Was garantiert der Fix weiterhin nicht?

Das Flachmachen ist eine Einbahnstraße und vertraut der Reihenfolge, die es vorfindet. EnumNumTree sammelt Paare in Dateireihenfolge ein, und GetPageLabel wendet den letzten Bereich an, dessen Key kleiner oder gleich dem Seitenindex ist, also kann eine Fremddatei, deren Blätter out of order sind – §7.9.7 verbietet das, aber solche Dateien zirkulieren – weiterhin falsche Labels liefern, bis Sie die Bereiche mit ClearPageLabels und frischen AddPageLabels-Aufrufen neu bauen. Labels hängen auch an Seitenindizes, nicht an Seitenobjekten, also lässt jede Operation, die Seitenzahl oder -ordnung ändert, die Bereiche dort, wo sie waren. Ein In-place-Tausch wie Seiten ersetzen bei erhaltenen Objektnummern hält die Zahl und damit die Labels ausgerichtet, während ein Merge wie das Zusammenführen verschränkter Duplex-Scans eine neue Seitenfolge produziert, die einen frisch geschriebenen Satz Bereiche verdient

Die hier beschriebenen Page-Label-Aufrufe, das Number-Tree-Handling und der Document-Data-Export und -Import erscheinen alle in der PDF Library for Delphi für Delphi, C++Builder und Lazarus, mit dem Referenzeintrag zu AddPageLabels, der die Stilwerte und Rückgabecodes dokumentiert