Technischer Artikel

Lesezeichen zu bestehenden PDFs in Delphi mit HotPDF hinzufügen

HotPDF fügt Lesezeichen zu einer bestehenden PDF-Datei in Delphi über AddLoadedOutline hinzu, das Gliederungseinträge auf jeder Verschachtelungsebene in einem geladenen Dokument erstellt. Es liest und schreibt benannte Ziele über ResolveLoadedNamedDestination und AddLoadedNamedDestination. Laden Sie die Datei, erstellen Sie die Baumstruktur, rufen Sie SaveLoadedDocument auf, und die zuvor leere Seitenleiste des Readers enthält nun ein funktionierendes Inhaltsverzeichnis

Das Szenario, das all dies motiviert, ist deprimierend häufig. Sie führen ein Dutzend Verträge in ein einziges Prüfpaket zusammen oder heften drei Produkthandbücher in eine einzige verteilbare Datei, und die Ausgabe ist strukturell in Ordnung: jede Seite vorhanden, jede Schriftart intakt. Dann öffnet es jemand in Acrobat und das Lesezeichen-Fenster ist leer. Ein 400-seitiges Dokument ohne Navigation ist zwar technisch vollständig, aber praktisch unbrauchbar. Bis zur Version 2.347.0 konnte HotPDF Lesezeichen in geladenen Dokumenten zwar lesen, ändern und löschen, aber niemals erstellen. Diese Lücke ist nun geschlossen, und dieser Artikel führt Sie durch die neue API-Struktur und das eine Detail des PDF-Geheimwissens, den Schlüssel /Count, der Ihnen zum Verhängnis wird, wenn Sie ihn als einfachen Eintragszähler behandeln

Was die Gliederungsstruktur tatsächlich ist

Eine PDF-Gliederung ist eine doppelt verkettete Baumstruktur aus Wörterbüchern (Dictionaries), keine flache Liste, und diese Struktur erklärt jeden Parameter in der API. ISO 32000-1 §12.3.3 definiert dies: Der Dokumentenkatalog verweist auf einen /Outlines-Root, der Root verweist über /First und /Last auf seine ersten und letzten Kindelemente, und Geschwisterelemente verketten sich über /Next und /Prev, während jeder Eintrag über /Parent nach oben verweist. Jeder Eintrag enthält eine /Title-Zeichenfolge und in der Regel ein /Dest-Ziel, das angibt, wohin das Anklicken den Leser führen soll

Ziele gibt es in zwei Varianten, und ISO 32000-1 §12.3.2 trennt sie bewusst. Ein explizites Ziel ist ein Inline-Array wie [page /XYZ x y zoom]: ein direkter Verweis auf ein Seitenobjekt plus eine Ansichtsspezifikation. Ein benanntes Ziel speichert stattdessen eine Namenszeichenfolge, und das tatsächliche Ziel befindet sich im Dokumentenkatalog unter der Baumstruktur /Names /Dests. Dies ist eine Dereferenzierungsebene, die es ermöglicht, dass viele Links dasselbe Ziel teilen und sich das Ziel bewegen kann, ohne die Links anfassen zu müssen. HotPDF schreibt explizite /XYZ-Ziele, wenn es Lesezeichen erstellt, und bietet separate Aufrufe zum Lesen und Erweitern der Namensstruktur

Wie fügt man Lesezeichen zu einer bestehenden PDF in Delphi hinzu?

Rufen Sie AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) nach LoadFromFile auf: Übergeben Sie ParentIndex = -1 für einen Eintrag auf oberster Ebene oder den nullbasierten Index eines vorhandenen Eintrags auf oberster Ebene, um ihn darunter zu verschachteln. Die Funktion erstellt den /Outlines-Root, falls das Dokument noch keinen hatte, baut das Eintragswörterbuch auf, schreibt ein Array /Dest [page /XYZ x y zoom], das auf den nullbasierten Index DestPageIndex verweist, und verkettet den neuen Eintrag in die Geschwisterkette. Sie gibt bei Erfolg 0 und bei Fehlschlag -1 zurück, z. B. wenn DestPageIndex außerhalb des Bereichs liegt

Ein Verkettungsdetail ist für die Reihenfolge wichtig: Ein neuer Eintrag wird am Anfang der Kindkette seines Elternteils eingefügt, als neues /First. Wenn Sie also „Kapitel 1“ und then „Kapitel 2“ als Einträge auf oberster Ebene hinzufügen, Kapitel 2 erscheint in der Seitenleiste über Kapitel 1, und Kapitel 1 verschiebt sich auf den Index 1 der obersten Ebene. Das praktische Vorgehen besteht darin, Einträge entweder in umgekehrter Lesereihenfolge hinzuzufügen oder jedes Elternteil hinzuzufügen und sofort seine Kinder zu befüllen, während es sich noch auf Index 0 befindet

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Add chapters in reverse reading order: each new
      // top-level entry becomes /First (index 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 is now top-level index 0; nest sections
      // under it (children also prepend, so reverse order).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

Die Parameter X, Y, und Zoom beziehen sich direkt auf das /XYZ-Ziel und sind standardmäßig auf 0 gesetzt. PDF platziert den Koordinatenursprung in der linken unteren Ecke, sodass Y = 792 auf den oberen Rand einer US-Letter-Seite zielt. Gemäß ISO 32000-1 weist ein Nullwert in einem /XYZ-Slot den Viewer an, seine aktuelle Einstellung beizubehalten, was in fast jedem Fall für den Zoom genau das Richtige ist. Alles geschieht auf der geladenen Objektstruktur im Speicher; nichts wird auf die Festplatte geschrieben, bis SaveLoadedDocument ausgeführt wird – dasselbe Edit-Then-Save-Modell, das die API für geladene Dokumente zum Umschreiben von Titel, Autor und XMP-Metadaten in geladenen PDFs verwendet

Neuausrichtung von Lesezeichen: Seitensprünge versus URI-Aktionen

Bestehende Lesezeichen können neu verdrahtet werden, ohne sie neu zu erstellen. HotPDF bietet für die beiden Fälle zwei unterschiedliche Aufrufe, da die zugrunde liegenden PDF-Konstrukte grundlegend verschieden sind. SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom) ersetzt das /Dest-Ziel des Eintrags auf oberster Ebene bei Index durch ein neues /XYZ-Array – das Werkzeug für den klassischen Fehler nach dem Zusammenführen, bei dem jedes Lesezeichen immer noch auf die Seitenzahlen der ursprünglichen Datei vor dem Zusammenführen verweist. SetLoadedOutlineURI(Index, URI) tut etwas strukturell anderes: Es fügt ein Aktionswörterbuch /A mit /S /URI hinzu, wodurch das Lesezeichen in einen Weblink statt in einen Sprung innerhalb des Dokuments umgewandelt wird

// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

Halten Sie die beiden Mechanismen bei der Gestaltung der Baumstruktur auseinander. Ein /Dest-Ziel dient der reinen Navigation innerhalb des Dokuments; eine URI-Aktion verlässt das Dokument vollständig. ISO 32000-1 erwartet, dass ein Gliederungseintrag über den einen oder den anderen Mechanismus navigiert, nicht über beide. Reservieren Sie SetLoadedOutlineURI daher für Einträge, die noch kein Seitenziel haben – z. B. ein Lesezeichen „Produkt-Homepage“ oder „Problem melden“ am Ende der Baumstruktur und nicht für eine konvertierte Kapitelüberschrift. Die hier geschriebene URI-Aktion entspricht dem /S /URI-Konstrukt, das auch für Link-Anmerkungen auf Seiteninhalten verwendet wird, was in der Anleitung zum Erstellen von Hyperlinks in PDF-Dokumenten mit HotPDF ausführlicher beschrieben ist

Wie funktionieren benannte Ziele in einer geladenen PDF?

Benannte Ziele sind der stabile Anker-Mechanismus von ISO 32000-1 §12.3.2.3: Anstatt in jedem Link einen Seitenverweis einzubetten, pflegt das Dokument eine Zuordnung von Namen zu Zielen in der Baumstruktur /Names /Dests des Katalogs, und Links verweisen namentlich auf Einträge. ResolveLoadedNamedDestination(Name) sucht nach einem Namen in diesem Baum und gibt den nullbasierten Seitenindex zurück, auf den er verweist, oder -1, wenn der Name nicht vorhanden ist. Die Auflösung unterstützt beide vom Standard erlaubten Speicherformen für den zugeordneten Wert: ein reines Ziel-Array wie [page /XYZ x y zoom] und die Wörterbuch-Form, die das Array unter einem /D-Schlüssel kapselt

AddLoadedNamedDestination(Name, DestPageIndex, X, Y) geht den umgekehrten Weg: Es registriert einen neuen Anker im Baum /Names /Dests, wobei es den Baum selbst erstellt, falls das Dokument noch keinen besitzt, und gibt bei Erfolg True zurück. Zusammen deckt das Paar den Workflow ab, bei dem ein zusammengeführtes Dokument Anker berücksichtigen muss, die von vorgeschalteten Werkzeugen geschrieben wurden, und neue Anker für nachgeschaltete Links bereitstellen muss

var
  PageIdx: Integer;
begin
  // Honor an anchor another tool wrote into /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publish a new anchor for other links to target.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

Die ehrlichen Grenzen: Die Ziele, die HotPDF sowohl für Lesezeichen als auch für benannte Anker schreibt, sind /XYZ-Ziele. Die anderen expliziten Typen, die der Standard definiert (wie /Fit, /FitH, /FitB usw.), werden durch diese Aufrufe nicht erzeugt, obwohl /XYZ mit Zoomwert Null den Hauptanwendungsfall „gehe zu dieser Stelle, behalte meinen Zoom“ abdeckt. Und GetLoadedBookmarkPageIndex(Title), die praktische Suchfunktion, die einen Lesezeichentitel entgegennimmt und die Zielseite zurückgibt, scannt die oberste Ebene der Gliederungsstruktur. Verwenden Sie sie daher zur Überprüfung der gerade erstellten Kapitel-Einträge und nicht für tief verschachtelte Kindelemente

Die /Count-Falle: Sie zählt Nachfahren, keine Einträge

Der Schlüssel /Count im Gliederungs-Root bedeutet nicht das, was die meisten Entwickler annehmen, und diese falsche Annahme führt zu Testfehlern, die wie eine Datenbeschädigung aussehen. ISO 32000-1 §12.3.3 definiert /Count als die Gesamtzahl der sichtbaren Nachfahren auf allen Ebenen, nicht als die Anzahl der Einträge auf oberster Ebene. Bei einem einzelnen Eintrag hat auch das Vorzeichen eine Bedeutung: Ein positiver Wert bedeutet, dass der Eintrag geöffnet ist und entsprechend viele Nachfahren in der Seitenleiste angezeigt werden, während ein negativer Wert bedeutet, dass der Eintrag eingeklappt ist und |N| Nachfahren darin verborgen sind

Dies ergab sich aus der Regressionstest-Suite von HotPDF während der Entwicklung der Lesezeichen-APIs für geladene Dokumente. Ein Testdokument enthielt drei Lesezeichen auf oberster Ebene, eines davon mit einem Kindelement. Nach dem Löschen eines Eintrags auf oberster Ebene meldete GetLoadedOutlineCount, das den Root-Wert /Count liest, 1 anstelle der erwarteten 2. Es war nichts beschädigt: Die anfängliche 3 war von Anfang an keine „drei Einträge auf oberster Ebene“, und die korrekte Neuberechnung muss die Kette der obersten Ebene durchlaufen, jeden Knoten plus seinen positiven /Count-Wert akkumulieren und die Nachfahren eingeklappter Knoten überspringen. Die Lehre für Ihren eigenen Code ist direkt: Machen Sie nach strukturellen Änderungen niemals Zusicherungen (Asserts) auf Differenzen von /Count, da sich der Wert nicht-linear mit den sichtbaren Nachfahren ändert. Überprüfen Sie die Struktur stattdessen anhand des Inhalts

// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

Wo sich dies in das Toolset für geladene Dokumente einfügt

Das Erstellen von Lesezeichen und benannten Zielen vervollständigt ein Bild, das die API für geladene Dokumente schon seit Längerem zeichnet: Derselbe Load-Edit-Save-Zyklus schreibt bereits Dokumentenmetadaten um, fügt Link-Anmerkungen hinzu und überträgt Anmerkungen über den XFDF-Import und -Export hin und zurück. Das Muster ist einheitlich: LoadFromFile, eine Abfolge von *Loaded*-Aufrufen auf der Objektstruktur im Speicher, dann ein SaveLoadedDocument. Dies macht es einfach, die Gliederungserstellung als einen weiteren Schritt vor dem Speichern in eine bestehende Pipeline zum Zusammenführen oder Stempeln einzubinden

Die hier besprochenen APIs, AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestination und GetLoadedBookmarkPageIndex, werden standardmäßig in der HotPDF-Komponente für Delphi und C++Builder ausgeliefert, ohne externe Abhängigkeiten und ohne separate Viewer-Laufzeitumgebung