Technical Article

Les PDF-bokmerke- og annotasjonshandlinger i Delphi

Du arver en mappe med PDF-er fra et sted lenger opp i kjeden, og oppgaven høres triviel ut: vis meg hvilke bokmerker som hopper til en ekstern URL, hvilke som kjører JavaScript, og hvor de interne faktisk lander. Så åpner du API-referansen og oppdager at biblioteket kan opprette alle disse handlingene, men ikke tilbyr noe for å lese dem tilbake. Denne asymmetrien finnes overalt i PDF-verktøy. Å skrive et bokmerke som åpner https://example.com er én linje; å spørre et eksisterende bokmerke "hva gjør du, og til hvilket mål?" betyr som regel at du må følge råobjekt-treet for hånd gjennom /A, /S, /Dest og en rekke fit-varianter som nesten ingen treffer riktig første gang.

PDFlibPas er et innebygd Object Pascal PDF-bibliotek for Delphi og C++Builder, og lenge hadde det samme gapet: rike skriv-side-settere, getters som ga deg tilbake et rått TPDFObject og lot deg grave selv. V3.77.0-utgaven lukket deler av dette med et lite sett typede introspeksjonskall som rapporterer handlingstype, nyttelast og destinasjonsgeometri som vanlige records. Denne artikkelen handler om hvordan disse kallene mapper til ISO 32000-1-modellen for handlinger og destinasjoner, og om de tre konkrete fellene som gjør håndskrevne versjoner av denne koden stille og rolig feil.

Hvorfor det er vanskeligere å lese handlinger enn å skrive dem

En handling i PDF er en ordbok med en /S-nøkkel som navngir undertypen: GoTo, GoToR, URI, Launch, Named, JavaScript, og en lengre hale du sjelden møter (ISO 32000-1 §12.6.4). Problemet er at nyttelasten ligger i en annen nøkkel for hver undertype, og det finnes ingen ensartet «gi meg målet»-plass. En URI-handling lagrer adressen i /URI. En GoToR- eller Launch-handling lagrer en filspesifikasjon i /F. En JavaScript-handling lagrer skriptet i /JS, som kan være enten en streng eller en strøm. En GoTo-handling bærer ingen egen nyttelast i det hele tatt; målet er en destinasjon, hengende på /D, som du deretter må løse separat.

Når du skriver en handling, vet du typen på forhånd, så alt dette spiller ingen rolle. Når du leser en, må du først forgrene på /S, deretter inn i riktig nøkkel, og så håndtere at samme logiske begrep («tingen denne handlingen peker på») er kodet på tre inkompatible måter. Den grenen er nettopp det de typede getterene absorberer. GetOutlineActionInfo og GetAnnotActionInfo returnerer begge en 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;

Recorden forteller deg hvilke felt som er meningsfulle via Kind. Hvis Kind kommer tilbake som akURI, les URI og ignorer resten. Hvis den kommer tilbake som akGoTo, gjelder ingen av nyttelastfeltene, og du går videre til destinasjonen, som er et separat kall lenger nede. akNone er det ærlige svaret når bokmerket eller annotasjonen ikke har noen handling i det hele tatt, i stedet for en nullverdi du må gjette meningen av.

Hvordan du går gjennom bokmerketreet for å finne et bokmerke

Før du kan inspisere et bokmerke, trenger du ID-en. PDFlibPas identifiserer bokmerke-noder med en heltalls-ID, og FindOutlineByTitle finner en etter den synlige teksten med eksplisitt kontroll over hvor langt søket skal gå:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Depth-argumentet er delen det er verdt å stoppe opp ved. osdSiblingsOnly skanner søskenkjeden på startnodens nivå og stopper; den finner et bokmerke på samme nivå, men går aldri ned i et søskens barn. osdChildrenOnly ser ett nivå ned, inn i de direkte barna til startnoden. osdFullSubTree går rekursivt gjennom hele grenen. Å velge feil er et stille bomtreff, ikke en feil: et søsken-only-søk etter en tittel som ligger to nivåer nede, returnerer bare null, og du konkluderer med at bokmerket ikke finnes selv om det var der hele tiden. Send GetFirstOutline som start-ID for å søke fra dokumentroten.

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;

Treffingen skjer på den eksakte tittelstrengen, sammenlignet som en WideString, så den er case-sensitiv og respekterer Unicode-teksten nøyaktig slik den er lagret. Hvis kilde-PDF-ene dine kommer fra produsenter med ulik kvalitet, normaliser tittelen du søker etter på samme måte som dokumentet lagret den, ellers jager du spøkelsesfeil.

Slik løser du opp et bokmerkes handling og mål

Med en ID i hånden gir GetOutlineActionInfo deg den typede visningen. Mønsteret er: kall den, switch på Kind, les feltet den typen fyller ut.

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;

Det er her den første virkelige fellen ligger, og den er den testtilbakemeldingen avdekket under implementeringen. Det finnes en eldre getter, GetActionURL, og det å gripe til den for å lese en URI-handling er den åpenbare feilen. GetActionURL løser en filspesifikasjon gjennom /F-nøkkelen. Det er riktig for GoToR og Launch, der målene faktisk er filer, men det er helt feil nøkkel for en URI-handling. En URI-handlings adresse er en ren streng på handlingens egen /URI-nøkkel, ikke en filspesifikasjon. Sender du en URI-handling inn i filspesifikasjonsstien, får du et tomt eller meningsløst resultat. Den typede getteren håndterer dette internt ved å lese /URI direkte for akURI og bare bruke filspesifikasjonsløseren for akGoToR og akLaunch, som er nettopp skillet en håndskrevet versjon lett visker ut.

Destinasjonens fit-typer og geometrien bak dem

En akGoTo-handling betyr «naviger innenfor dette dokumentet», men den forteller deg ingenting om hvor eller hvordan. Det er destinasjonens jobb, og destinasjoner har mer nyanse enn folk forventer. En PDF-destinasjon er ikke bare et sidenummer; den er en side pluss en «fit»-spesifikasjon som sier hvordan visningsprogrammet skal ramme inn siden (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo returnerer den som en 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;

De åtte fit-typene svarer på ulike innrammingsspørsmål. dkXYZ plasserer et bestemt punkt øverst til venstre ved en eksplisitt zoom, så den bruker Left, Top og Zoom. dkFit får hele siden til å passe i vinduet og ignorerer koordinater. dkFitH og dkFitV får sidebredden eller -høyden til å passe med én relevant koordinat (en toppkant eller en venstrekant). dkFitR er den interessante: den får et spesifisert rektangel til å passe, så alle fire kantene teller. dkFitB*-familien gjør det samme relativt til grenseboksen for synlig innhold i stedet for hele siden. Å vite hvilke felt som faktisk er aktive for hver type er forskjellen mellom å lese en destinasjon riktig og å skrive ut søppelkoordinater som tilfeldigvis er null.

PDF reader bookmark navigation panel showing a nested outline tree
Hvert bokmerke i dette navigasjonspanelet løser seg til en handling og, for interne hopp, en destinasjon med sin egen fit-type og koordinater.

Under panseret bygger implementasjonen på en bevisst avstemming som er verdt å kjenne til, fordi den forklarer hvorfor mappingen er pålitelig. Den interne GetDestType returnerer et heltall 1..8 for de åtte fit-typene i akkurat XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV-rekkefølge. TPDFlibDestinationKind er deklarert slik at ordinalene ligger én til én: dkXYZ har ordinal 1, dkFitBV har ordinal 8, med dkNone stående på null. Så konverteringen er en direkte ordinal-cast med en rangesjekk, ikke en oppslagstabell som kan drive ut av synk når enumen vokser. Det er en liten detalj, men den typen ting som, gjort på den naive måten, blir en off-by-one-feil første gang noen omorganiserer en enumerasjon.

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;

En Page lik null er signalet om at destinasjonen ikke ble løst opp, vanligvis fordi handlingen ikke har noen destinasjon, eller fordi den navngitte destinasjonen ikke kunne finnes. Sjekk det før du stoler på noen koordinat. Legg også merke til at GetOutlineDestinationInfo ser på begge stedene en destinasjon kan bo: direkte på bokmerkets /Dest, og inne i en innebygd GoTo-handlings /D. Du trenger ikke vite hvilken form produsenten brukte.

Annotasjonshandlinger og SelectPage-fellen

Lenkeannotasjoner bærer handlinger akkurat slik bokmerker gjør, og GetAnnotActionInfo returnerer den samme TPDFlibActionInfo-recorden med samme type-først-og-nyttelast-mønsteret. Men her finnes det en tilstandsbundet hake som ikke gjelder for bokmerkehierarkier, og det er den tredje fellen.

Annotasjoner tilhører sider, og PDFlibPas eksponerer den aktuelle sidens annotasjoner gjennom tilstand som først blir gyldig etter at du har valgt den siden. Kaller du GetAnnotActionInfo uten først å kalle SelectPage(N), er annotasjonshåndtaket null; kallet returnerer akNone, og du trekker feilaktig slutningen at siden ikke har noen annotasjoner med handlinger. Fiksen er én linje, men det er lett å glemme når du løper gjennom sider:

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;

To ting i den løkken er bevisste. Først kommer SelectPage(P) før all annotasjons-tilgang i hver iterasjon; annotasjonstilstanden per side følger ikke med videre. For det andre bruker eksistenssjekken GetAnnotActionID(1) <> 0 heller enn CheckPageAnnots. Den sistnevnte rapporterer tilstedeværelse som en boolsk flaggverdi snarere enn et antall, så en ikke-null handlings-ID er den mer presise måten å spørre «finnes det en første annotasjon, og har den en handling jeg kan lese?» på. Én subtilitet til det er verdt å flagge: for annotasjoner leses skriptet til en JavaScript-handling direkte fra /JS, med dekoding av en strøm når skriptet er lagret slik og lesing av en streng ellers, så den overlever begge de vanlige kodningene.

Hvor den lesende introspeksjonen passer inn

Disse getterne er med vilje smale. De er rene lesninger bygget oppå bibliotekets eksisterende heltalls-håndtaklag for handlinger og destinasjoner, så de berører ingen skrivesti og tilfører ingen risiko til dokumenter du også redigerer. De rapporterer det som står i filen; de validerer det ikke mot en policy og skriver ingenting om. Hvis målet ditt er det motsatte, å bygge bokmerker og lenkeannotasjoner som bærer disse handlingene i utgangspunktet, hører det til på skrivesiden, og den tilhørende artikkelen om interaktive skjemahandlinger og JavaScript i Delphi går gjennom hvordan du oppretter dem. For å hente ut det synlige og strukturelle innholdet fra en PDF heller enn navigasjonsgrafen, se uttrekking av tekst, bilder og fonter med PDFlibPas.

Den ærlige grensen å ha i bakhodet: introspeksjon ser bare det produsenten faktisk skrev. Et bokmerke hvis handling en generator lot være skadet, eller en destinasjon som peker på et navngitt mål som aldri ble definert, vil dukke opp som akNone eller en side lik null snarere enn som et unntak. Det er riktig oppførsel for en lesende API som auditerer upålitelige filer, men det betyr at koden din bør behandle disse nullresultatene som «fraværende eller uløst», ikke som en garanti for velformet input. Den typede handling- og destinasjonsintrospeksjonen som vises her er en del av PDFlibPas, det innebygde PDF-biblioteket for Delphi og C++Builder.