Odborný článok

Rekurzia Form XObject: detekcia cyklov v PDFlibPas pre Delphi

PDFlibPas rieši rekurzívne volania Form XObject v obsahových streamoch PDF pre Delphi sledovaním aktívneho reťazca volaní, nie globálnej množiny navštívených objektov, takže TPDFlib.EnumPageContentStatesEx dokáže prejsť ten istý Form invokovaný niekoľkokrát na jednej strane bez toho, aby oprávnené opätovné použitie zamenil za cyklus. Typickým prípadom je pečiatka Form XObject vo faktúrovej šablóne: ten istý objekt sa volá z hlavičky, päty aj z vrstvy vodoznaku na jednej strane, a skutočným cyklom je jedine reťazec volaní, ktorý sa vráti sám na seba

ISO 32000-1 §8.10 definuje Form XObject ako samostatný obsahový stream, ktorý strana alebo iný Form invokuje operátorom Do, s vlastným súradnicovým systémom v /Matrix, orezávacou hranicou v tomto súradnicovom systéme v /BBox a voliteľne s vlastným slovníkom zdrojov. Špecifikácia nikde neobmedzuje, koľkokrát možno jeden Form invokovať ani ako hlboko sa Formy môžu navzájom vnárovať, takže konformný parser musí prijať oprávnené opätovné použitie aj oprávnené vnáranie, a zároveň sa brániť pred jediným usporiadaním, ktoré špecifikácia skutočne zakazuje: Form, ktorého obsahový stream priamo alebo tranzitívne invokuje sám seba. PDFlibPas toto rozlíšenie hlási cez hodnoty TPDFlibContentFormTraversalStatus pripojené ku každému zachyteniu Do, najmä ftsEnumerated pre úspešný zostup a ftsCycle pre jediný prípad, ktorý je skutočne slučkou

Prečo opätovné použitie toho istého Form XObjectu nespôsobí falošný cyklus?

Opakovaný odkaz na Form XObject sám osebe nie je dôkazom ničoho zlého. ISO 32000-1 dovoľuje, aby bol ten istý Form objekt invokovaný z toľkých miest v obsahovom streame, koľko si autor želá, čo je presne spôsob, akým sa pečiatka loga, šablóna hlavičkového papiera alebo päta s číslom strany opätovne použije na strane bez toho, aby sa jej obsahový stream niekoľkokrát duplikoval. Naivná obrana proti nekontrolovanej rekurzii je jediná množina navštívených objektov kľúčovaná číslom objektu: prvýkrát, keď prehľadávač uvidí Form objekt 12, označí 12 ako videný a odmieta doň znovu vstúpiť kdekoľvek inde v strome. Tento prístup zlyhá vo chvíli, keď sa tá istá pečiatka objaví v dvoch nesúvisiacich rohoch jednej strany, pretože druhé, úplne oprávnené volanie príde po tom, čo je číslo objektu už označené ako videné, a bude odmietnuté, akoby išlo o slučku

PDFlibPas sa tomuto falošnému pozitívu vyhýba tak, že detekciu cyklov obmedzuje na aktuálny reťazec volaní, nie na celý dokument. EnumPageContentStatesEx pridá vyriešený Form stream na aktívny reťazec volaní bezprostredne pred vnorením doňho a hneď po návrate zo zostupu, úspešného či nie, tú istú položku znova z reťazca odstráni. Súrodenecké volanie identického streamu začne až po tom, čo bolo to prvé už z reťazca odstránené, takže reťazec volaní je v okamihu, keď súrodenecké volanie tento stream kontroluje, od neho čistý, a prehľadávač ho vypočíta úplne rovnako, ako ktorýkoľvek iný Form. Skutočný cyklus vyzerá na tom istom reťazci inak: Form A volá Form B, B je na reťazci stále otvorený, keď jeho vlastný obsah volá späť do A, a A stále sedí na reťazci z vonkajšieho volania, ktoré sa ešte nevrátilo – to je jediný tvar, ktorý ftsCycle hlási: Form stream stále otvorený niekde skôr na aktuálnom reťazci volaní, nielen prítomný niekde inde na strane

Ako hlboko môže rekurzia Form XObject zájsť, kým ju PDFlibPas zastaví?

Detekcia cyklov a obmedzenie hĺbky riešia dva odlišné problémy, a PDFlibPas ich preto zachováva ako dva odlišné výsledky TPDFlibContentFormTraversalStatus. Reťazec dvadsiatich odlišných Formov, kde každý volá ďalší a žiadny sa neopakuje, nie je cyklus podľa žiadnej definície – kontrola aktívneho reťazca nikdy nenájde opakovaný stream – no dvadsať čestných úrovní vnárania je stále dvadsať úrovní spracovania, zreťazenia matíc a riešenia zdrojov, ktoré by chybne zostavené alebo zámerne škodlivé PDF mohlo neobmedzene ďalej zvyšovať, ak by ho nič iné nezastavilo. EnumPageContentStatesEx práve preto prijíma parameter MaxFormDepth a akúkoľvek odovzdanú hodnotu obmedzí na maximum 64, bez ohľadu na to, o čo volajúci žiada. Hĺbka nula je vlastný osobitný prípad, ktorý stojí za zapamätanie: úplne vypína rekurziu do Formov a reprodukuje ploché správanie iba na úrovni strany zo staršej metódy EnumPageContentStates, a preto v tomto režime každé zachytenie Do hlási ftsNotRequested namiesto akéhokoľvek pokusu

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;

Jeden pomocný sledovač na invokáciu: izolácia stavu grafiky

Každý zostup do Form XObjectu dostáva vlastný sledovač stavu grafiky namiesto zdieľania toho, ktorý už prechádza stranou, pretože obsahový stream Formu musí podľa požiadaviek ponechať stav grafiky presne taký, aký ho našiel, a PDFlibPas nemôže predpokladať, že každé PDF, ktoré otvorí, túto požiadavku skutočne dodrží. Detský sledovač začína zo zachyteného stavu toho, akú CTM, farebný stav a textové parametre malo volajúce miesto Do, a potom resetuje svoj vlastný zásobník ukladania a obnovenia stavu, ako aj sledovanie aktuálnej cesty, na prázdny, ešte pred vykonaním čo i len jednej inštrukcie Formu. Nevyvážené q bez zodpovedajúceho Q vnútri nedbalého alebo poškodeného Formu, čo v PDF vyprodukovaných staršími nástrojmi nie je zriedkavé, zostane obmedzené na sledovač tejto jedinej invokácie a nikdy neunikne do sledovača strany ani do súrodeneckej invokácie tej istej pečiatky sediacej o riadok ďalej v obsahovom streame

Form /Matrix sa skladá s CTM platnou pri Do rovnako, ako to robí operátor cm, násobením zľava voči aktuálnej transformácii namiesto jej nahradenia, a PDFlibPas zámerne opätovne používa tú istú cestu kódu namiesto udržiavania druhého vzorca, keďže dve nezávislé implementácie tej istej maticovej algebry sú presne ten druh duplicity, ktorá sa po niekoľkých kolách škálovania, rotácie a skosenia potichu rozíde. /BBox potom orezáva vo vlastnom súradnicovom priestore Formu už po aplikovaní matice, a všetky štyri rohy tejto plochy sa transformujú jednotlivo, nielen protiľahlé rohy, keďže otočený alebo skosený Form by inak mohol hlásiť ohraničujúci obdĺžnik, ktorý minie skutočný obsah sediaci v tom, čo bol pred transformáciou extrémny roh, kým ho transformácia nepresunula inam. Rozšírenie cyklu z predchádzajúceho príkladu nad tým istým poľom States tieto polia číta priamo

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);

Zdieľajú dva Formy s rovnakým názvom zdroja jedno písmo?

Nie. Názov zdroja ako /F1 má význam len vzhľadom na slovník zdrojov aktívny v mieste svojho použitia, a dva odlišné Form XObjecty majú úplnú voľnosť definovať pod tým istým názvom dve úplne odlišné písma. PDFlibPas to rieši sledovaním rozsahu zdrojov popri každom názve zdroja: keď Form nesie vlastný slovník /Resources, tento slovník sa stáva úplným rozsahom zdrojov pre všetko vnútri neho, bez akéhokoľvek náhradného hľadania kľúča v slovníku strany či volajúceho pre čokoľvek, čo vlastný slovník Formu vynechá. Iba Form bez akéhokoľvek kľúča /Resources, vzor, ktorý stále produkujú niektoré staršie generátory PDF, dedí volajúci slovník celý, a to je zámerná výnimka pre kompatibilitu, nie všeobecné pravidlo, o ktoré by sa mal opierať nový výstup. Identita písma v zachytenom stave TPDFlibContentGraphicsState je preto dvojica FontResource a FontResourceScope, nie samotný názov, pričom FontObjectNumber je k dispozícii na potvrdenie, ktorý presne nepriamy objekt sa dané /F1 v danom konkrétnom rozsahu vyriešilo

Rovnaké rozsahovanie platí pre každý ďalší pomenovaný zdroj, ktorý Form môže niesť, vrátane položiek ExtGState a vnorených položiek XObject, keďže samotný mechanizmus riešenia neošetruje písma ako osobitný prípad – prípad písma len najviac záleží, pretože nesprávna zhoda identity písma potichu vyprodukuje nesprávne glyfy namiesto zjavného zlyhania. Extrakčný kód, ktorý zoskupuje úseky textu len podľa názvu písma bez zoskupovania podľa rozsahu zdrojov, zlúči dve vizuálne odlišné písma, ktoré náhodou zdieľajú názov, a chyba sa neprejaví, kým si niekto nevšimne číslice z nesprávneho typu písma vnútri toho, čo malo pôsobiť ako jedno konzistentné písmo

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);

Čítanie FormTraversalStatus vo vlastnom pipeline

FormTraversalStatus mení každé zachytenie Do na malú samostatnú diagnostickú správu, a pipeline, ktorý ju ignoruje, zahadzuje presne tú informáciu, ktorá by vysvetlila neúplnú extrakciu. ftsNotApplicable znamená, že inštrukcia nikdy nebola vyriešenou invokáciou Formu; ftsNotRequested znamená, že rekurzia bola pre toto volanie vypnutá; ftsEnumerated znamená, že Form bol úspešne spracovaný a prejdený; ftsDepthLimit a ftsCycle označujú dva spôsoby, ako sa zostup zámerne skráti; a ftsMalformed pokrýva všetko ostatné, čo prehľadávanie zastavilo – neriešiteľný odkaz na stream, /Matrix alebo /BBox, ktoré sa nepodarilo spracovať, alebo výnimku vyvolanú počas vykonávania vlastného obsahu Formu. Posledný prípad má praktický význam, pretože neúspešný vnorený prechod vráti späť akýkoľvek čiastočný výstup, ktorý pre danú vetvu už stihol vyprodukovať, takže volajúci nikdy nemusí hádať, či bol Form skutočne prázdny, alebo jednoducho zlyhal na druhej či tretej inštrukcii svojho obsahového streamu

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;

Hranice, náklady a kam toto všetko zapadá

Obsahový stream Formu sa dekóduje a spracuje presne raz na jedno volanie enumerácie bez ohľadu na to, koľkokrát je Form invokovaný, pretože PDFlibPas ukladá spracovaný zoznam inštrukcií do vyrovnávacej pamäte podľa podkladového objektu streamu namiesto jeho opätovného spracovania pri každom súrodeneckom volaní – pečiatka s tromi rohmi z úvodného príkladu sa dekóduje raz a prejde trikrát, nie dekóduje trikrát. Čo sa naopak znovu zostavuje pri každej jednej invokácii, je všetko, čo sa medzi jednotlivými miestami volania oprávnene líši: detský sledovač, zreťazená CTM, pretnuté orezanie a rozsah zdrojov. Táto účtovná réžia CTM a orezania na invokáciu je ten istý mechanizmus, aký stojí za sledovačom stavu CTM a orezania obsahového streamu v PDFlibPas, ktorý sa oplatí prečítať popri tomto článku pri akomkoľvek prechode obsahovým streamom presahujúcom samotnú rekurziu do Formov

Predtým, než sa toto API zapojí do väčšieho pipeline, stojí za to nastaviť si očakávania okolo dvoch limitov. Strop hĺbky 64 úrovní nie je ladiaci parameter pre legitímne hlboké dokumenty, keďže skutočné faktúry, výpisy a šablóny reportov v podstate nikdy nevnárajú Formy hlbšie než tri či štyri úrovne – dokument, ktorý skutočne narazí na ftsDepthLimit, je oveľa pravdepodobnejšie chybne zostavený alebo zámerne škodlivý než nezvyčajne prepracovaný, a oplatí sa ho zalogovať ako signál o kvalite dát, nie ticho opakovať s väčším číslom. EnumPageContentStatesEx je tiež analytické API len na čítanie: hlási, čo obsahový stream robí, nie či by Form mal byť vôbec viditeľný, čo je samostatná otázka, na ktorú odpovedá stav viditeľnosti voliteľných skupín obsahu, keď pečiatka alebo vodoznakový Form sedí za vrstvou, ktorú mohol prehliadač vypnúť. Detekcia cyklov podľa reťazca volaní, izolácia na úrovni invokácie a rozsahovanie zdrojov spolu tvoria jeden roh plochy inšpekcie obsahového streamu v komponente PDFlibPas pre Delphi a C++Builder