Tehnički članak

Čitanje PDF akcija knjižnih oznaka i anotacija u Delphiju

Naslijedili ste mapu PDF-ova od nekud uzvodno, a zadatak zvuči trivijalno: recite mi koje knjižne oznake skaču na vanjski URL, koje pokreću JavaScript i gdje unutarnje zapravo završavaju. Onda otvorite API referencu i otkrijete da biblioteka može stvoriti svaku od tih akcija, ali ne nudi ništa za njihovo čitanje natrag. Ta je asimetrija posvuda u PDF alatima. Pisanje knjižne oznake koja otvara https://example.com je jedan redak; pitati postojeću knjižnu oznaku "što radiš i prema kojem odredištu?" obično znači ručno prolaziti sirovo stablo objekata kroz /A, /S, /Dest i čitav niz varijanti fit tipa koje gotovo nitko ne pogodi iz prve

PDFlibPas je nativna Object Pascal PDF biblioteka za Delphi i C++Builder, i dugo je imala isti jaz: bogati setter-i za pisanje, getter-i koji su vam vraćali goli TPDFObject i ostavljali vas da kopate. Izdanje v3.77.0 zatvorilo je dio toga malim skupom tipiziranih introspekcijskih poziva koji vrstu akcije, korisni teret akcije i geometriju odredišta vraćaju kao obične zapise. Ovaj članak govori o tome kako se ti pozivi preslikavaju na ISO 32000-1 model akcija i odredišta te o tri konkretne zamke koje ručno pisane verzije ovog koda vode u tihe pogreške

Zašto je čitanje akcija teže od njihova pisanja

Akcija u PDF-u je rječnik s ključem /S koji imenuje njegov podtip: GoTo, GoToR, URI, Launch, Named, JavaScript, i dulji rep koji rijetko susrećete (ISO 32000-1 §12.6.4). Kvaka je u tome što korisni teret živi u različitom ključu za svaki podtip, a ne postoji jedinstveni utor "daj mi cilj". Akcija URI akcija drži svoju adresu u /URI. Akcija GoToR ili Launch drži specifikaciju datoteke u /F. Akcija JavaScript drži svoj skript u /JS, koji može biti string ili stream. Akcija GoTo ne nosi nikakav vlastiti korisni teret; njezin cilj je odredište, obješeno o /D, koje zatim morate riješiti zasebno

Kad pišete akciju, njezin tip znate unaprijed, pa ništa od toga nije važno. Kad je čitate, prvo morate granati po /S , zatim posegnuti u pravi ključ, pa se nositi s činjenicom da je isti logički pojam ("stvar na koju ova akcija pokazuje") kodiran na tri nekompatibilna načina. Upravo to grananje apsorbiraju tipizirani getteri. GetOutlineActionInfoGetOutlineActionInfo i GetAnnotActionInfoGetAnnotActionInfoTPDFlibActionInfoTPDFlibActionInfo

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;

Zapis vam govori koja su polja smislena putem KindKindKind. Ako akURI vrati URI, čitajte akGoTo, ništa od polja za korisni teret ne vrijedi i prelazite na odredište, koje je zaseban poziv opisan niže. akNone akNone je pošten odgovor kada knjižna oznaka ili anotacija uopće nemaju akciju, a ne neka nula kojoj morate pogađati značenje

Prolazak kroz stablo sadržaja kako biste našli knjižnu oznaku

Prije nego što možete introspektirati knjižnu oznaku trebate njezin handle. PDFlibPas identificira outline čvorove integer ID-jem, a FindOutlineByTitle pronalazi jedan prema vidljivom tekstu uz eksplicitnu kontrolu koliko daleko pretraga ide:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Argument Depth vrijedi zaustaviti se nad njim. osdSiblingsOnly pretražuje lanac siblinga na razini početnog čvora i staje; pronaći će peer bookmark, ali nikada neće sići u djecu peera. osdChildrenOnly gleda jednu razinu dolje, u neposrednu djecu start čvora. osdFullSubTree rekurzivno prolazi cijelu granu. Odabir krivog je tiho promašivanje, ne greška: pretraga samo po siblingima za naslov koji živi dvije razine dublje jednostavno vraća nulu, i zaključite da knjižna oznaka ne postoji iako je cijelo vrijeme ondje. Proslijedite GetFirstOutline kao start ID za pretragu od korijena dokumenta

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;

Podudaranje je na točnom naslovnom nizu, uspoređenom kao WideString, pa je osjetljivo na velika i mala slova te poštuje Unicode tekst točno onako kako je spremljen. Ako vaši izvorni PDF-ovi dolaze od nedosljednih proizvođača, normalizirajte naslov koji tražite isto kao što ga je dokument spremio, ili ćete loviti duh-promašaje

Rješavanje akcije i odredišta knjižne oznake

s handleom u ruci, GetOutlineActionInfo vam daje tipizirani pogled. Obrazac je: pozovite ga, prebacite se na Kind, pročitajte polje koje taj tip popunjava

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;

Tu leži prva stvarna zamka, i to ona koju je testni feedback otkrio tijekom implementacije. Postoji stariji getter, GetActionURL, i posezanje za njim da biste pročitali URI akciju očita je pogreška. GetActionURL rješava specifikaciju datoteke kroz ključ /F. To je prava stvar za GoToR i Launch, čiji su ciljevi doista datoteke, ali to je pogrešan ključ za URI akciju uopće. Adresa URI akcije je običan niz na vlastitom ključu /URI, ne file spec. Pošaljete li URI akciju kroz put datotečne specifikacije, dobit ćete prazan ili besmislen rezultat. Tipizirani getter to interno rješava čitajući /URI izravno za akURI i pozivajući resolver datotečne specifikacije samo za akGoToR i akLaunch, što je upravo razlika koju ručno pisana verzija najlakše zamuti

Tipovi fit odredišta i geometrija iza njih

Akcija akGoTo znači "navigiraj unutar ovog dokumenta", ali vam ne govori ništa o gdje ili kako. To je posao odredišta, a odredišta nose više nijansi nego što ljudi očekuju. PDF odredište nije samo broj stranice; to je stranica plus specifikacija "fit" koja govori kako bi preglednik trebao uokviriti tu stranicu (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo ga vraća kao zapis:

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;

Osam fit vrsta odgovara na različita pitanja uokvirivanja. dkXYZ pozicionira određenu točku u gornji lijevi kut pri eksplicitnom zumu, pa koristi Left, Top i Zoom. dkFit uklapa cijelu stranicu u prozor i ignorira koordinate. dkFitH i dkFitV uklapaju širinu ili visinu stranice s jednom relevantnom koordinatom (gornji rub ili lijevi rub). dkFitR je zanimljiv: uklapa zadani pravokutnik, pa su sva četiri ruba važna. Obitelj dkFitB* radi iste stvari u odnosu na ograničavajući okvir vidljivog sadržaja umjesto na cijelu stranicu. Znati koja su polja aktivna za koju vrstu razlika je između ispravnog čitanja odredišta i ispisivanja besmislenih koordinata koje se slučajno pokažu kao nula

PDF reader bookmark navigation panel showing a nested outline tree
Svaka knjižna oznaka u ovom navigacijskom panelu vodi do akcije i, za unutarnje skokove, do odredišta s vlastitim fit tipom i koordinatama.

Ispod haube implementacija se oslanja na namjerno poravnanje koje vrijedi znati jer objašnjava zašto je preslikavanje pouzdano. Interni GetDestType vraća integer 1..8 za osam fit vrsta točno redom XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV. TPDFlibDestinationKind je deklariran tako da se njegovi ordinali poravnaju jedan-na-jedan: dkXYZ je ordinal 1, dkFitBV je ordinal 8, a dkNone sjedi na nuli. Dakle, pretvorba je izravan ordinal cast s range guardom, a ne lookup table koja može iskliznuti iz sinka kako enum raste. To je mali detalj, ali upravo je to vrsta stvari koja, napravljena na naivan način, postane off-by-one bug prvi put kad netko presloži enumeraciju

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;

A Page od nule signal je da se odredište nije razriješilo, obično zato što akcija nema odredište ili imenovano odredište nije pronađeno. Provjerite to prije nego što vjerujete bilo kojoj koordinati. Primijetite i da GetOutlineDestinationInfo gleda na oba mjesta gdje odredište može živjeti: izravno na bookmarkovom /Dest, i unutar ugrađene GoTo akcije /D. Ne morate znati koji je oblik proizvođač upotrijebio

Akcije anotacija i zamka SelectPage

Link anotacije nose akcije baš kao i knjižne oznake, a GetAnnotActionInfo vraća isti TPDFlibActionInfo zapis s istim uzorkom kind-then-payload. Ali ovdje postoji stanje koje ne vrijedi za outline, i to je treća zamka

Anotacije pripadaju stranicama, a PDFlibPas izlaže trenutačne anotacije stranice kroz stanje koje postaje valjano tek nakon što odaberete tu stranicu. Pozovete li GetAnnotActionInfo bez prethodnog poziva SelectPage(N) i handle anotacije je nula; poziv vraća akNone i pogrešno zaključite da stranica nema akcijskih anotacija. Popravak je jedna linija, ali lako ju je zaboraviti kada petljate po stranicama:

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;

Dvije stvari u toj petlji su namjerne. Prvo, SelectPage(P) dolazi prije bilo kakvog pristupa anotacijama u svakoj iteraciji; stanje anotacija po stranici ne prenosi se. Drugo, test postojanja koristi GetAnnotActionID(1) <> 0 umjesto CheckPageAnnots. Potonje prijavljuje prisutnost kao boolean-style zastavicu umjesto brojanja, pa je nenulta action ID precizniji način da pitate "postoji li prva anotacija i nosi li akciju koju mogu pročitati?" Još jedna nijansa vrijedna spomena: za anotacije se skripta akcije čita izravno iz JavaScript, dekodirajući stream kada je skripta tako pohranjena i čitajući string inače, pa preživljava oba uobičajena kodiranja./JSGdje se read-side introspekcija uklapa

Ovi getteri su namjerno uski. Oni su čitanja izgrađena na postojećim integer-handle slojevima akcija i odredišta u biblioteci, pa ne diraju write path i ne dodaju rizik dokumentima koje također uređujete. Oni prijavljuju ono što je u datoteci; ne validiraju to prema politici niti išta prepisuju. Ako vam je cilj obrnuto, graditi knjižne oznake i link anotacije koje nose te akcije od početka, to je na strani pisanja, a prateći članak o

These getters are intentionally narrow. They are pure reads built on top of the library's existing integer-handle action and destination layers, so they touch no write path and add no risk to documents you are also editing. They report what is in the file; they do not validate it against a policy or rewrite anything. If your goal is the inverse, building bookmarks and link annotations that carry these actions in the first place, that lives on the write side, and the companion piece on interaktivnim formama akcija i JavaScriptu u Delphiju walks through creating them. For pulling the visible and structural content out of a PDF rather than its navigation graph, see extracting text, images, and fonts with PDFlibPas

The honest boundary to keep in mind: introspection only sees what the producer actually wrote. A bookmark whose action a generator left malformed, or a destination pointing at a named target that was never defined, will surface as akNone or a zero page rather than an exception. That is the right behavior for a read API auditing untrusted files, but it means your code should treat those zero results as "absent or unresolved," not as a guarantee of well-formed input. The typed action and destination introspection shown here is part of PDFlibPas, the native PDF library for Delphi and C++Builder