Technischer Artikel

PDF-Lesezeichen und Anmerkungsaktionen in Delphi lesen

Sie erben einen Ordner mit PDFs von irgendwoher, und die Aufgabe klingt trivial: Sagen Sie mir, welche Lesezeichen zu einer externen URL springen, welche JavaScript ausführen und wo die internen tatsächlich landen. Dann öffnen Sie die API-Referenz und stellen fest, dass die Bibliothek jede dieser Aktionen erstellen kann, aber nichts bietet, um sie wieder auszulesen. Diese Asymmetrie gibt es überall in den PDF-Werkzeugen. Das Schreiben eines Lesezeichens, das https://example.com öffnet, ist ein Einzeiler; das Abfragen eines vorhandenen Lesezeichens nach dem Motto „Was tust du und auf welches Ziel verweist du?“ bedeutet in der Regel das manuelle Durchsuchen des rohen Objektbaums über /A, /S, /Dest und eine Vielzahl von Anpassungstyp-Varianten, die fast niemand beim ersten Mal richtig hinbekommt

PDFlibPas ist eine native Object-Pascal-PDF-Bibliothek für Delphi und C++Builder, und lange Zeit hatte sie dieselbe Lücke: Umfangreiche schreibende Setter, lesende Getter, die einem ein nacktes TPDFObject zurückgaben und einen selbst forschen ließen. Die Version 3.77.0 schloss einen Teil davon mit einem kleinen Satz typisierter Introspektionsaufrufe, die die Aktionsart, die Aktionsnutzlast und die Zielgeometrie als einfache Records zurückgeben. In diesem Artikel geht es darum, wie diese Aufrufe auf das Aktions- und Zielmodell von ISO 32000-1 abgebildet werden und welche drei konkreten Fallen dazu führen, dass selbstgeschriebene Versionen dieses Codes stillschweigend fehlschlagen

Warum das Lesen von Aktionen schwieriger ist als das Schreiben

Eine Aktion in einer PDF-Datei ist ein Dictionary mit einem /S-Schlüssel, der ihren Untertyp benennt: GoTo, GoToR, URI, Launch, Named, JavaScript und eine längere Liste, auf die man selten stößt (ISO 32000-1 §12.6.4). Das Problem ist, dass die Nutzlast für jeden Untertyp in einem anderen Schlüssel liegt und es keinen einheitlichen Steckplatz „Gib mir das Ziel“ gibt. Eine URI-Aktion speichert ihre Adresse in /URI. Eine GoToR- oder Launch-Aktion speichert eine Dateispezifikation in /F. Eine JavaScript-Aktion speichert ihr Skript in /JS, was entweder eine Zeichenfolge oder ein Stream sein kann. Eine GoTo-Aktion trägt selbst überhaupt keine Nutzlast; ihr Ziel ist ein Zielort (Destination), der an /D hängt und den Sie dann separat auflösen müssen

Wenn Sie eine Aktion schreiben, kennen Sie deren Art im Voraus, so dass all dies keine Rolle spielt. Wenn Sie eine lesen, müssen Sie zuerst nach /S verweisen, dann in den richtigen Schlüssel greifen und dann damit umgehen, dass dasselbe logische Konzept („das Ding, auf das diese Aktion zeigt“) auf drei inkompatible Weisen kodiert ist. Diese Verzweigung ist genau das, was die typisierten Getter abfangen. GetOutlineActionInfo und GetAnnotActionInfo geben beide einen TPDFlibActionInfo-Record zurück:

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // populated for akURI
    JavaScript: WideString;   // populated for akJavaScript
    FileName: AnsiString;     // populated for akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

Der Record teilt Ihnen über Kind mit, welche Felder von Bedeutung sind. Wenn Kind als akURI zurückgegeben wird, lesen Sie URI und ignorieren den Rest. Wenn es als akGoTo zurückgegeben wird, trifft keines der Nutzlastfelder zu und Sie fahren mit der Destination fort, was ein separater Aufruf ist, der weiter unten behandelt wird. akNone ist die ehrliche Antwort, wenn das Lesezeichen oder die Anmerkung überhaupt keine Aktion hat, einer Null vorzuziehen, deren Bedeutung Sie erraten müssen

Den Gliederungsbaum durchlaufen, um ein Lesezeichen zu finden

Bevor Sie ein Lesezeichen untersuchen können, benötigen Sie dessen Handle. PDFlibPas identifiziert Gliederungsknoten (Outline Nodes) durch eine Ganzzahl-ID, und FindOutlineByTitle lokalisiert einen anhand seines sichtbaren Textes mit expliziter Kontrolle darüber, wie weit die Suche reicht:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

Das Argument Depth ist der Teil, bei dem man innehalten sollte. osdSiblingsOnly scannt die Geschwisterkette auf der Ebene des Startknotens und stoppt; es findet ein gleichgeordnetes Lesezeichen, steigt aber niemals in die Kinder eines Gleichgeordneten ab. osdChildrenOnly sucht eine Ebene tiefer, in den unmittelbaren Kindern des Startknotens. osdFullSubTree durchläuft rekursiv den gesamten Zweig. Die Wahl des falschen Werts ist ein stiller Misserfolg, kein Fehler: Eine reine Geschwistersuche nach einem Titel, der zwei Ebenen tiefer liegt, gibt einfach Null zurück, und Sie kommen zu dem Schluss, dass das Lesezeichen nicht existiert, obwohl es die ganze Zeit vorhanden war. Übergeben Sie GetFirstOutline als Start-ID, um von der Dokumentenwurzel aus zu suchen

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Search the whole tree from the root for a nested bookmark.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID is now a handle you can pass to the action and
        // destination getters below.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

Der Abgleich erfolgt auf der exakten Titelzeichenfolge, verglichen als WideString, ist also fallsensitiv und berücksichtigt den Unicode text genau so, wie er gespeichert ist. Wenn Ihre Quell-PDFs von inkonsistenten Erzeugern stammen, normalisieren Sie den gesuchten Titel auf dieselbe Weise, wie das Dokument ihn gespeichert hat, da Sie sonst Phantom-Misserfolge jagen

Auflösen der Aktion und des Ziels eines Lesezeichens

Mit einem Handle in der Hand bietet Ihnen GetOutlineActionInfo die typisierte Ansicht. Das Muster ist: Aufrufen, nach Kind verzweigen, das von dieser Art ausgefüllte Feld lesen

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // see destination below
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

Hier befindet sich die erste echte Falle, und es ist diejenige, die das Test-Feedback während der Implementierung zutage gefördert hat. Es gibt einen älteren Getter namens GetActionURL, und der Versuch, damit eine URI-Aktion auszulesen, ist der naheliegende Fehler. GetActionURL löst eine Dateispezifikation über den /F-Schlüssel auf. Das ist das Richtige für GoToR und Launch, deren Ziele tatsächlich Dateien sind, aber es ist der völlig falsche Schlüssel für eine URI-Aktion. Die Adresse einer URI-Aktion ist eine einfache Zeichenfolge im aktionseigenen /URI-Schlüssel, keine Dateispezifikation. Übergibt man eine URI-Aktion an den Dateispezifikationspfad, erhält man ein leeres oder unsinniges Ergebnis. Der typisierte Getter erledigt dies intern, indem er /URI direkt für akURI liest und den Dateispezifikations-Resolver nur für akGoToR und akLaunch aufruft — genau der Unterschied, den eine selbstgeschriebene Version tendenziell verwischt

Ziel-Fit-Typen (Framing) und die Geometrie dahinter

Eine akGoTo-Aktion bedeutet „Navigieren innerhalb dieses Dokuments“, sagt aber nichts darüber aus, wohin oder wie. Das ist Aufgabe des Zielorts (Destination), und Zielorte enthalten mehr Nuancen, als man erwartet. Ein PDF-Zielort ist nicht nur eine Seitenzahl; es ist eine Seite plus eine „Fit“-Spezifikation, die angibt, wie der Viewer diese Seite einrahmen soll (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo gibt dies als Record zurück:

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // 1-based; 0 when unresolved
    Left, Top, Right, Bottom, Zoom: Double;
  end;

Die acht Fit-Arten beantworten unterschiedliche Einrahmungsfragen. dkXYZ positioniert einen bestimmten Punkt bei einem expliziten Zoom in der oberen linken Ecke und verwendet daher Left, Top und Zoom. dkFit passt die gesamte Seite in das Fenster ein und ignoriert Koordinaten. dkFitH und dkFitV passen die Seitenbreite oder -höhe mit einer einzigen relevanten Koordinate an (eine Oberkante oder eine Linkskante). dkFitR ist die interessante Variante: Sie passt in ein bestimmtes Rechteck ein, so dass alle vier Kanten eine Rolle spielen. Die Familie dkFitB* tut dasselbe relativ zum Begrenzungsrahmen (Bounding Box) des sichtbaren Inhalts statt der vollständigen Seite. Zu wissen, welche Felder für jede Art aktiv sind, macht den Unterschied aus, ob man einen Zielort korrekt ausliest oder Müllkoordinaten ausgibt, die zufällig Null sind

PDF-Reader-Lesezeichen-Navigationsbereich mit einem verschachtelten Gliederungsbaum
Jedes Lesezeichen in diesem Navigationsbereich verweist auf eine Aktion und bei internen Sprüngen auf einen Zielort mit eigenem Fit-Typ und Koordinaten.

Unter dem Deckmantel stützt sich die Implementierung auf eine bewusste Ausrichtung, die man kennen sollte, weil sie erklärt, warum die Abbildung zuverlässig ist. Das interne GetDestType gibt eine Ganzzahl 1..8 für die acht Fit-Arten in genau der Reihenfolge XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV zurück. TPDFlibDestinationKind ist so deklariert, dass seine Ordinalzahlen eins zu eins übereinstimmen: dkXYZ hat die Ordinalzahl 1, dkFitBV die Ordinalzahl 8, wobei dkNone auf Null liegt. Die Konvertierung ist also ein direkter Ordinal-Cast mit einer Bereichsprüfung, keine Nachschlagetabelle, die asynchron werden kann, wenn das Enum wächst. Das ist ein kleines Detail, aber es ist die Art von Sache, die naiv umgesetzt zu einem Off-by-one-Fehler führt, sobald jemand eine Enumeration umstrukturiert

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destination did not resolve
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

Eine Page von Null ist das Signal, dass sich der Zielort nicht auflösen ließ, meist weil die Aktion keinen Zielort trägt oder das benannte Ziel nicht gefunden werden konnte. Überprüfen Sie dies, bevor Sie einer Koordinate vertrauen. Beachten Sie auch, dass GetOutlineDestinationInfo an beiden Stellen sucht, an denen ein Zielort liegen kann: direkt im /Dest des Lesezeichens und in dem /D einer eingebetteten GoTo-Aktion. Sie müssen nicht wissen, welches Format der Erzeuger verwendet hat

Anmerkungsaktionen (Annotation Actions) und die SelectPage-Falle

Link-Anmerkungen tragen Aktionen genau wie Lesezeichen, und GetAnnotActionInfo gibt denselben TPDFlibActionInfo-Record mit demselben Muster aus Art und Nutzlast zurück. Aber hier gibt es eine zustandsabhängige Falle, die für Gliederungen nicht gilt, und das ist die dritte Falle

Anmerkungen gehören zu Seiten, und PDFlibPas macht die Anmerkungen der aktuellen Seite über einen Zustand zugänglich, der erst gültig wird, nachdem Sie diese Seite ausgewählt haben. Rufen Sie GetAnnotActionInfo auf, ohne vorher SelectPage(N) aufgerufen zu haben, ist das Anmerkungshandle Null; der Aufruf gibt akNone zurück, und Sie kommen fälschlicherweise zu dem Schluss, dass die Seite keine ausführbaren Anmerkungen enthält. Die Behebung besteht aus einer einzigen Zeile, die man jedoch beim Schleifendurchlauf über Seiten leicht vergisst:

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // mandatory before touching annotations
    // GetAnnotActionID(1) <> 0 is the reliable "has an action"
    // test. CheckPageAnnots returns a boolean-style flag, not a
    // count, so it is the weaker signal here.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

Zwei Dinge in dieser Schleife sind bewusst so gewählt. Erstens steht SelectPage(P) bei jeder Iteration vor jedem Anmerkungszugriff; der Anmerkungszustand pro Seite wird nicht übernommen. Zweitens verwendet der Existenztest GetAnnotActionID(1) <> 0 anstelle von CheckPageAnnots. Letzteres meldet das Vorhandensein als Flag im booleschen Stil und nicht als Anzahl, so dass eine Aktions-ID ungleich Null der präzisere Weg ist, um zu fragen: „Gibt es eine erste Anmerkung und trägt sie eine Aktion, die ich lesen kann?“ Eine weitere Subtilität, die man erwähnen sollte: Bei Anmerkungen wird das Skript einer JavaScript-Aktion direkt aus /JS gelesen, wobei ein Stream dekodiert wird, wenn das Skript so gespeichert ist, und andernfalls eine Zeichenfolge gelesen wird, so dass es beide gängigen Kodierungen übersteht

Wo die lesende Introspektionsmethode hingehört

Diese Getter sind bewusst eng gefasst. Es handelt sich um reine Lesevorgänge auf der Grundlage der bestehenden Ganzzahl-Handle-Aktion und Zielebenen der Bibliothek. Sie berühren also keinen Schreibpfad und stellen kein Risiko für Dokumente dar, die Sie gerade bearbeiten. Sie melden, was in der Datei steht; sie validieren es nicht gegen eine Richtlinie oder schreiben irgendetwas um. Wenn Ihr Ziel das Gegenteil ist — das Erstellen von Lesezeichen und Link-Anmerkungen, die diese Aktionen überhaupt erst tragen —, liegt dies auf der schreibenden Seite, und der Begleitartikel über interaktive Formularaktionen und JavaScript in Delphi führt durch deren Erstellung. Wie man sichtbare und strukturelle Inhalte aus einer PDF-Datei extrahiert (anstatt ihres Navigationsgraphen), lesen Sie unter Extrahieren von Text, Bildern und Schriftarten mit PDFlibPas

Die ehrliche Grenze, die man im Auge behalten sollte: Die Introspektion sieht nur das, was der Erzeuger tatsächlich geschrieben hat. Ein Lesezeichen, dessen Aktion ein Generator fehlerhaft hinterlassen hat, oder ein Zielort, der auf ein benanntes Ziel zeigt, das nie definiert wurde, wird als akNone oder eine Nullseite statt einer Ausnahme dargestellt. Das ist das richtige Verhalten für eine Lese-API, die nicht vertrauenswürdige Dateien prüft, bedeutet aber, dass Ihr Code diese Null-Ergebnisse als „fehlend oder ungelöst“ behandeln sollte und nicht als Garantie für eine wohlgeformte Eingabe. Die hier gezeigte typisierte Aktions- und Zielort-Introspektion ist Teil von PDFlibPas, der nativen PDF-Bibliothek für Delphi und C++Builder