Technický článek

Rekurze Form XObject: detekce cyklů v PDFlibPas pro Delphi

PDFlibPas rozřešuje rekurzivní volání Form XObject v proudech obsahu PDF v Delphi sledováním aktivního řetězu volání, ne globální množiny navštívených, takže TPDFlib.EnumPageContentStatesEx dokáže projít stejný Form vyvolaný na jedné stránce několikrát, aniž by legitimní opakované použití zaměnil za cyklus. Form XObject razítka na faktuře je typický případ: stejný objekt se volá ze záhlaví, patičky a vrstvy vodoznaku na jedné stránce, a jen řetěz volání, který se smyčkou vrátí sám do sebe, je skutečný cyklus

ISO 32000-1 §8.10 definuje Form XObject jako samostatný proud obsahu, který stránka, nebo jiný Form, vyvolá operátorem Do, kompletní s vlastním souřadnicovým systémem v /Matrix, ořezovou hranicí v tomto souřadnicovém systému v /BBox, a volitelně vlastním slovníkem zdrojů. Nic ve specifikaci nestanovuje strop, kolikrát lze jeden Form vyvolat nebo jak hluboko se Formy mohou vzájemně vyvolávat, takže konformní parser musí přijmout legitimní opakované použití a legitimní vnořování a přitom se pořád bránit proti jediné konfiguraci, kterou specifikace skutečně zakazuje: Form, jehož proud obsahu, přímo nebo tranzitivně, vyvolává sám sebe. PDFlibPas toto rozlišení hlásí přes hodnoty TPDFlibContentFormTraversalStatus připojené ke každému snímku Do, nejvýrazněji ftsEnumerated pro úspěšný sestup a ftsCycle pro ten jediný případ, který je skutečně smyčka

Proč opakované použití stejného Form XObject nevyvolá falešný cyklus?

Opakovaný odkaz na Form XObject sám o sobě není důkazem ničeho špatného. ISO 32000-1 dovoluje, aby byl stejný objekt Form vyvolán z tolika míst proudu obsahu, kolik autor chce, což je přesně způsob, jak se razítko loga, šablona hlavičkového papíru, nebo patička s číslem stránky opakovaně použije napříč stránkou, aniž by se jejich proud obsahu duplikoval několikrát. Naivní obrana proti utíkající rekurzi je jediná navštívená množina klíčovaná číslem objektu: poprvé, kdy procházení uvidí objekt Form 12, označí 12 jako viděné a odmítne do něj znovu vstoupit kdekoli jinde ve stromu. Tento přístup se rozbije v okamžiku, kdy se stejné razítko objeví ve dvou nesouvisejících koutech jedné stránky, protože druhé, zcela legitimní volání dorazí poté, co je číslo objektu už označeno jako viděné, a je odmítnuto, jako by šlo o smyčku

PDFlibPas se tomuto falešnému poplachu vyhýbá tím, že omezuje detekci cyklu na aktuální řetěz volání místo na celý dokument. EnumPageContentStatesEx vloží rozřešený proud Formu na aktivní řetěz volání ihned před sestupem do něj, pak tentýž záznam znovu vyjme, jakmile se sestup vrátí, úspěšně nebo ne. Sourozenecké vyvolání identického proudu začne až poté, co byl první už vyjmutý, takže je řetěz volání v době, kdy sourozenecké volání kontroluje, od tohoto proudu čistý, a procházení jej vyčíslí přesně tak, jako by to udělalo s jakýmkoli jiným Formem. Skutečný cyklus vypadá na stejném řetězu jinak: Form A volá Form B, B je pořád otevřený na řetězu, když jeho vlastní obsah volá zpátky do A, a A pořád sedí na řetězu z vnějšího volání, které se ještě nevrátilo — to je jediný tvar, který ftsCycle hlásí, proud Formu pořád otevřený někde dřív na aktuálním řetězu volání, ne jen přítomný někde jinde na stránce

Jak hluboko může rekurze Form XObject jít, než ji PDFlibPas zastaví?

Detekce cyklu a omezení hloubky řeší dva odlišné problémy, a PDFlibPas je udržuje jako dva odlišné výsledky TPDFlibContentFormTraversalStatus přesně z tohoto důvodu. Řetěz dvaceti odlišných Formů, každý volající další a žádný se neopakující, není cyklus podle žádné definice — kontrola aktivního řetězu nikdy nenajde opakovaný proud — ale dvacet poctivých úrovní vnoření je pořád dvacet úrovní parsování, skládání matic a rozřešování zdrojů, které by zdeformované nebo nepřátelské PDF mohlo posunout libovolně výš, kdyby to nic jiného nezastavilo. EnumPageContentStatesEx bere parametr MaxFormDepth přesně z tohoto důvodu a přiškrtí jakoukoli předanou hodnotu na maximum 64, bez ohledu na to, o co volající žádá. Hloubka nula je zvláštní případ, který stojí za samostatnou znalost: úplně vypne rekurzi Formů a reprodukuje ploché, jen-stránkové chování staršího EnumPageContentStates, což je důvod, proč každý snímek Do v tomto režimu hlásí ftsNotRequested místo pokusu o cokoli

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 dílčí tracker na vyvolání: izolace stavu grafiky

Každý sestup do Form XObject dostane vlastní tracker stavu grafiky místo sdílení toho, který už prochází stránku, protože se od proudu obsahu Formu vyžaduje, aby stav grafiky ponechal přesně tak, jak jej našel, a PDFlibPas nemůže předpokládat, že každé PDF, které otevře, tento požadavek skutečně dodržuje. Dceřiný tracker začíná ze snímku toho, jaké CTM, stav barvy a textové parametry byly aktivní na volající instrukci Do, pak resetuje vlastní zásobník ukládání-a-obnovování a sledování aktuální cesty na prázdné dřív, než vykoná jedinou instrukci Formu. Nevyvážené q bez odpovídajícího Q uvnitř nedbalého nebo poškozeného Formu, ne vzácná věc k nalezení v PDF vyprodukovaných staršími nástroji, zůstane obsažené uvnitř trackeru tohoto jednoho vyvolání a nikdy neunikne do trackeru stránky ani do sourozeneckého vyvolání stejného razítka sedícího o řádek dál v proudu obsahu

Form /Matrix se skládá s CTM platným na Do stejným způsobem jako operátor cm, vynásoben zleva proti aktuální transformaci místo toho, aby ji nahradil, a PDFlibPas záměrně znovu používá tuto jednu cestu kódu místo udržování druhého vzorce, protože dvě nezávislé implementace stejné maticové algebry jsou přesně ten druh duplicity, který se po pár kolech skládání škálování, rotace a zkosení tiše rozejde. /BBox pak ořízne ve vlastním souřadnicovém prostoru Formu poté, co už byla matice aplikována, a všechny čtyři rohy tohoto boxu se transformují jednotlivě, ne jen protilehlé rohy, protože otočený nebo zkosený Form by jinak mohl hlásit ohraničující box, který mine skutečný obsah sedící v tom, co bývalo extrémním rohem, dřív než jej transformace posunula jinam. Rozšíření smyčky z předchozího příkladu nad stejné pole States čte tato pole přímo

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

Sdílejí dva Formy se stejným jménem zdroje jeden font?

Ne. Jméno zdroje jako /F1 znamená něco jen relativně vůči slovníku zdrojů aktivnímu v místě, kde se používá, a dva různé Form XObjects mají volnost definovat pod tímto identickým jménem dva úplně odlišné fonty. PDFlibPas to řeší sledováním rozsahu zdroje spolu s každým jménem zdroje: když Form nese vlastní slovník /Resources, tento slovník se stane úplným rozsahem zdroje pro vše uvnitř něj, bez záložního mechanismu po klíčích na stránku nebo volající slovník pro cokoli, co vlastní slovník Formu náhodou vynechá. Jen Form bez jakéhokoli klíče /Resources, vzor pořád produkovaný některými staršími generátory PDF, zdědí volající slovník vcelku, a to je záměrná výjimka kompatibility, ne obecné pravidlo, na které by se vyplatilo spoléhat v novém výstupu. Identita fontu ve snímku TPDFlibContentGraphicsState je proto dvojice FontResource a FontResourceScope, ne jen jméno samotné, s FontObjectNumber dostupným pro potvrzení, na který přesně nepřímý objekt se dané /F1 v tomto konkrétním rozsahu rozřešilo

Stejné rozsahové omezení platí pro každý jiný pojmenovaný zdroj, který Form může nést, položky ExtGState a vnořené položky XObject nevyjímaje, protože podkladový mechanismus rozřešení nedělá výjimku pro fonty — případ fontu prostě náhodou záleží nejvíc, protože neshodná identita fontu tiše vyprodukuje špatné glyfy místo zjevného selhání. Kód extrakce, který seskupuje běhy textu jen podle jména fontu, bez seskupení i podle rozsahu zdroje, sloučí dva vizuálně odlišné fonty, které náhodou sdílejí jméno, a chyba se neprojeví, dokud si někdo nevšimne číslic ze špatného písma sedících uvnitř toho, co mělo číst jako jeden konzistentní font

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

Čtení FormTraversalStatus ve vlastní pipeline

FormTraversalStatus promění každý snímek Do na malý diagnostický report sám o sobě, a pipeline, která jej ignoruje, zahazuje přesně tu informaci, která by vysvětlila neúplnou extrakci. ftsNotApplicable znamená, že instrukce nikdy nebyla rozřešeným vyvoláním Formu na prvním místě; ftsNotRequested znamená, že rekurze byla pro toto volání vypnutá; ftsEnumerated znamená, že Form byl úspěšně naparsován a projit; ftsDepthLimit a ftsCycle označují dva způsoby, jak se sestup záměrně zkrátí; a ftsMalformed pokrývá vše ostatní, co procházení zastavilo — nerozřešitelný odkaz na proud, /Matrix nebo /BBox, který se nepodařilo naparsovat, nebo výjimku vyvolanou při vykonávání vlastního obsahu Formu. Tento poslední případ je operačně důležitý, protože selhaný vnořený průchod vrátí zpět jakýkoli částečný výstup, který už pro tuto větev vyprodukoval, takže volající nikdy nemusí hádat, zda byl Form skutečně prázdný, nebo prostě vybuchl dvě instrukce do svého proudu obsahu

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 to zapadá

Proud obsahu Formu se dekóduje a parsuje přesně jednou na volání výčtu bez ohledu na to, kolikrát je Form vyvolán, protože PDFlibPas cachuje naparsovaný seznam instrukcí proti podkladovému objektu proudu místo toho, aby jej znovu parsoval při každém sourozeneckém volání — razítko se třemi rohy z úvodního příkladu se dekóduje jednou a projde třikrát, ne dekóduje třikrát. Co se skutečně přestaví při každém jednotlivém vyvolání, je vše, co se legitimně liší mezi jedním místem volání a dalším: dceřiný tracker, zřetězené CTM, protnutý ořez, a rozsah zdroje. Toto účetnictví CTM a ořezu na vyvolání je stejný mechanismus za trackerem stavu CTM a ořezu proudu obsahu v PDFlibPas, který se vyplatí přečíst spolu s tímto pro jakýkoli průchod proudem obsahu, který jde za samotnou rekurzi Formů

Dva limity stojí za nastavení očekávání dřív, než toto API vstoupí do větší pipeline. Strop hloubky 64 úrovní není ladicí knoflík pro legitimně hluboké dokumenty, protože skutečné faktury, výpisy a šablony reportů v podstatě nikdy nevnořují Formy víc než tři nebo čtyři úrovně hluboko — dokument, který skutečně narazí na ftsDepthLimit, je mnohem pravděpodobněji zdeformovaný nebo nepřátelský než neobvykle propracovaný, a vyplatí se jej zalogovat jako signál kvality dat místo tichého opakování s větším číslem. EnumPageContentStatesEx je také API analýzy jen ke čtení: hlásí, co proud obsahu dělá, ne zda by Form měl být vůbec viditelný, což je samostatná otázka, na kterou odpovídá stav viditelnosti volitelných skupin obsahu, když razítko nebo Form vodoznaku sedí za vrstvou, kterou mohl prohlížeč vypnout. Detekce cyklu podle řetězu volání, izolace na vyvolání a rozsahové omezení zdrojů společně tvoří jeden kout plochy inspekce proudu obsahu v komponentě PDFlibPas pro Delphi a C++Builder