Teknisk artikel

Form XObject-rekursion: PDFlibPas cykelupptäckt i Delphi

PDFlibPas löser upp rekursiva Form XObject-anrop i Delphi PDF-innehållsströmmar genom att spåra den aktiva anropskedjan, inte en global besökt-mängd, så att TPDFlib.EnumPageContentStatesEx kan gå igenom samma Form anropad flera gånger på en sida utan att förväxla legitim återanvändning med en cykel. En stämpel-Form-XObject i en fakturamall är det typiska fallet: samma objekt anropas från huvudet, sidfoten, och ett vattenmärkeslager på en sida, och bara en anropskedja som slingrar tillbaka till sig själv är en genuin cykel

ISO 32000-1 §8.10 definierar en Form XObject som en självständig innehållsström som en sida, eller en annan Form, anropar med Do-operatorn, komplett med sitt eget koordinatsystem i /Matrix, en klippgräns i det koordinatsystemet i /BBox, och valfritt sin egen resursordbok. Inget i specifikationen begränsar hur många gånger en Form kan anropas eller hur djupt Forms kan anropa varandra, så en konform parser måste acceptera legitim återanvändning och legitim kapsling samtidigt som den försvarar sig mot det enda upplägget specifikationen faktiskt förbjuder: en Form vars innehållsström, direkt eller transitivt, anropar sig själv. PDFlibPas rapporterar den distinktionen genom TPDFlibContentFormTraversalStatus-värdena bifogade till varje Do-ögonblicksbild, mest anmärkningsvärt ftsEnumerated för en framgångsrik nedstigning och ftsCycle för det enda fallet som faktiskt är en loop

Varför utlöser inte återanvändning av samma Form XObject en falsk cykel?

En upprepad Form XObject-referens är inte, i sig, bevis på något fel. ISO 32000-1 tillåter att samma Form-objekt anropas från så många ställen i en innehållsström som författaren vill, vilket är precis hur en logotypstämpel, en brevhuvudmall, eller en sidnummerfotnot återanvänds över en sida utan att duplicera dess innehållsström flera gånger. Den naiva vakten mot skenande rekursion är en enda besökt-mängd nyckelmed av objektnummer: första gången en vandrare ser Form-objekt 12 markerar den 12 som sedd och vägrar gå in i den igen någon annanstans i trädet. Det tillvägagångssättet går sönder i det ögonblick samma stämpel dyker upp i två orelaterade hörn av en sida, eftersom det andra, helt legitima anropet anländer efter att objektnumret redan är markerat sett och avvisas som om det vore en loop

PDFlibPas undviker den falska positiven genom att avgränsa cykelupptäckt till den aktuella anropskedjan istället för hela dokumentet. EnumPageContentStatesEx trycker den upplösta Form-strömmen på den aktiva anropskedjan omedelbart innan den stiger ner i den, och poppar sedan samma post igen i det ögonblick nedstigningen returnerar, oavsett om den lyckas eller ej. Ett syskonanrop av den identiska strömmen börjar bara efter att det första redan poppats, så anropskedjan är fri från den strömmen när syskonanropet kontrollerar den, och vandraren räknar upp den precis som den skulle vilken annan Form som helst. En sann cykel ser annorlunda ut på samma kedja: Form A anropar Form B, B är fortfarande öppen på kedjan när dess eget innehåll anropar tillbaka in i A, och A sitter fortfarande på kedjan från det yttre anropet som ännu inte returnerat — det är den enda formen ftsCycle rapporterar, en Form-ström fortfarande öppen någonstans tidigare på den aktuella anropskedjan, inte bara närvarande någon annanstans på sidan

Hur djupt kan Form XObject-rekursion gå innan PDFlibPas stoppar den?

Cykelupptäckt och djupbegränsning löser två olika problem, och PDFlibPas håller dem som två olika TPDFlibContentFormTraversalStatus-utfall av precis den anledningen. En kedja av tjugo distinkta Forms, var och en anropande nästa och ingen av dem upprepande, är inte en cykel enligt någon definition — den aktiva-kedja-kontrollen hittar aldrig en upprepad ström — men tjugo ärliga kapslingsnivåer är fortfarande tjugo nivåer av tolkning, matrissammanfogning, och resursupplösning som en felformad eller fientlig PDF kunde driva godtyckligt högre om inget annat stoppade den. EnumPageContentStatesEx tar en MaxFormDepth-parameter av precis den anledningen och klämmer vilket värde som än skickas till ett maximum av 64, oavsett vad anroparen begär. Ett djup på noll är ett specialfall värt att känna till för sig: det avaktiverar Form-rekursion helt och återskapar det platta, endast-sida-beteendet hos den äldre EnumPageContentStates-metoden, vilket är varför varje Do-ögonblicksbild i det läget rapporterar ftsNotRequested istället för att försöka något

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 under-spårare per anrop: att isolera grafiktillstånd

Varje nedstigning i en Form XObject får sin egen grafiktillståndsspårare snarare än att dela den som redan går igenom sidan, eftersom en Forms innehållsström krävs lämna grafiktillståndet exakt som den hittade det, och PDFlibPas kan inte anta att varje PDF den öppnar faktiskt respekterar det kravet. Barn-spåraren startar från en ögonblicksbild av vilken CTM, färgtillstånd, och textparametrar som än var aktiva vid det anropande Do-instruktionen, och återställer sedan sin egen spara-och-återställ-stack och aktuell-bana-spårning till tomt innan den exekverar en enda instruktion av Formen. En obalanserad q utan matchande Q inuti en vårdslös eller skadad Form, inget ovanligt att hitta i PDF:er producerade av äldre verktyg, förblir innesluten inuti det anropets spårare och läcker aldrig ut i sidspåraren eller in i ett syskonanrop av samma stämpel sittande en rad senare i innehållsströmmen

Form /Matrix sammansätts med den CTM som gäller vid Do på samma sätt som en cm-operator gör, vänster-multiplicerad mot den aktuella transformen snarare än ersatt för den, och PDFlibPas återanvänder medvetet den enda kodvägen istället för att underhålla en andra formel, eftersom två oberoende implementationer av samma matrisalgebra är precis den typen av dubblering som tyst driver isär efter några omgångar av skalning, rotation, och skjuvningskomposition. /BBox klipper sedan i Formens eget koordinatutrymme efter att matrisen redan tillämpats, och alla fyra hörn av den rutan transformeras individuellt snarare än bara de motsatta hörnen, eftersom en roterad eller skjuvad Form annars kan rapportera en avgränsningsruta som missar riktigt innehåll som sitter i vad som brukade vara ett extremt hörn innan transformen flyttade det någon annanstans. Att utöka loopen från föregående exempel över samma States-array läser de fälten direkt

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

Delar två Forms med samma resursnamn ett typsnitt?

Nej. Ett resursnamn som /F1 betyder bara något relativt till resursordboken aktiv vid den punkt det används, och två olika Form XObjects är fria att definiera två helt olika typsnitt under det identiska namnet. PDFlibPas löser det här genom att spåra ett resursomfång vid sidan av varje resursnamn: när en Form bär sin egen /Resources-ordbok blir den ordboken det fullständiga resursomfånget för allt inuti den, utan per-nyckel-reservlösning till sidans eller anroparens ordbok för vad Formens egen ordbok än råkar utelämna. Bara en Form utan någon /Resources-nyckel alls, ett mönster fortfarande producerat av vissa äldre PDF-generatorer, ärver den anropande ordboken helt, och det är en medveten kompatibilitetsundantag snarare än en generell regel värd att luta sig mot i ny utdata. Typsnittsidentitet i en TPDFlibContentGraphicsState-ögonblicksbild är därför paret av FontResource och FontResourceScope, inte namnet ensamt, med FontObjectNumber tillgängligt för att bekräfta exakt vilket indirekt objekt en given /F1 löste upp till i det specifika omfånget

Samma avgränsning gäller för varje annan namngiven resurs en Form kan bära, ExtGState-poster och kapslade XObject-poster inräknade, eftersom den underliggande upplösningsmekanismen inte specialbehandlar typsnitt — typsnittsfallet råkar bara spela störst roll, eftersom en missmatchad typsnittsidentitet tyst producerar fel glyfer istället för ett uppenbart fel. Extraktionskod som grupperar textkörningar efter typsnittsnamn ensamt, utan att också gruppera efter resursomfång, kommer slå ihop två visuellt olika typsnitt som råkar dela ett namn, och misstaget kommer inte visa sig förrän någon märker siffror från fel typsnitt sittande inuti vad som skulle läsas som ett konsekvent typsnitt

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

Att läsa FormTraversalStatus i din egen pipeline

FormTraversalStatus förvandlar varje Do-ögonblicksbild till en liten diagnostisk rapport i sig, och en pipeline som ignorerar den kastar bort precis den information som skulle förklara en ofullständig extraktion. ftsNotApplicable betyder att instruktionen aldrig var ett upplöst Form-anrop från början; ftsNotRequested betyder att rekursion var avslagen för det här anropet; ftsEnumerated betyder att Formen tolkades och gicks igenom framgångsrikt; ftsDepthLimit och ftsCycle markerar de två sätten en nedstigning avbryts med avsikt; och ftsMalformed täcker allt annat som stoppade genomgången — en olöslig strömreferens, en /Matrix eller /BBox som misslyckades tolkas, eller ett undantag kastat medan Formens eget innehåll exekverades. Det sista fallet spelar roll operativt, eftersom en misslyckad kapslad genomgång rullar tillbaka vilken partiell utdata den än redan producerat för den grenen, så en anropare aldrig behöver gissa om en Form genuint var tom eller helt enkelt exploderade två instruktioner in i sin innehållsström

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;

Gränser, kostnader, och var det här passar in

En Forms innehållsström avkodas och tolkas exakt en gång per uppräkningsanrop oavsett hur många gånger Formen anropas, eftersom PDFlibPas cachar den tolkade instruktionslistan mot det underliggande strömobjektet snarare än att tolka den igen vid varje syskonanrop — treshörnsstämpeln från öppningsexemplet avkodas en gång och gås igenom tre gånger, inte avkodas tre gånger. Vad som faktiskt byggs om vid varje enskilt anrop är allt som legitimt skiljer sig mellan ett anropsställe och nästa: barn-spåraren, den sammanfogade CTM:en, det genomskurna klippet, och resursomfånget. Den per-anrops-CTM- och klippbokföringen är samma maskineri bakom PDFlibPas innehållsströms-CTM- och klipptillståndsspårare, värd att läsa tillsammans med den här för alla innehållsströmsgenomgångar som går bortom själva Form-rekursionen

Två gränser är värda att sätta förväntningar kring innan det här API:et går in i en större pipeline. 64-nivåers djuptak är ingen inställningsratt för legitimt djupa dokument, eftersom riktiga fakturor, kontoutdrag, och rapportmallar i princip aldrig kapslar Forms mer än tre eller fyra nivåer djupt — ett dokument som faktiskt träffar ftsDepthLimit är mycket mer sannolikt felformat eller fientligt än ovanligt utstuderat, och är värt att logga som en datakvalitetssignal snarare än tyst försökt igen med ett större tal. EnumPageContentStatesEx är också ett läs-sida-analys-API: det rapporterar vad en innehållsström gör, inte om en Form ens borde vara synlig, vilket är en separat fråga besvarad av Optional Content Group-synlighetstillstånd när en stämpel- eller vattenmärke-Form sitter bakom ett lager en visare kan ha stängt av. Anropskedje-cykelupptäckt, per-anrops-isolering, och resursomfångning tillsammans utgör ett hörn av innehållsströms-inspektionsytan i PDFlibPas-komponenten för Delphi och C++Builder