Tehnični članak

Rekurzija Form XObject: zaznavanje ciklov PDFlibPas v Delphiju

PDFlibPas razrešuje rekurzivne klice Form XObject v tokovih vsebine PDF za Delphi s sledenjem aktivni klicni verigi, ne globalnemu naboru že obiskanih objektov, zato lahko TPDFlib.EnumPageContentStatesEx prehodi isti Form, ki je na eni strani priklican večkrat, ne da bi upravičeno ponovno uporabo zamenjal za cikel. Tipičen primer je žig Form XObject v predlogi računa: isti objekt je na eni strani priklican iz glave, noge in plasti vodnega žiga, resničen cikel pa je samo klicna veriga, ki se vrne sama vase

ISO 32000-1 §8.10 opredeljuje Form XObject kot samostojen tok vsebine, ki ga stran ali drug Form prikliče z operatorjem Do, skupaj z lastnim koordinatnim sistemom v /Matrix, mejo izrezovanja v tem koordinatnem sistemu v /BBox in po potrebi lastnim slovarjem virov. Specifikacija ne omejuje, kolikokrat je mogoče priklicati isti Form ali kako globoko lahko Formi prikličejo drug drugega, zato mora skladen razčlenjevalnik sprejeti upravičeno ponovno uporabo in upravičeno gnezdenje, hkrati pa se zaščititi pred edino razporeditvijo, ki jo specifikacija prepoveduje: Formom, katerega tok vsebine neposredno ali prehodno prikliče samega sebe. PDFlibPas to razliko sporoča prek vrednosti TPDFlibContentFormTraversalStatus, pripetih vsakemu posnetku Do, predvsem ftsEnumerated za uspešen spust in ftsCycle za edini primer, ki je dejansko zanka

Zakaj ponovna uporaba istega Form XObject ne sproži lažnega cikla?

Ponavljajoča se referenca na Form XObject sama po sebi ni dokaz težave. ISO 32000-1 dovoljuje, da se isti objekt Form prikliče iz toliko mest v toku vsebine, kolikor jih želi avtor, kar omogoča ponovno uporabo logotipa, predloge dopisnega papirja ali noge s številko strani na eni strani brez večkratnega podvajanja njegovega toka vsebine. Naivna zaščita pred nenadzorovano rekurzijo je en sam nabor že obiskanih objektov, določen s številko objekta: ko sprehajalnik prvič vidi objekt Form 12, ga označi kot viden in zavrne ponovni vstop vanj kjer koli drugje v drevesu. Tak pristop odpove takoj, ko se isti žig pojavi v dveh nepovezanih kotih ene strani, saj drugi, povsem upravičen klic pride za tem, ko je številka objekta že označena kot videna, zato je zavrnjen, kot da bi šlo za zanko

PDFlibPas se temu lažnemu pozitivnemu rezultatu izogne tako, da zaznavanje ciklov omeji na trenutno klicno verigo namesto na celoten dokument. EnumPageContentStatesEx potisne razrešeni tok Form v aktivno klicno verigo tik pred spustom vanj, nato pa isti vnos odstrani takoj, ko se spust vrne, uspešno ali ne. Sosednji klic enakega toka se začne šele, ko je bil prvi že odstranjen, zato je klicna veriga ob preverjanju sosednjega klica brez tega toka in ga sprehajalnik prešteje natanko tako kot kateri koli drug Form. Pravi cikel je na isti verigi videti drugače: Form A prikliče Form B, B je še vedno odprt v verigi, ko njegova vsebina ponovno prikliče A, A pa je še vedno v verigi zaradi zunanjega klica, ki se še ni vrnil — to je edina oblika, ki jo sporoča ftsCycle, tok Form, ki je še vedno odprt nekje prej v trenutni klicni verigi, ne pa zgolj prisoten drugje na strani

Kako globoko lahko gre rekurzija Form XObject, preden jo PDFlibPas ustavi?

Zaznavanje ciklov in omejevanje globine rešujeta dve različni težavi, zato ju PDFlibPas ohranja kot dva različna izida TPDFlibContentFormTraversalStatus. Veriga dvajsetih različnih Formov, kjer vsak prikliče naslednjega in se noben ne ponovi, po nobeni definiciji ni cikel — preverjanje aktivne verige nikoli ne najde ponovljenega toka — vendar je dvajset poštenih ravni gnezdenja še vedno dvajset ravni razčlenjevanja, združevanja matrik in razreševanja virov, ki bi jih lahko okvarjen ali zlonameren PDF brez druge omejitve potisnil poljubno visoko. EnumPageContentStatesEx ima zato parameter MaxFormDepth in vsako posredovano vrednost omeji na največ 64, ne glede na to, kaj zahteva klicatelj. Globina nič je poseben primer, ki ga je vredno poznati: v celoti onemogoči rekurzijo Form in ponovi ravno, samo stran obravnavajoče vedenje starejše metode EnumPageContentStates, zato vsak posnetek Do v tem načinu sporoči ftsNotRequested, namesto da bi kar koli poskušal

var
  Lib: TPDFlib;
  States: array of TPDFlibContentGraphicsState;
  Count, I: Integer;
begin
  Lib:= TPDFlib.Create;
  try
    if Lib.LoadFromFile('invoice-batch.pdf', '')<> 1 then
      Exit;
    Lib.SelectPage(1);
    Count:= Lib.EnumPageContentStatesEx(True, 8, States);  // count only
    SetLength(States, Count);
    Lib.EnumPageContentStatesEx(True, 8, States);          // fill
    for I:= 0 to Count- 1 do
      if States[I].FormTraversalStatus= ftsCycle then
        LogSuspectForm(States[I].XObjectResource, States[I].ContentDepth);
  finally
    Lib.Free;
  end;
end;

En sledilnik na priklic: izolacija grafičnega stanja

Vsak spust v Form XObject dobi svoj sledilnik grafičnega stanja, namesto da bi delil sledilnik, ki že prehaja stran, saj mora tok vsebine Forma zapustiti grafično stanje natančno tako, kot ga je našel, PDFlibPas pa ne more domnevati, da vsak PDF, ki ga odpre, to zahtevo dejansko upošteva. Podrejeni sledilnik se začne s posnetkom trenutnega CTM, barvnega stanja in besedilnih parametrov, ki so bili aktivni pri klicnem ukazu Do, nato pa pred izvajanjem enega ukaza Forma ponastavi lastni sklad shranjevanja in obnavljanja ter sledenje trenutni poti na prazno. Neuravnotežen q brez ujemajočega Q znotraj malomarno izdelanega ali poškodovanega Forma, kar ni redko v PDF-jih, ustvarjenih s starejšimi orodji, ostane omejen na sledilnik tega priklica in nikoli ne uide v sledilnik strani ali v sosednji priklic istega žiga, ki je eno vrstico pozneje v toku vsebine

Form /Matrix se sestavi s CTM, veljavnim pri Do, enako kot operator cm, in se z leve množi s trenutno transformacijo, namesto da bi jo nadomestil, PDFlibPas pa namerno ponovno uporabi to isto kodno pot, saj sta dve neodvisni izvedbi iste matrične algebre natanko vrsta podvajanja, ki se po nekaj krogih sestavljanja skaliranja, vrtenja in striga neopazno razideta. /BBox nato izrezuje v lastnem koordinatnem prostoru Forma, potem ko je matrika že uporabljena, vsi štirje vogali tega polja pa se transformirajo posamično namesto zgolj nasprotnih vogalov, saj lahko zasukani ali postrani premaknjeni Form sicer sporoči omejevalno polje, ki zgreši dejansko vsebino v vogalu, ki je bil pred transformacijo skrajni. Če prejšnji primer razširite čez isto polje States, lahko ta polja preberete neposredno

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Do')and (States[I].XObjectKind= cxkForm)and
    States[I].FormBBoxKnown then
    Writeln('Form ', States[I].XObjectResource, ' matrix ',
      States[I].FormMatrix.M11:0:3, ',', States[I].FormMatrix.M12:0:3,
      ' bbox ', States[I].FormBBoxLeft:0:1, '..', States[I].FormBBoxRight:0:1);

Ali si dva Forma z istim imenom vira delita eno pisavo?

Ne. Ime vira, kot je /F1, pomeni nekaj le glede na slovar virov, aktiven na mestu njegove uporabe, dva različna Form XObject pa lahko pod tem enakim imenom prosto določita dve povsem različni pisavi. PDFlibPas to razreši tako, da poleg vsakega imena vira spremlja obseg vira: ko ima Form lasten slovar /Resources, ta slovar postane celoten obseg virov za vse znotraj njega, brez nadomestnega iskanja po posameznih ključih v slovarju strani ali klicatelja za tisto, kar lastni slovar Forma izpusti. Le Form brez ključa /Resources, vzorec, ki ga še vedno ustvarjajo nekateri starejši generatorji PDF, v celoti podeduje klicni slovar, kar je namerna združljivostna izjema in ne splošno pravilo, na katerega bi se bilo vredno opirati pri novem izhodu. Identiteta pisave v posnetku TPDFlibContentGraphicsState je zato par FontResource in FontResourceScope, ne samo ime, pri čemer je FontObjectNumber na voljo za potrditev, kateri posredni objekt je določen /F1 razrešil v tem konkretnem obsegu

Enako omejevanje obsega velja za vsak drug poimenovan vir, ki ga lahko vsebuje Form, vključno z vnosi ExtGState in gnezdenimi vnosi XObject, saj osnovni mehanizem razreševanja pisav ne obravnava posebej — primer pisave je le pomembnejši, ker neusklajena identiteta pisave tiho ustvari napačne glife namesto očitne napake. Koda za izločanje, ki skupine besedilnih izvajanj združuje samo po imenu pisave in ne tudi po obsegu vira, bo združila dve vizualno različni pisavi, ki si po naključju delita ime, napaka pa se bo pokazala šele, ko bo nekdo opazil številke v napačni tipografiji znotraj besedila, ki naj bi bilo videti kot ena enotna pisava

for I:= 0 to Count- 1 do
  if (States[I].OperatorName= 'Tj')and States[I].TextAdvanceResolved then
    RecordGlyphRun(States[I].FontResource, States[I].FontResourceScope,
      States[I].FontObjectNumber, States[I].ContentDepth);

Branje FormTraversalStatus v lastnem cevovodu

FormTraversalStatus vsak posnetek Do spremeni v majhno diagnostično poročilo, cevovod, ki ga prezre, pa zavrže prav tiste informacije, ki bi pojasnile nepopolno izločanje. ftsNotApplicable pomeni, da ukaz sploh ni bil razrešen priklic Forma; ftsNotRequested pomeni, da je bila rekurzija za ta klic izklopljena; ftsEnumerated pomeni, da je bil Form uspešno razčlenjen in prehojen; ftsDepthLimit in ftsCycle označujeta dva načina, na katera se spust namerno prekine; ftsMalformed pa zajema vse drugo, kar je ustavilo prehod — nerazrešljivo referenco toka, neuspešno razčlenjeno /Matrix ali /BBox oziroma izjemo, sproženo med izvajanjem lastne vsebine Forma. Zadnji primer je operativno pomemben, saj neuspešen gnezdeni prehod povrne delni izhod, ki ga je že ustvaril za to vejo, zato klicatelju ni treba ugibati, ali je bil Form res prazen ali pa se je sesul dve navodili po začetku svoje vsebine

var
  Tally: array[TPDFlibContentFormTraversalStatus] of Integer;
  Status: TPDFlibContentFormTraversalStatus;
begin
  for Status:= Low(Tally) to High(Tally) do
    Tally[Status]:= 0;
  for I:= 0 to Count- 1 do
    Inc(Tally[States[I].FormTraversalStatus]);
  if (Tally[ftsCycle]> 0)or (Tally[ftsMalformed]> 0) then
    FlagForManualReview(SourceFileName, Tally[ftsCycle], Tally[ftsMalformed]);
end;

Meje, stroški in umestitev te funkcionalnosti

Tok vsebine Forma se pri vsakem klicu štetja dekodira in razčleni natanko enkrat, ne glede na to, kolikokrat je Form priklican, saj PDFlibPas predpomni razčlenjeni seznam ukazov glede na osnovni objekt toka, namesto da bi ga pri vsakem sosednjem klicu znova razčlenil — žig s tremi vogali iz uvodnega primera se dekodira enkrat in prehodi trikrat, ne dekodira trikrat. Pri vsakem priklicu pa se znova zgradi vse, kar se upravičeno razlikuje med enim mestom klica in drugim: podrejeni sledilnik, sestavljeni CTM, presek izreza in obseg virov. To knjigovodstvo CTM in izreza na ravni priklica uporablja isti mehanizem kot PDFlibPasov sledilnik CTM in stanja izrezovanja toka vsebine, ki ga je vredno prebrati skupaj s tem člankom pri vsakem prehodu toka vsebine, ki sega dlje od same rekurzije Form

Pred vključitvijo tega API-ja v večji cevovod je vredno določiti pričakovanja glede dveh omejitev. Meja globine 64 ni nastavitev za upravičeno globoke dokumente, saj se pravi računi, izkazi in predloge poročil skoraj nikoli ne gnezdijo globlje od treh ali štirih ravni — dokument, ki dejansko doseže ftsDepthLimit, je veliko verjetneje okvarjen ali zlonameren kot nenavadno zapleten in ga je vredno zabeležiti kot signal kakovosti podatkov, namesto da bi ga tiho znova poskusili z večjo številko. EnumPageContentStatesEx je tudi analizni API za branje: poroča, kaj počne tok vsebine, ne pa, ali bi moral biti Form sploh viden, saj je to ločeno vprašanje, na katerega odgovori stanje vidnosti skupine izbirne vsebine, ko je žig ali vodni žig Form za plastjo, ki jo je gledalec morda izklopil. Zaznavanje ciklov po klicni verigi, izolacija posameznih priklicev in omejevanje obsega virov skupaj tvorijo en del površine za pregled toka vsebine v komponenti PDFlibPas za Delphi in C++Builder