Articolo tecnico

Leggere azioni PDF di segnalibro e annotazione in Delphi

Erediti una cartella di PDF da qualche parte a monte, e il compito sembra banale: dimmi quali segnalibri saltano a un URL esterno, quali eseguono JavaScript e dove arrivano davvero quelli interni. Poi apri la documentazione API e scopri che la libreria può creare tutte quelle azioni, ma non offre alcun modo per rileggerle. Questa asimmetria è ovunque negli strumenti PDF. Scrivere un segnalibro che apre https://example.com è un'operazione di una sola riga; chiedere a un segnalibro esistente «cosa fai e verso quale destinazione?» di solito significa percorrere a mano l'albero grezzo degli oggetti attraverso /A, /S, /Dest e una ramificazione di varianti fit-type che quasi nessuno azzecca al primo colpo

PDFlibPas è una libreria PDF nativa in Object Pascal per Delphi e C++Builder, e per molto tempo ha avuto la stessa lacuna: setter ricchi sul lato di scrittura, getter che ti restituivano un semplice TPDFObject e ti lasciavano a scavare nei dettagli. La release v3.77.0 ha colmato in parte questa lacuna con un piccolo insieme di chiamate di introspezione tipizzate che riportano il tipo di azione, il payload dell'azione e la geometria della destinazione come semplici record. Questo articolo spiega come quelle chiamate si mappano sul modello di azioni e destinazioni di ISO 32000-1 e i tre tranelli concreti che fanno andare silenziosamente storta una versione scritta a mano di questo codice

Perché leggere le azioni è più difficile che scriverle

Un'azione in PDF è un dizionario con una /S chiave che ne indica il sottotipo: GoTo, GoToR, URI, Launch, Named, JavaScript e una coda più lunga di varianti che raramente si incontrano (ISO 32000-1 §12.6.4). Il problema è che il payload vive in una chiave diversa per ogni sottotipo e non esiste un campo uniforme «dammi la destinazione». Una URI azione conserva il proprio indirizzo in /URI. Una GoToR o una Launch azione conserva una specifica di file in /F. Una JavaScript azione conserva il proprio script in /JS, che può essere una stringa o uno stream. Una GoTo azione non porta affatto un payload proprio; la sua destinazione è una destinazione separata, agganciata a /D, che poi devi risolvere separatamente

Quando scrivi un'azione ne conosci già il tipo, quindi tutto questo non conta. Quando ne leggi una, devi prima diramare su /S, poi andare alla chiave giusta, poi gestire il fatto che lo stesso concetto logico ("la cosa verso cui punta questa azione") è codificato in tre modi incompatibili. È proprio questa diramazione che le funzioni getter tipizzate assorbono. GetOutlineActionInfo e GetAnnotActionInfo restituiscono entrambe un TPDFlibActionInfo record:

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;

Il record ti dice quali campi sono significativi tramite Kind. Se Kind torna akURI, leggi URI e ignora il resto. Se torna akGoTo, nessuno dei campi del payload si applica e passi alla destinazione, che è una chiamata separata trattata più avanti. akNone è la risposta onesta quando il segnalibro o l'annotazione non ha affatto un'azione, invece di uno zero di cui devi indovinare il significato

Percorrere l'albero dei segnalibri per trovare un segnalibro

Prima di poter ispezionare un segnalibro ti serve il suo handle. PDFlibPas identifica i nodi dell'outline con un ID intero, e FindOutlineByTitle ne localizza uno tramite il testo visibile con controllo esplicito su quanto lontano si estende la ricerca:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

L'argomento Depth è la parte su cui vale la pena soffermarsi. osdSiblingsOnly percorre la catena dei fratelli al livello del nodo di partenza e si ferma; troverà un segnalibro fratello ma non scenderà mai nei figli di un fratello. osdChildrenOnly scende di un livello, nei figli immediati del nodo di partenza. osdFullSubTree attraversa l'intero ramo. Scegliere quello sbagliato produce un mancato riscontro silenzioso, non un errore: una ricerca limitata ai fratelli per un titolo che si trova due livelli più in basso restituisce semplicemente zero, e tu concludi che il segnalibro non esiste quando invece era lì fin dall'inizio. Passa GetFirstOutline come ID di partenza per cercare dalla radice del documento

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;

La corrispondenza avviene sulla stringa esatta del titolo, confrontata come una WideString, quindi è case-sensitive e rispetta il testo Unicode esattamente come memorizzato. Se i PDF di origine provengono da produttori incoerenti, normalizza il titolo che cerchi nello stesso modo in cui il documento lo ha memorizzato, altrimenti inseguirai mancate corrispondenze fantasma

Risolvere l'azione e la destinazione di un segnalibro

Con un handle in mano, GetOutlineActionInfo ti offre la vista tipizzata. Il modello è: chiamalo, fai uno switch su Kind, leggi il campo popolato da quel tipo

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;

Qui vive il primo vero tranello, ed è quello che i feedback dei test hanno portato alla luce durante l'implementazione. Esiste un getter più vecchio, GetActionURL, e usarlo per leggere una URI azione è l'errore più ovvio. GetActionURL risolve una specifica di file attraverso la chiave /F. È la cosa giusta per GoToR e Launch, i cui target sono davvero file, ma è la chiave sbagliata per una URI azione in assoluto. L'indirizzo di una URI azione è una semplice stringa nella chiave propria /URI dell'azione, non una specifica di file. Passare una URI azione al percorso delle specifiche di file produce un risultato vuoto o privo di senso. Il getter tipizzato gestisce questa distinzione internamente leggendo /URI direttamente per akURI e invocando il risolutore della specifica di file solo per akGoToR e akLaunch, che è esattamente la distinzione che una versione scritta a mano tende a confondere

Tipi di fit delle destinazioni e la geometria che li sostiene

Un akGoTo significa «navigare all'interno di questo documento», ma non ti dice nulla su dove o come. Questo è il compito della destinazione, e le destinazioni hanno più sfumature di quanto ci si aspetti. Una destinazione PDF non è solo un numero di pagina; è una pagina più una specifica di «fit» che dice come il visualizzatore deve inquadrare quella pagina (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo restituisce il tutto come record:

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;

Gli otto tipi di fit rispondono a domande di inquadratura diverse. dkXYZ posiziona un punto specifico nell'angolo in alto a sinistra con uno zoom esplicito, quindi usa Left, Top e Zoom. dkFit adatta l'intera pagina alla finestra e ignora le coordinate. dkFitH e dkFitV adattano la larghezza o l'altezza della pagina con una sola coordinata rilevante (un bordo superiore o un bordo sinistro). dkFitR è quella interessante: adatta un rettangolo specificato, quindi contano tutti e quattro i bordi. La dkFitB* famiglia fa le stesse cose rispetto al riquadro di delimitazione del contenuto visibile invece che dell'intera pagina. Sapere quali campi sono attivi per ciascun tipo fa la differenza tra leggere correttamente una destinazione e stampare coordinate prive di senso che per caso valgono zero

PDF reader bookmark navigation panel showing a nested outline tree
Ciascun segnalibro in questo pannello di navigazione viene risolto in un'azione e, per i salti interni, in una destinazione con il proprio tipo di fit e le proprie coordinate.

Sotto il cofano, l'implementazione si appoggia a un allineamento deliberato che vale la pena conoscere perché spiega perché la mappatura è affidabile. L'interno GetDestType restituisce un intero da 1 a 8 per gli otto tipi di fit, esattamente nell'ordine XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind è dichiarato in modo che i suoi ordinali coincidano uno a uno: dkXYZ è ordinal 1, dkFitBV è ordinal 8, con dkNone a zero. Quindi la conversione è una conversione ordinale diretta con un controllo di range, non una tabella di lookup che può sfasarsi man mano che l'enum cresce. È un dettaglio piccolo, ma è il tipo di cosa che, fatta nel modo ingenuo, diventa un bug di off-by-one la prima volta che qualcuno riordina un'enumerazione

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;

Un Page di zero è il segnale che la destinazione non si è risolta, di solito perché l'azione non contiene alcuna destinazione oppure la destinazione con nome non è stata trovata. Controllalo prima di fidarti di qualunque coordinata. Nota anche che GetOutlineDestinationInfo cerca in entrambi i posti in cui una destinazione può trovarsi: direttamente nel bookmark's /Dest, e dentro una GoTo azione incorporata /D. Non devi sapere quale forma abbia usato il produttore

Azioni delle annotazioni e tranello di SelectPage

Le annotazioni di link portano azioni esattamente come i segnalibri, e GetAnnotActionInfo restituisce lo stesso TPDFlibActionInfo record con lo stesso schema tipo-poi-payload. Ma qui c'è un tranello legato allo stato che non si applica agli outline, ed è il terzo tranello

Le annotazioni appartengono alle pagine, e PDFlibPas espone le annotazioni della pagina corrente attraverso uno stato che diventa valido solo dopo che hai selezionato quella pagina. Chiamare GetAnnotActionInfo senza aver prima chiamato SelectPage(N) e l'handle dell'annotazione è zero; la chiamata restituisce akNone e tu concludi erroneamente che la pagina non abbia annotazioni azionabili. La correzione è una sola riga, ma è facile dimenticarla quando scorri le pagine:

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;

Due cose in quel ciclo sono intenzionali. Primo, SelectPage(P) viene prima di qualunque accesso alle annotazioni in ogni iterazione; lo stato delle annotazioni per pagina non si trascina da una pagina all'altra. Secondo, il test di esistenza usa GetAnnotActionID(1) <> 0 invece di CheckPageAnnots. Quest'ultimo segnala la presenza come flag di tipo booleano invece che come conteggio, quindi un ID di azione non nullo è il modo più preciso per chiedere «c'è una prima annotazione e contiene un'azione che posso leggere?». Un'altra sottigliezza che vale la pena segnalare: per le annotazioni, lo script di una JavaScript azione viene letto da /JS direttamente, decodificando uno stream quando lo script è memorizzato in quel modo e leggendo una stringa altrimenti, così funziona in entrambe le codifiche comuni

Dove si colloca l'introspezione lato lettura

Questi getter sono volutamente ristretti. Sono letture pure costruite sopra gli attuali livelli della libreria per azioni e destinazioni con handle interi, quindi non toccano il percorso di scrittura e non aggiungono rischi ai documenti che stai anche modificando. Riportano ciò che è nel file; non lo validano rispetto a una policy né riscrivono nulla. Se il tuo obiettivo è l'opposto, costruire segnalibri e annotazioni di link che contengano in primo luogo queste azioni, quello sta sul lato di scrittura, e il pezzo complementare su azioni di form interattivo e JavaScript in Delphi spiega come crearle. Per estrarre il contenuto visibile e strutturale da un PDF anziché il suo grafo di navigazione, vedi estrazione di testo, immagini e font con PDFlibPas

Il confine onesto da tenere a mente: l'introspezione vede solo ciò che il produttore ha effettivamente scritto. Un segnalibro la cui azione un generatore ha lasciato malformata, oppure una destinazione che punta a una destinazione con nome mai definita, emergerà come akNone o una pagina zero invece che come eccezione. È il comportamento giusto per una API di lettura che controlla file non fidati, ma significa che il tuo codice dovrebbe trattare quei risultati zero come «assente o non risolto», non come garanzia di input ben formato. L'introspezione tipizzata di azioni e destinazioni mostrata qui fa parte di PDFlibPas, la libreria PDF nativa per Delphi e C++Builder