Teknisk artikel

Form-XObject-rekursion: PDFlibPas-cyklus-registrering i Delphi

PDFlibPas løser rekursive Form-XObject-kald i Delphi-PDF-content-streams ved at spore den aktive kaldkæde, ikke et globalt besøgt-sæt, så TPDFlib.EnumPageContentStatesEx kan gennemgå det samme Form kaldt flere gange på én side uden at forveksle legitimt genbrug med en cyklus. Et stempel-Form-XObject i en fakturaskabelon er det typiske tilfælde: det samme objekt kaldes fra header'en, footer'en, og et vandmærke-lag på én side, og kun en kaldkæde, der løber tilbage på sig selv, er en ægte cyklus

ISO 32000-1 §8.10 definerer et Form-XObject som en selvstændig content-stream, en side, eller et andet Form, påkalder med Do-operatoren, komplet med sit eget koordinatsystem i /Matrix, en klipgrænse i det koordinatsystem i /BBox, og valgfrit sin egen ressource-ordbog. Intet i specifikationen sætter et loft for, hvor mange gange ét Form kan påkaldes, eller hvor dybt Forms kan påkalde hinanden, så en konform parser skal acceptere legitimt genbrug og legitim indlejring, mens den stadig forsvarer sig mod den ene arrangement, specifikationen faktisk forbyder: et Form, hvis content-stream, direkte eller transitivt, påkalder sig selv. PDFlibPas rapporterer det skel gennem TPDFlibContentFormTraversalStatus-værdierne knyttet til hvert Do-snapshot, især ftsEnumerated for en vellykket nedstigning og ftsCycle for det ene tilfælde, der rent faktisk er en løkke

Hvorfor udløser genbrug af det samme Form-XObject ikke en falsk cyklus?

En gentaget Form-XObject-reference er ikke i sig selv bevis på, at noget er galt. ISO 32000-1 tillader det samme Form-objekt at blive påkaldt fra så mange steder i en content-stream, som forfatteren vil, hvilket er præcis, hvordan et logo-stempel, en brevhoved-skabelon, eller en side-nummer-footer genbruges på tværs af en side uden at duplikere dens content-stream flere gange. Den naive vagt mod løbsk rekursion er ét enkelt besøgt-sæt nøglet på objektnummer: første gang en gennemgang ser Form-objekt 12, markerer den 12 som set og nægter at gå ind i det igen noget andet sted i træet. Den tilgang går i stykker, i det øjeblik det samme stempel dukker op i to urelaterede hjørner af én side, fordi det andet, helt legitime kald ankommer efter, at objektnummeret allerede er markeret set, og afvises som var det en løkke

PDFlibPas undgår den falske positiv ved at afgrænse cyklus-registrering til den aktuelle kaldkæde frem for hele dokumentet. EnumPageContentStatesEx skubber den løste Form-strøm ind på den aktive kaldkæde umiddelbart før nedstigning ind i den, og popper derefter den samme post af igen, i det øjeblik nedstigningen returnerer, hvad enten med succes eller ej. En søskende-påkaldelse af den identiske strøm starter kun, efter den første allerede er blevet poppet, så kaldkæden er ren for den strøm på det tidspunkt, søskende-kaldet tjekker den, og gennemgangen optæller det præcis, som den ville et hvilket som helst andet Form. En ægte cyklus ser anderledes ud på den samme kæde: Form A kalder Form B, B er stadig åben på kæden, når dets eget indhold kalder tilbage ind i A, og A sidder stadig på kæden fra det ydre kald, der ikke er returneret endnu — det er den eneste form, ftsCycle rapporterer, en Form-strøm stadig åben et sted tidligere på den aktuelle kaldkæde, ikke blot til stede et andet sted på siden

Hvor dybt kan Form-XObject-rekursion gå, før PDFlibPas stopper den?

Cyklus-registrering og dybde-begrænsning løser to forskellige problemer, og PDFlibPas holder dem som to forskellige TPDFlibContentFormTraversalStatus-udfald af netop den grund. En kæde af tyve distinkte Forms, hver kalder den næste, og ingen af dem gentager sig, er ikke en cyklus efter nogen definition — den aktive-kæde-tjek finder aldrig en gentaget strøm — men tyve ærlige niveauer af indlejring er stadig tyve niveauer af parsing, matrix-sammenkædning og ressource-løsning, som en misdannet eller fjendtlig PDF kunne skubbe vilkårligt højere, hvis intet andet stoppede den. EnumPageContentStatesEx tager en MaxFormDepth-parameter af netop den grund og klemmer, hvilken værdi der end sendes, til et maksimum på 64, uanset hvad kalderen beder om. En dybde på nul er et specialtilfælde værd at kende for sig selv: det deaktiverer Form-rekursion helt og genskaber den flade, kun-side-opførsel fra den ældre EnumPageContentStates-metode, hvilket er grunden til, at hvert Do-snapshot i den tilstand rapporterer ftsNotRequested frem for at forsøge noget som helst

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;

Én under-tracker pr. påkaldelse: at isolere grafiktilstand

Hver nedstigning ind i et Form-XObject får sin egen grafiktilstands-tracker frem for at dele den ene, der allerede gennemgår siden, fordi et Forms content-stream kræves at efterlade grafiktilstanden præcis, som den fandt den, og PDFlibPas kan ikke antage, at hver PDF, den åbner, rent faktisk respekterer det krav. Barn-trackeren starter fra et snapshot af hvilken som helst CTM, farvetilstand og tekstparametre, der var aktive ved det kaldende Do-instruktion, nulstiller derefter sin egen gem-og-gendan-stak og aktuel-sti-sporing til tom, før den udfører en enkelt instruktion af Formet. Et ubalanceret q uden noget matchende Q inde i et skødesløst eller beskadiget Form, ikke noget sjældent at finde i PDF'er produceret af ældre værktøjer, forbliver indeholdt inde i den ene påkaldelses tracker og lækker aldrig ind i side-trackeren eller ind i en søskende-påkaldelse af det samme stempel siddende en linje senere i content-strømmen

Form-/Matrix sammensætter med CTM'en i kraft ved Do'en på samme måde en cm-operator gør, venstre-multipliceret mod den aktuelle transformation frem for erstattet for den, og PDFlibPas genbruger bevidst den ene kodevej i stedet for at vedligeholde en anden formel, da to uafhængige implementeringer af den samme matrix-algebra er præcis den slags duplikering, der i stilhed driver fra hinanden efter et par runder af skala-, roter- og skær-sammensætning. /BBox klipper derefter i Formets eget koordinatrum, efter matricen allerede er anvendt, og alle fire hjørner af den boks transformeres individuelt frem for bare de modsatte hjørner, da et roteret eller skævt Form ellers kan rapportere en afgrænsningsboks, der misser rigtigt indhold siddende i, hvad der plejede at være et yderpunkts-hjørne, før transformationen flyttede det et andet sted hen. At udvide løkken fra det forrige eksempel over det samme States-array læser de felter direkte

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

Deler to Forms med samme ressourcenavn én skrifttype?

Nej. Et ressourcenavn såsom /F1 betyder kun noget relativt til den ressource-ordbog, der er aktiv på det punkt, det bruges, og to forskellige Form-XObjects er frie til at definere to helt forskellige skrifttyper under det identiske navn. PDFlibPas løser dette ved at spore et ressource-scope sammen med hvert ressourcenavn: når et Form bærer sin egen /Resources-ordbog, bliver den ordbog det komplette ressource-scope for alt inde i det, uden nogen per-nøgle-fallback til side- eller kalder-ordbogen for, hvad end Formets egen ordbog tilfældigvis udelader. Kun et Form uden nogen /Resources-nøgle overhovedet, et mønster stadig produceret af nogle ældre PDF-generatorer, arver den kaldende ordbog en bloc, og det er en bevidst kompatibilitets-undtagelse frem for en generel regel, det er værd at læne sig på i nyt output. Skrifttype-identitet i et TPDFlibContentGraphicsState-snapshot er derfor parret af FontResource og FontResourceScope, ikke navnet alene, med FontObjectNumber tilgængelig til at bekræfte præcis, hvilket indirekte objekt et givent /F1 løste til i netop det scope

Den samme scoping gælder for hver anden navngiven ressource, et Form kan bære, ExtGState-poster og indlejrede XObject-poster inkluderet, da den underliggende løsnings-mekanisme ikke special-caser skrifttyper — skrifttype-tilfældet betyder bare mest, fordi en fejlmatchet skrifttype-identitet i stilhed producerer de forkerte glyffer i stedet for en åbenlys fejl. Udtrækningskode, der grupperer tekstforløb efter skrifttype-navn alene, uden også at gruppere efter ressource-scope, vil flette to visuelt forskellige skrifttyper, der tilfældigvis deler et navn, og fejlen vil ikke dukke op, før nogen bemærker cifre fra den forkerte skrifttype siddende inde i, hvad der skulle læses som én konsistent skrifttype

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

At læse FormTraversalStatus i ens egen pipeline

FormTraversalStatus gør hvert Do-snapshot til en lille diagnostisk rapport på egen hånd, og en pipeline, der ignorerer den, smider netop den information væk, der ville forklare en ufuldstændig udtrækning. ftsNotApplicable betyder, at instruktionen aldrig var en løst Form-påkaldelse i første omgang; ftsNotRequested betyder, at rekursion var slået fra for dette kald; ftsEnumerated betyder, at Formet blev parset og gennemgået med succes; ftsDepthLimit og ftsCycle markerer de to måder en nedstigning afskæres med vilje; og ftsMalformed dækker alt andet, der stoppede gennemgangen — en uløselig strøm-reference, en /Matrix eller /BBox der fejlede at parse, eller en undtagelse rejst under udførelse af Formets eget indhold. Det sidste tilfælde betyder noget operationelt, fordi en mislykket indlejret gennemgang ruller tilbage, hvilket partielt output den allerede havde produceret for den gren, så en kalder aldrig behøver gætte, om et Form genuint var tomt, eller simpelthen sprang to instruktioner ind i sin content-stream

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, omkostninger, og hvor dette passer ind

Et Forms content-stream afkodes og parses præcis én gang pr. optællings-kald, uanset hvor mange gange Formet påkaldes, fordi PDFlibPas cacher den parsede instruktionsliste mod det underliggende strøm-objekt frem for at genparse den ved hvert søskende-kald — tre-hjørne-stemplet fra åbnings-eksemplet afkodes én gang og gennemgås tre gange, ikke afkodet tre gange. Hvad der rent faktisk genopbygges ved hver enkelt påkaldelse, er alt, der legitimt varierer fra ét kaldested til det næste: barn-trackeren, den sammenkædede CTM, det sammenskårne klip, og ressource-scopet. Den per-påkaldelses-CTM- og klip-regnskab er det samme maskineri bag PDFlibPas' content-stream-CTM- og klip-tilstands-tracker, værd at læse sammen med denne til enhver content-stream-gennemgang, der går ud over selve Form-rekursion

To grænser er værd at sætte forventninger omkring, før dette API går ind i en større pipeline. 64-niveau-dybde-loftet er ikke en tuning-knap til legitimt dybe dokumenter, da rigtige fakturaer, kontoudtog og rapportskabeloner i det væsentlige aldrig indlejrer Forms mere end tre eller fire niveauer dybt — et dokument, der rent faktisk rammer ftsDepthLimit, er langt mere sandsynligt at være misdannet eller fjendtligt end usædvanligt udførligt, og er værd at logge som et datakvalitets-signal frem for i stilhed at blive genforsøgt med et større tal. EnumPageContentStatesEx er også et læse-side-analyse-API: det rapporterer, hvad en content-stream gør, ikke om et Form overhovedet skal være synligt, hvilket er et separat spørgsmål besvaret af Optional-Content-Group-synligheds-tilstand, når et stempel- eller vandmærke-Form sidder bag et lag, en fremviser måske har slået fra. Kaldkæde-cyklus-registrering, per-påkaldelses-isolering og ressource-scoping udgør sammen ét hjørne af content-stream-inspektions-fladen i PDFlibPas-komponenten til Delphi og C++Builder