Teknisk artikel

Læs PDF-bogmærke- og annotationsactions i Delphi

Du arver en mappe med PDF'er et sted oppefra, og opgaven lyder triviel: fortæl mig, hvilke bogmærker der hopper til en ekstern URL, hvilke der kører JavaScript, og hvor de interne faktisk lander. Så åbner du API-referencen og opdager, at biblioteket kan oprette hver eneste af de actions, men ikke tilbyder noget til at læse dem tilbage. Den asymmetri findes overalt i PDF-værktøjer. At skrive et bogmærke, der åbner https://example.com, er en one-liner; at spørge et eksisterende bogmærke "hvad gør du, og til hvad peger du?" betyder som regel, at du må vandre det rå objekttræ manuelt gennem /A, /S, /Dest og en vifte af fit-type-varianter, som næsten ingen får rigtigt første gang

PDF Library for Delphi er et native Object Pascal PDF-bibliotek til Delphi og C++Builder, og i lang tid havde det det samme hul: rige skrivesides-setters, getters der gav dig et nøgent TPDFObject tilbage og overlod resten til dig selv at udforske. v3.77.0-udgivelsen lukkede en del af det med et lille sæt typede introspektionskald, der rapporterer action-typen, action-nyttelasten og destinationsgeometrien som almindelige records. Denne artikel handler om, hvordan de kald mapper til ISO 32000-1's action- og destinationsmodel, og de tre konkrete faldgruber, der får håndrullede versioner af denne kode til at gå stille galt

Hvorfor det er sværere at læse actions end at skrive dem

En action i PDF er et dictionary med en /S-nøgle, der navngiver dens subtype: GoTo, GoToR, URI, Launch, Named, JavaScript, og en længere hale, du sjældent møder (ISO 32000-1 §12.6.4). Problemet er, at nyttelasten bor i en anden nøgle for hver subtype, og der er ingen ensartet "giv mig målet"-plads. En URI-action holder sin adresse i /URI. En GoToR- eller Launch-action holder en filspecifikation i /F. En JavaScript-action holder sit script i /JS, som kan være enten en streng eller en stream. En GoTo-action bærer slet ingen nyttelast selv; dens mål er en destination, der hænger på /D, som du derefter skal opløse separat

Når du skriver en action, kender du dens type på forhånd, så intet af det her betyder noget. Når du læser en, skal du først forgrene på /S, så nå ind til den rigtige nøgle, og så håndtere det faktum, at det samme logiske begreb ("det, denne action peger på") er kodet på tre indbyrdes uforenelige måder. Den forgrening er præcis det, de typede gettere absorberer. GetOutlineActionInfo og GetAnnotActionInfo returnerer begge en TPDFlibActionInfo-record:

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

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

Recorden fortæller dig, hvilke felter der er relevante, via Kind. Kommer Kind tilbage som akURI, læs URI og ignorér resten. Kommer den tilbage som akGoTo, gælder ingen af nyttelastfelterne, og du går videre til destinationen, som er et separat kald, dækket længere nede. akNone er det ærlige svar, når bogmærket eller annotationen slet ingen action har, i stedet for en nul-værdi, du skal gætte betydningen af

Gå dispositionstræet igennem for at finde et bogmærke

Før du kan introspicere et bogmærke, har du brug for dets handle. PDF Library for Delphi identificerer dispositionsknuder med et heltals-ID, og FindOutlineByTitle finder én ud fra dens synlige tekst med eksplicit kontrol over, hvor langt søgningen rækker:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Argumentet Depth er den del, det er værd at stoppe op ved. osdSiblingsOnly scanner søskendekæden på startknudens niveau og stopper; den finder et jævnbyrdigt bogmærke, men går aldrig ned i et jævnbyrdigt bogmærkes børn. osdChildrenOnly kigger ét niveau ned, ind i startknudens umiddelbare børn. osdFullSubTree rekurserer gennem hele grenen. At vælge den forkerte er et stille miss, ikke en fejl: en søskende-kun-søgning efter en titel, der ligger to niveauer nede, returnerer simpelthen nul, og du konkluderer, at bogmærket ikke findes, selvom det hele tiden var der. Angiv GetFirstOutline som startID for at søge fra dokumentroden

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Søg hele træet fra roden efter et indlejret bogmærke.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID er nu et handle, du kan give videre til action- og
        // destinations-getterne nedenfor.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

Matchning sker på den eksakte titelstreng, sammenlignet som en WideString, så det er versalfølsomt og respekterer Unicode-teksten præcis, som den er gemt. Kommer dine kilde-PDF'er fra inkonsistente producenter, skal du normalisere den titel, du søger efter, på samme måde som dokumentet gemte den, ellers jager du fantom-misses

Opløs et bogmærkes action og mål

Med et handle i hånden giver GetOutlineActionInfo dig den typede visning. Mønstret er: kald den, forgren på Kind, læs det felt, den type udfylder

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');  // se destination nedenfor
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

Det er her, den første reelle faldgrube bor, og det er den, testfeedback afslørede under implementeringen. Der findes en ældre getter, GetActionURL, og at gribe fat i den for at læse en URI-action er den fejl, der ser mest oplagt ud. GetActionURL opløser en filspecifikation gennem /F-nøglen. Det er det rigtige for GoToR og Launch, hvis mål reelt er filer, men det er den forkerte nøgle for en URI-action fuldstændigt. En URI-actions adresse er en almindelig streng på actionens egen /URI-nøgle, ikke en filspec. Fodrer du en URI-action til filspec-stien, får du et tomt eller meningsløst resultat. Den typede getter håndterer dette internt ved at læse /URI direkte for akURI og kun kalde filspecifikations-opløseren for akGoToR og akLaunch, hvilket er præcis den skelnen, en håndskrevet version har en tendens til at sløre

Destinationens fit-typer og geometrien bag dem

En akGoTo-action betyder "naviger inden for dette dokument", men den fortæller dig intet om hvor eller hvordan. Det er destinationens opgave, og destinationer bærer mere nuance, end folk forventer. En PDF-destination er ikke bare et sidenummer; det er en side plus en "fit"-specifikation, der siger, hvordan vieweren skal indramme den side (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-baseret; 0 når uopløst
    Left, Top, Right, Bottom, Zoom: Double;
  end;

De otte fit-typer besvarer forskellige indramningsspørgsmål. dkXYZ placerer et bestemt punkt i det øverste venstre hjørne ved en eksplicit zoom, så den bruger Left, Top og Zoom. dkFit tilpasser hele siden til vinduet og ignorerer koordinater. dkFitH og dkFitV tilpasser sidens bredde eller højde med én relevant koordinat (en øverste kant eller en venstre kant). dkFitR er den interessante: den tilpasser et angivet rektangel, så alle fire kanter betyder noget. dkFitB*-familien gør de samme ting relativt til afgrænsningsboksen for synligt indhold i stedet for hele siden. At vide, hvilke felter der er levende for hver type, er forskellen mellem at læse en destination korrekt og udskrive volapyk-koordinater, der tilfældigvis er nul

PDF-readerbogmærkenavigationspanel, der viser et indlejret dispositionstræ
Hvert bogmærke i dette navigationspanel opløses til en action og, for interne hop, en destination med sin egen fit-type og koordinater

Under motorhjelmen læner implementeringen sig op ad en bevidst justering, det er værd at kende, fordi den forklarer, hvorfor mappingen er pålidelig. Den interne GetDestType returnerer et heltal 1..8 for de otte fit-typer i præcis rækkefølgen XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind er erklæret, så dens ordinaler flugter én til én: dkXYZ er ordinal 1, dkFitBV er ordinal 8, med dkNone siddende på nul. Så konverteringen er et direkte ordinal-cast med en intervalvagt, ikke en opslagstabel, der kan glide ude af synkronisering, efterhånden som enumen vokser. Det er en lille detalje, men det er den slags, der, gjort på den naive måde, bliver en off-by-one-fejl, første gang nogen omordner en enumeration

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destinationen blev ikke opløst
  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 på nul er signalet om, at destinationen ikke blev opløst, som regel fordi actionen ingen destination bærer, eller den navngivne destination ikke kunne findes. Tjek det, før du stoler på nogen koordinat. Bemærk også, at GetOutlineDestinationInfo kigger begge steder, en destination kan bo: direkte på bogmærkets /Dest, og inde i en indlejret GoTo-actions /D. Du behøver ikke vide, hvilken form producenten brugte

Annotationsactions og SelectPage-fælden

Link-annotationer bærer actions på præcis samme måde som bogmærker, og GetAnnotActionInfo returnerer den samme TPDFlibActionInfo-record med det samme type-så-nyttelast-mønster. Men der er en tilstandsbaseret fælde her, der ikke gælder for dispositionselementer, og det er den tredje faldgrube

Annotationer hører til sider, og PDF Library for Delphi eksponerer den aktuelle sides annotationer gennem tilstand, der først bliver gyldig, efter du har valgt den side. Kald GetAnnotActionInfo uden først at kalde SelectPage(N), og annotationshandlet er nul; kaldet returnerer akNone, og du konkluderer fejlagtigt, at siden ingen handlingsbare annotationer har. Rettelsen er én linje, men den er let at glemme, når du løkker over sider:

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // obligatorisk, før du rører annotationer
    // GetAnnotActionID(1) <> 0 er den pålidelige "har en action"-
    // test. CheckPageAnnots returnerer et boolsk flag, ikke en
    // count, så det er det svagere signal her.
    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økke er bevidste. For det første kommer SelectPage(P) før enhver annotationsadgang på hver iteration; den pr.-side-annotationstilstand bæres ikke over. For det andet bruger eksistenstesten GetAnnotActionID(1) <> 0 frem for CheckPageAnnots. Sidstnævnte rapporterer tilstedeværelse som et boolsk flag frem for en count, så et action-ID forskelligt fra nul er den mere præcise måde at spørge "findes der en første annotation, og bærer den en action, jeg kan læse?" Endnu en finesse værd at nævne: for annotationer læses en JavaScript-actions script direkte fra /JS, hvor en stream afkodes, når scriptet er gemt på den måde, og en streng læses ellers, så det overlever begge almindelige kodninger

Hvor læseside-introspektion passer ind

Disse gettere er bevidst snævre. De er rene læsninger bygget oven på bibliotekets eksisterende integer-handle action- og destinationslag, så de rører ingen skrivesti og tilføjer ingen risiko for dokumenter, du også redigerer. De rapporterer, hvad der er i filen; de validerer det ikke mod en politik eller omskriver noget. Er dit mål det omvendte, at bygge bogmærker og link-annotationer, der bærer disse actions i første omgang, hører det til på skrivesiden, og sidestykket om interaktive formular-actions og JavaScript i Delphi gennemgår, hvordan man opretter dem. For at trække det synlige og strukturelle indhold ud af en PDF frem for dens navigationsgraf, se udtrækning af tekst, billeder og fonte med PDF Library for Delphi

Den ærlige grænse, du skal huske: introspektion ser kun det, producenten faktisk skrev. Et bogmærke, hvis action en generator efterlod misdannet, eller en destination, der peger på et navngivet mål, der aldrig blev defineret, vil dukke op som akNone eller en nul-side i stedet for en undtagelse. Det er den rigtige adfærd for en læse-API, der auditerer utroværdige filer, men det betyder, at din kode bør behandle de nul-resultater som "fraværende eller uopløst", ikke som en garanti for velformet input. Den typede action- og destinationsintrospektion, der er vist her, er en del af PDF Library for Delphi, det native PDF-bibliotek til Delphi og C++Builder