Technisch artikel

Lees bookmark- en annotatieacties in Delphi met PDFlibPas

Je erft een map PDF's van ergens stroomopwaarts, en de taak klinkt simpel: vertel me welke bookmarks naar een externe URL springen, welke JavaScript uitvoeren en waar de interne eigenlijk uitkomen. Dan open je de API-referentie en ontdek je dat de bibliotheek elk van die acties wel kan aanmaken, maar niets biedt om ze terug te lezen. Die asymmetrie zit overal in PDF-tools. Een bookmark schrijven die https://example.com opent is een eenregelige actie; een bestaand bookmark vragen "wat doe je, en waar ga je heen?" betekent meestal handmatig door de ruwe objectboom lopen via /A, /S, /Dest en een waaier aan fit-varianten die bijna niemand de eerste keer goed krijgt

PDFlibPas is een native Object Pascal PDF-bibliotheek voor Delphi en C++Builder, en lang had die dezelfde kloof: rijke write-side setters, getters die je een kale TPDFObject teruggeven en je zelf laten zoeken. De release v3.77.0 sloot daar gedeeltelijk een gat in met een kleine set getypeerde introspectie-aanroepen die het actietype, de actielading en de destinaiegeometrie als gewone records rapporteren. Dit artikel gaat over hoe die calls op het ISO 32000-1 action- en destination-model aansluiten, en over de drie concrete valkuilen die handgeschreven versies van deze code stil fout laten gaan

Waarom het lezen van acties moeilijker is dan ze schrijven

Een actie in PDF is een dictionary met een /S-sleutel die het subtype benoemt: GoTo, GoToR, URI, Launch, Named, JavaScript, en een langere staart die je zelden tegenkomt (ISO 32000-1 §12.6.4). Het probleem is dat de payload voor elk subtype in een andere sleutel zit, en dat er geen uniforme "geef me de target"-slot bestaat. Een URI-actie bewaart zijn adres in /URI. Een GoToR- of Launch-actie bewaart een file specification in /F. Een JavaScript-actie bewaart zijn script in /JS, dat een string of een stream kan zijn. Een GoTo-actie draagt zelf helemaal geen payload; zijn target is een destination, hangend aan /D, die je daarna apart moet oplossen

Wanneer je een actie schrijft, weet je het type vooraf, dus maakt dit niets uit. Wanneer je er één leest, moet je eerst op /S brancheren, daarna in de juiste sleutel kijken, en vervolgens omgaan met het feit dat hetzelfde logische concept, "het ding waar deze actie naar wijst", op drie incompatibele manieren gecodeerd is. Die branchlogica vangen de getypeerde getters af. GetOutlineActionInfo en GetAnnotActionInfo geven allebei een TPDFlibActionInfo-record terug:

Het record vertelt je via Kind welke velden betekenisvol zijn. Komt Kind terug als akURI, lees dan URI en negeer de rest. Komt het terug als akGoTo, dan zijn geen van de payloadvelden van toepassing en ga je door naar de destination, wat verderop als aparte call terugkomt. akNone is het eerlijke antwoord wanneer de bookmark of annotatie helemaal geen actie heeft, in plaats van een nul waarvan je maar moet raden wat die betekent

Door de outline tree lopen om een bookmark te vinden

Voor je een bookmark kunt introspecteren heb je zijn handle nodig. PDFlibPas identificeert outline nodes met een integer ID, en FindOutlineByTitle zoekt er één op basis van zichtbare tekst, met expliciete controle over hoe ver de zoekactie reikt:

Het Depth-argument is het deel dat de moeite waard is om even bij stil te staan. osdSiblingsOnly scant de sibling chain op het niveau van het startknooppunt en stopt daar; hij vindt een peer-bookmark maar daalt nooit af in de kinderen van een peer. osdChildrenOnly kijkt één niveau omlaag, in de directe kinderen van het startknooppunt. osdFullSubTree recursief de hele tak. Het verkeerde kiezen is een stille miss, geen fout: een sibling-only search voor een titel die twee niveaus diep leeft, geeft gewoon nul terug, en je concludeert dat het bookmark niet bestaat terwijl het er wel degelijk was. Geef GetFirstOutline als start-ID om vanaf de documentroot te zoeken

De match is op de exacte titelsring, vergeleken als een WideString, dus ze is case-sensitive en respecteert de Unicode-tekst exact zoals die is opgeslagen. Als je bron-PDF's van inconsistente producers komen, normaliseer de titel waar je op zoekt op dezelfde manier als het document hem heeft opgeslagen, anders jaag je spookmisses na

Een bookmarkactie en doel oplossen

Met een handle in de hand geeft GetOutlineActionInfo je de getypeerde weergave. Het patroon is: aanroepen, switchen op Kind, het veld lezen dat bij dat type hoort

Hier zit de eerste echte valkuil, en die kwam tijdens de implementatie boven door testfeedback. Er is een oudere getter, GetActionURL, en die pakken om een URI-actie te lezen is de voor de hand liggende fout. GetActionURL lost een file specification op via de /F-sleutel. Dat is goed voor GoToR en Launch, waarvan de target echt een bestand is, maar het is de verkeerde sleutel voor een URI-actie. Het adres van een URI-actie is een gewone string op de eigen /URI-sleutel van de actie, geen file spec. Geef je een URI-actie aan het file-spec-pad door, dan krijg je een leeg of onzinnig resultaat. De getypeerde getter doet dit intern goed door voor akURI direct /URI te lezen en alleen de file-specification resolver in te roepen voor akGoToR en akLaunch, precies het onderscheid dat een handgeschreven versie vaak laat vervagen

Destination fit types en de geometrie erachter

Een akGoTo-actie betekent "navigeer binnen dit document", maar zegt niets over waar of hoe. Dat is de taak van de destination, en destinations hebben meer nuance dan mensen verwachten. Een PDF-destination is niet alleen een pagenummer; het is een pagina plus een "fit"-specificatie die zegt hoe de viewer die pagina moet framen (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo geeft die terug als een record:

De acht fit-types beantwoorden verschillende framingvragen. dkXYZ positioneert een specifiek punt linksboven met een expliciete zoom, dus gebruikt het Left, Top en Zoom. dkFit past de hele pagina in het venster en negeert coördinaten. dkFitH en dkFitV passen respectievelijk de breedte of hoogte van de pagina aan met één relevante coördinaat, een bovenrand of een linkerrand. dkFitR is de interessante: die past een opgegeven rechthoek, dus alle vier randen tellen. De dkFitB*-familie doet hetzelfde, maar dan relatief aan de bounding box van zichtbare content in plaats van de volledige pagina. Weten welke velden levend zijn per type is het verschil tussen een destination juist lezen en garbage-coördinaten afdrukken die toevallig nul zijn

Onder de motorkap leunt de implementatie op een bewuste alignering die de moeite waard is om te kennen, omdat die uitlegt waarom de mapping betrouwbaar is. De interne GetDestType geeft een geheel getal 1..8 terug voor de acht fit-types, in exact de volgorde XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind is zo gedeclareerd dat de ordinalen één-op-één meelopen: dkXYZ heeft ordinal 1, dkFitBV ordinal 8, met dkNone op nul. De conversie is dus een directe ordinal cast met een range guard, geen lookup table die uit sync kan raken als de enum groeit. Dat is klein, maar precies het soort detail dat, op naïeve wijze gedaan, een off-by-one-bug wordt zodra iemand de enumeratie herschikt

Een Page van nul is het signaal dat de destination niet resolveerde, meestal omdat de actie geen destination draagt of de benoemde destination nooit is gevonden. Controleer dat voordat je op de coördinaten vertrouwt. Merk ook op dat GetOutlineDestinationInfo op beide plaatsen kijkt waar een destination kan leven: direct op het bookmark via /Dest, en in de ingebedde GoTo-actie via /D. Je hoeft niet te weten welke vorm de producer gebruikte

Annotatieacties en de SelectPage-val

Link-annotaties dragen acties precies zoals bookmarks dat doen, en GetAnnotActionInfo geeft hetzelfde TPDFlibActionInfo-record terug met hetzelfde kind-then-payloadpatroon. Maar hier zit een stateful addertje onder het gras dat niet op outlines van toepassing is, en dat is de derde valkuil

Annotaties horen bij pagina's, en PDFlibPas exposeert de annotaties van de huidige pagina via state die pas geldig wordt nadat je die pagina hebt geselecteerd. Roep GetAnnotActionInfo aan zonder eerst SelectPage(N) te gebruiken, en de annotatiehandle is nul; de call geeft akNone terug en je concludeert onterecht dat de pagina geen actievolle annotaties heeft. De oplossing is één regel, maar die vergeet je makkelijk wanneer je over pagina's heen loopt:

Twee dingen in die lus zijn bewust. Ten eerste komt SelectPage(P) vóór elke annotatie-access op elke iteratie; de per-page annotatiestate loopt niet door. Ten tweede gebruikt de existence test GetAnnotActionID(1) <> 0 in plaats van CheckPageAnnots. Die laatste rapporteert aanwezigheid als een boolean-achtige vlag in plaats van een count, dus een niet-nul action ID is de preciezere manier om te vragen "is er een eerste annotatie, en draagt die een actie die ik kan lezen?" Nog een subtiliteit om te noemen: voor annotaties wordt een JavaScript-actie-script direct uit /JS gelezen, waarbij een stream wordt gedecodeerd wanneer het script zo opgeslagen is en anders een string wordt gelezen, zodat beide gangbare encodings werken

Waar read-side introspectie past

Deze getters zijn bewust smal. Het zijn pure reads boven op de bestaande integer-handle action- en destinationlagen van de bibliotheek, dus ze raken geen write-pad en voegen geen risico toe aan documenten die je tegelijk bewerkt. Ze rapporteren wat er in het bestand staat; ze valideren het niet tegen een beleid en herschrijven niets. Als je het omgekeerde wilt, bookmarks en link-annotaties bouwen die deze acties dragen, zit dat op de write-kant, en het begeleidende stuk over interactieve form actions en JavaScript in Delphi laat zien hoe je ze maakt. Voor het uit een PDF trekken van de zichtbare en structurele content in plaats van zijn navigatiegraaf, zie extracting text, images, and fonts with PDFlibPas

De eerlijke grens om in gedachten te houden: introspectie ziet alleen wat de producer echt heeft geschreven. Een bookmark waarvan de actie door een generator is misvormd, of een destination die naar een benoemde target wijst die nooit gedefinieerd is, verschijnt als akNone of een nulpagina in plaats van een exception. Dat is het juiste gedrag voor een read API die onbetrouwbare bestanden inspecteert, maar het betekent dat je code die nulwaarden moet behandelen als "afwezig of onopgelost", niet als een garantie op correcte input. De getypeerde action- en destination-introspectie die hier is getoond, maakt deel uit van PDFlibPas, de native PDF-bibliotheek voor Delphi en C++Builder

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;
type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;
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;
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;
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;
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;
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;