Techninis straipsnis

PDFlibPas žymių ir anotacijų veiksmų introspekcija Delphi aplinkoje

Jūs paveldite PDF aplanką iš kažkur aukščiau grandinėje, o užduotis skamba visai trivialiai: pasakykite, kurios žymės šoka į išorinį URL, kurios paleidžia JavaScript ir kur tiksliai nusileidžia vidinės nuorodos. Tada atidarote API nuorodyną ir pastebite, kad biblioteka gali sukurti kiekvieną iš šių veiksmų, bet nieko neduoda jiems perskaityti atgal. Toks asimetriškumas PDF įrankiuose visur. Parašyti žymę, kuri atidaro https://example.com, yra vienos eilutės reikalas; paklausti esamos žymės „ką tu darai ir į kokį tikslą rodai?“ dažniausiai reiškia rankomis pereiti žalią objektų medį per /A, /S, /Dest ir visą fit-type atšakų puokštę, kurią beveik niekas teisingai nesurašo iš pirmo karto

PDFlibPas yra vietinė Object Pascal PDF biblioteka, skirta Delphi ir C++Builder, ir ilgą laiką ji turėjo tą pačią spragą: turtingus rašymo pusės nustatytojus ir getterius, kurie grąžindavo tik pliką TPDFObject ir palikdavo jus landžioti pačiam. Leidimas v3.77.0 dalį šios spragos uždarė nedideliu rinkiniu tipizuotų introspekcijos kvietimų, kurie veiksmų tipą, veiksmų apkrovą ir paskirties geometriją grąžina kaip paprastus įrašus. Šis straipsnis apie tai, kaip tie kvietimai susiejami su ISO 32000-1 veiksmų ir paskirties modeliu, ir apie tris konkrečius spąstus, dėl kurių ranka parašytos šio kodo versijos tyliai nueina klystkeliais

Kodėl veiksmus skaityti sunkiau nei juos rašyti

PDF veiksmas yra žodynas su /S raktu, kuris įvardija jo potipį: GoTo, GoToR, URI, Launch, Named, JavaScript ir ilgesnę uodegą, su kuria susiduriama retai (ISO 32000-1 §12.6.4). Problema ta, kad apkrova kiekvienam potipiui gyvena kitame rakte, ir nėra vienodo lizdo „duok man tikslą“. URI veiksmas savo adresą laiko /URI. GoToR arba Launch veiksmas failo specifikaciją laiko /F. JavaScript veiksmas savo scenarijų laiko /JS, kuris gali būti ir eilutė, ir srautas. O GoTo veiksmas apskritai neturi jokios apkrovos; jo tikslas yra paskirtis, pakabinta ant /D, kurią po to dar reikia atskirai išspręsti

Kai veiksmą rašote, jo rūšį žinote iš anksto, todėl visa tai nesvarbu. Kai veiksmą skaitote, pirmiausia turite išsišakoti pagal /S, tada lįsti į teisingą raktą ir dar susitaikyti su tuo, kad ta pati loginė sąvoka („dalykas, į kurį šis veiksmas rodo“) užkoduota trimis tarpusavyje nesuderinamais būdais. Būtent šį šakojimą tipizuoti getteriai ir sugeria. GetOutlineActionInfo ir GetAnnotActionInfo abu grąžina TPDFlibActionInfo įrašą:

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;

Įrašas pats savo Kind lauku nurodo, kurie kiti laukai turi prasmę. Jei Kind grįžta kaip akURI, skaitote URI ir visą kitą ignoruojate. Jei jis grįžta kaip akGoTo, nė vienas apkrovos laukas negalioja ir jūs pereinate prie paskirties, kuri aptariama atskiru kvietimu žemiau. akNone yra sąžiningas atsakymas, kai žymė arba anotacija išvis neturi jokio veiksmo, o ne kažkoks nulinis skaičius, kurio reikšmę dar reikia spėlioti

Žymių medžio perėjimas norint rasti žymę

Prieš pradedant introspektuoti žymę, jums reikia jos rankenos. PDFlibPas kontūro mazgus identifikuoja sveikuoju ID, o FindOutlineByTitle vieną iš jų suranda pagal matomą tekstą, kartu leisdamas aiškiai valdyti, kaip toli paieška driekiasi:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

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

Argumentas Depth čia yra ta vieta, prie kurios verta stabtelėti. osdSiblingsOnly pereina tik brolių grandinę pradiniame lygyje ir sustoja; jis ras lygiavertę žymę, bet niekada nenusileis į jos vaikų šaką. osdChildrenOnly žiūri vienu lygiu žemyn, į tiesioginius pradžios mazgo vaikus. osdFullSubTree rekursiškai pereina visą šaką. Pasirinkus neteisingą reikšmę gaunate tylų prasilenkimą, o ne klaidą: sibling-only paieška pavadinimui, kuris gyvena dviem lygiais giliau, tiesiog grąžina nulį, ir jūs padarote išvadą, kad žymės nėra, nors ji visą laiką buvo ten. Jei norite ieškoti nuo dokumento šaknies, kaip pradžios ID perduokite GetFirstOutline

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;

Sutapimas čia remiasi tiksliu pavadinimo tekstu, lyginamu kaip WideString, todėl paieška jautri raidžių dydžiui ir tiksliai gerbia Unicode tekstą taip, kaip jis saugomas dokumente. Jei jūsų šaltiniai PDF ateina iš nenuoseklių generatorių, normalizuokite ieškomą pavadinimą taip pat, kaip jį saugo dokumentas, kitaip medžiosite fantominius nepataikymus

Žymės veiksmo ir tikslo išsprendimas

Turint rankeną, GetOutlineActionInfo pateikia tipizuotą vaizdą. Modelis paprastas: iškviečiate, persijungiate pagal Kind ir perskaitote lauką, kurį ta rūšis užpildo

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;

Čia gyvena pirmasis tikras spąstas, ir būtent jį įgyvendinimo metu iškėlė testų grįžtamasis ryšys. Yra senesnis getteris GetActionURL, ir griebtis jo skaitant URI veiksmą atrodo akivaizdžiai teisinga klaida. GetActionURL failo specifikaciją sprendžia per /F raktą. Tai teisinga GoToR ir Launch, nes jų tikslai tikrai yra failai, bet URI veiksmui tai išvis ne tas raktas. URI veiksmo adresas yra paprasta eilutė pačiame veiksmo /URI rakte, o ne failo specifikacija. Paduokite URI veiksmą į file-spec kelią ir gausite tuščią arba absurdišką rezultatą. Tipizuotas getteris tai sutvarko viduje: skaito /URI tiesiogiai, kai kalbama apie akURI, o file-specification resolverį kviečia tik akGoToR ir akLaunch atvejais - būtent šis skirtumas ir yra tas, kurį ranka parašyta versija dažniausiai sulieja

Paskirties fit tipai ir už jų slypinti geometrija

An akGoTo veiksmas reiškia „naviguok šiame dokumente“, bet pats savaime jis nieko nepasako apie kur arba kaip. Tą darbą atlieka paskirtis, o paskirtys turi gerokai daugiau niuansų, nei daug kas tikisi. PDF paskirtis yra ne tik puslapio numeris; tai yra puslapis plius „fit“ specifikacija, aprašanti, kaip peržiūros programa turi įrėminti puslapį (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo ją grąžina kaip įrašą:

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;

Aštuoni fit tipai atsako į skirtingus kadravimo klausimus. dkXYZ konkretų tašką išdėsto viršutiniame kairiajame kampe su aiškiu masteliu, todėl jam reikia Left, Top ir Zoom. dkFit sutalpina visą puslapį lange ir koordinates ignoruoja. dkFitH ir dkFitV sutalpina puslapio plotį arba aukštį, naudodami tik vieną svarbią koordinatę (viršutinį arba kairįjį kraštą). Įdomiausias yra dkFitR: jis sutalpina konkretų stačiakampį, todėl reikšmingi visi keturi kraštai. dkFitB* šeima daro tą patį, tik remiasi matomo turinio ribų dėže, o ne visu puslapiu. Suvokti, kurie laukai kuriam tipui gyvi, yra skirtumas tarp teisingai nuskaitytos paskirties ir tiesiog nulių, kuriuos per klaidą išspausdinote kaip koordinates

PDF reader bookmark navigation panel showing a nested outline tree
Each bookmark in this navigation panel resolves to an action and, for internal jumps, a destination with its own fit type and coordinates.

Po gaubtu įgyvendinimas remiasi sąmoningai sulygiuota detale, kurią verta žinoti, nes ji paaiškina, kodėl žemėlapis patikimas. Vidinis GetDestType aštuoniems fit tipams grąžina sveikąjį 1..8 tiksliai XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV tvarka. TPDFlibDestinationKind deklaruotas taip, kad jo ordinalai sutaptų vienas prie vieno: dkXYZ turi ordinalą 1, dkFitBV - 8, o dkNone sėdi ties nuliu. Taigi konversija yra tiesioginis ordinalo cast su intervalo patikra, o ne lookup lentelė, kuri, enum augant, gali išsiderinti. Tai smulkmena, bet būtent tokia smulkmena, kuri, padaryta naiviai, tampa off-by-one klaida vos tik kas nors perrikiuoja enum

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 reikšmė, lygi nuliui, yra signalas, kad paskirties išspręsti nepavyko, dažniausiai todėl, kad veiksmas neturi jokios paskirties arba vardinė paskirtis nerasta. Patikrinkite tai prieš pasitikėdami bet kokia koordinate. Taip pat svarbu, kad GetOutlineDestinationInfo žiūri į abi vietas, kuriose paskirtis gali gyventi: tiesiai ant žymės /Dest ir įterpto GoTo veiksmo viduje, ant /D. Jums nereikia žinoti, kurią formą panaudojo generatorius

Anotacijų veiksmai ir SelectPage spąstai

Nuorodų anotacijos veiksmus neša lygiai taip pat, kaip ir žymės, o GetAnnotActionInfo grąžina tą patį TPDFlibActionInfo įrašą su tuo pačiu „pirmiausia rūšis, tada apkrova“ modeliu. Tačiau čia yra būseninis kabliukas, kuris kontūrams negalioja, ir tai yra trečias spąstas

Anotacijos priklauso puslapiams, o PDFlibPas dabartinio puslapio anotacijas pateikia per būseną, kuri galioja tik tada, kai tą puslapį pasirinkote. Iškvieskite GetAnnotActionInfo prieš SelectPage(N) ir anotacijos rankena bus nulinė; kvietimas grąžins akNone, o jūs klaidingai nuspręsite, kad puslapis neturi jokių veiksmingų anotacijų. Pataisa čia yra viena eilutė, tačiau ją labai lengva pamiršti, kai sukate ciklą per puslapius:

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;

Dvi detalės šiame cikle yra sąmoningos. Pirma, SelectPage(P) kviečiamas prieš bet kokią prieigą prie anotacijų kiekvienoje iteracijoje; per-puslapio anotacijų būsena nepersikelia. Antra, egzistavimo testas naudoja GetAnnotActionID(1) <> 0, o ne CheckPageAnnots. Pastarasis apie buvimą praneša boolean stiliaus vėliava, o ne skaičiumi, todėl neneulinis action ID yra tikslesnis būdas paklausti „ar yra pirmoji anotacija ir ar ji turi veiksmą, kurį galiu perskaityti?“ Dar viena smulki detalė, kurią verta pažymėti: anotacijoms JavaScript veiksmų scenarijus skaitomas tiesiai iš /JS, dekoduojant srautą, kai scenarijus saugomas tuo būdu, ir skaitant eilutę kitu atveju, todėl abi įprastos kodavimo formos išgyvena

Kur tinka skaitymo pusės introspekcija

Šie getteriai sąmoningai siauri. Tai gryni skaitymo kvietimai, pastatyti ant esamų bibliotekos sveikųjų rankenų veiksmų ir paskirties sluoksnių, todėl jie neliečia jokio rašymo kelio ir nesukuria jokios papildomos rizikos dokumentams, kuriuos tuo pačiu ir redaguojate. Jie praneša, kas yra faile; jie nieko nevaliduoja pagal politiką ir nieko neperrašo. Jei jūsų tikslas atvirkštinis - pačiam kurti žymes ir nuorodų anotacijas, kurios tuos veiksmus neša - tai jau gyvena rašymo pusėje, o gretimas tekstas apie interaktyvių formų veiksmus ir JavaScript Delphi aplinkoje žingsnis po žingsnio rodo, kaip juos sukurti. Jei iš PDF norite ištraukti matomą ir struktūrinį turinį, o ne jo navigacijos grafiką, žiūrėkite teksto, vaizdų ir šriftų ištraukimą su PDFlibPas

Sąžininga riba, kurią verta turėti galvoje: introspekcija mato tik tai, ką generatorius iš tikrųjų parašė. Žymė, kurios veiksmas sugeneruotas netaisyklingai, arba paskirtis, rodanti į vardinį tikslą, kuris taip ir nebuvo apibrėžtas, iškils kaip akNone arba nulinis puslapis, o ne kaip išimtis. Tai yra teisingas elgesys skaitymo API, audituojančiam nepatikimus failus, tačiau tai reiškia, kad jūsų kodas tokius nulinius rezultatus turi laikyti „nėra arba neišspręsta“, o ne gerai suformuotos įvesties garantija. Čia parodyta tipizuota veiksmų ir paskirčių introspekcija yra PDFlibPas, vietinės PDF bibliotekos, skirtos Delphi ir C++Builder, dalis