Articolo tecnico

Ricorsione Form XObject: Rilevamento dei Cicli di PDFlibPas in Delphi

PDFlibPas risolve le chiamate ricorsive a Form XObject nei content stream PDF Delphi tracciando la catena di chiamata attiva, non un insieme globale di visitati, cosicché TPDFlib.EnumPageContentStatesEx possa percorrere lo stesso Form invocato diverse volte su una pagina senza scambiare un legittimo riutilizzo per un ciclo. Un Form XObject di timbro in un modello di fattura è il caso tipico: lo stesso oggetto viene chiamato dall'intestazione, dal piè di pagina, e da un livello filigrana su una pagina, e solo una catena di chiamata che si richiude su se stessa è un vero ciclo

ISO 32000-1 §8.10 definisce un Form XObject come un content stream autonomo che una pagina, o un altro Form, invoca con l'operatore Do, completo di un proprio sistema di coordinate in /Matrix, un confine di ritaglio in quel sistema di coordinate in /BBox, e opzionalmente il proprio dizionario di risorse. Nulla nella specifica limita quante volte un Form possa essere invocato o quanto in profondità i Form possano invocarsi a vicenda, quindi un parser conforme deve accettare il legittimo riutilizzo e l'annidamento legittimo mentre si difende comunque dall'unica configurazione che la specifica vieta realmente: un Form il cui content stream, direttamente o transitivamente, invoca se stesso. PDFlibPas riporta quella distinzione tramite i valori TPDFlibContentFormTraversalStatus allegati a ogni snapshot Do, in particolare ftsEnumerated per una discesa riuscita e ftsCycle per l'unico caso che è realmente un ciclo

Perché riutilizzare lo stesso Form XObject non scatena un falso ciclo?

Un riferimento ripetuto a un Form XObject non è, di per sé, evidenza di nulla di sbagliato. ISO 32000-1 permette che lo stesso oggetto Form venga invocato da tutti i punti che l'autore desidera in un content stream, che è esattamente come un timbro logo, un modello di carta intestata, o un piè di pagina con numero di pagina venga riutilizzato in una pagina senza duplicare il proprio content stream più volte. La protezione ingenua contro la ricorsione fuori controllo è un unico insieme di visitati indicizzato per numero di oggetto: la prima volta che un percorritore vede l'oggetto Form 12, marca 12 come visto e rifiuta di rientrarvi in qualsiasi altro punto dell'albero. Quell'approccio si rompe nel momento in cui lo stesso timbro appare in due angoli non correlati di una pagina, perché la seconda chiamata, del tutto legittima, arriva dopo che il numero di oggetto è già stato marcato come visto e viene respinta come se fosse un ciclo

PDFlibPas evita quel falso positivo delimitando l'ambito del rilevamento cicli alla catena di chiamata corrente invece che all'intero documento. EnumPageContentStatesEx spinge lo stream Form risolto sulla catena di chiamata attiva immediatamente prima di discendere in esso, poi estrae di nuovo quella stessa voce nel momento in cui la discesa ritorna, con successo o meno. Un'invocazione gemella dello stream identico inizia solo dopo che la prima è già stata estratta, quindi la catena di chiamata è libera da quello stream nel momento in cui la chiamata gemella lo controlla, e il percorritore lo enumera esattamente come farebbe con qualsiasi altro Form. Un vero ciclo appare diverso su quella stessa catena: il Form A chiama il Form B, B è ancora aperto sulla catena quando il proprio contenuto richiama A, e A è ancora seduto sulla catena dalla chiamata esterna che non è ancora tornata — quella è l'unica forma che ftsCycle segnala, uno stream Form ancora aperto da qualche parte prima nella catena di chiamata corrente, non semplicemente presente da qualche altra parte nella pagina

Quanto in profondità può andare la ricorsione Form XObject prima che PDFlibPas la fermi?

Il rilevamento dei cicli e la limitazione della profondità risolvono due problemi diversi, e PDFlibPas li mantiene come due diversi esiti TPDFlibContentFormTraversalStatus proprio per questo motivo. Una catena di venti Form distinti, ciascuno che chiama il successivo e nessuno che si ripete, non è un ciclo secondo alcuna definizione — il controllo della catena attiva non trova mai uno stream ripetuto — ma venti onesti livelli di annidamento sono comunque venti livelli di analisi, concatenazione di matrici, e risoluzione di risorse che un PDF malformato o avversario potrebbe spingere arbitrariamente più in alto se nient'altro lo fermasse. EnumPageContentStatesEx accetta un parametro MaxFormDepth proprio per questo motivo e blocca qualunque valore venga passato a un massimo di 64, indipendentemente da cosa il chiamante richieda. Una profondità di zero è un caso speciale che vale la pena conoscere a sé: disabilita del tutto la ricorsione Form e riproduce il comportamento piatto, solo pagina, del vecchio metodo EnumPageContentStates, motivo per cui ogni snapshot Do in quella modalità segnala ftsNotRequested invece di tentare qualsiasi cosa

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;

Un sotto-tracker per invocazione: isolare lo stato grafico

Ogni discesa in un Form XObject ottiene il proprio tracker di stato grafico invece di condividere quello che già percorre la pagina, perché un Form ha l'obbligo di lasciare lo stato grafico esattamente come lo ha trovato, e PDFlibPas non può presupporre che ogni PDF che apre rispetti realmente tale obbligo. Il tracker figlio parte da uno snapshot di qualunque CTM, stato colore e parametri di testo fossero attivi all'istruzione Do chiamante, poi azzera il proprio stack di salva-e-ripristina e il tracciamento del percorso corrente prima di eseguire una singola istruzione del Form. Un q sbilanciato senza un Q corrispondente dentro un Form disattento o danneggiato, non una cosa rara da trovare in PDF prodotti da strumenti più vecchi, resta contenuto dentro il tracker di quell'unica invocazione e non trapela mai nel tracker della pagina né in un'invocazione gemella dello stesso timbro seduta una riga più tardi nel content stream

La /Matrix del Form si compone con la CTM in vigore al Do nello stesso modo in cui fa un operatore cm, premoltiplicata contro la trasformazione corrente invece che sostituita ad essa, e PDFlibPas riutilizza deliberatamente quell'unico percorso di codice invece di mantenere una seconda formula, poiché due implementazioni indipendenti della stessa algebra matriciale sono esattamente il tipo di duplicazione che silenziosamente diverge dopo qualche giro di composizione di scala, rotazione e taglio. /BBox poi ritaglia nello spazio di coordinate proprio del Form dopo che la matrice è già stata applicata, e tutti e quattro gli angoli di quel riquadro vengono trasformati individualmente invece che solo gli angoli opposti, poiché un Form ruotato o cesoiato può altrimenti segnalare un riquadro delimitatore che manca contenuto reale seduto in ciò che era un angolo estremo prima che la trasformazione lo spostasse altrove. Estendere il ciclo dall'esempio precedente sullo stesso array States legge quei campi direttamente

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

Due Form con lo stesso nome risorsa condividono un font?

No. Un nome risorsa come /F1 significa qualcosa solo relativamente al dizionario di risorse attivo nel punto in cui viene usato, e due diversi Form XObject sono liberi di definire due font completamente diversi sotto quel nome identico. PDFlibPas risolve questo tracciando un ambito di risorsa accanto a ogni nome risorsa: quando un Form porta il proprio dizionario /Resources, quel dizionario diventa l'ambito completo di risorse per tutto ciò che sta al suo interno, senza alcun fallback per singola chiave al dizionario di pagina o chiamante per qualunque cosa il dizionario proprio del Form capiti di omettere. Solo un Form senza alcuna chiave /Resources affatto, uno schema ancora prodotto da alcuni generatori PDF più vecchi, eredita il dizionario chiamante in blocco, e quella è una deroga di compatibilità deliberata piuttosto che una regola generale su cui vale la pena fare affidamento in nuovo output. L'identità del font in uno snapshot TPDFlibContentGraphicsState è quindi la coppia di FontResource e FontResourceScope, non il nome da solo, con FontObjectNumber disponibile per confermare esattamente a quale oggetto indiretto un dato /F1 si sia risolto in quell'ambito particolare

Lo stesso ambito si applica a ogni altra risorsa nominata che un Form possa portare, voci ExtGState e voci XObject annidate incluse, poiché il meccanismo di risoluzione sottostante non tratta i font come caso speciale — il caso dei font capita semplicemente di contare di più, perché un'identità di font disallineata produce silenziosamente i glifi sbagliati invece di un fallimento evidente. Il codice di estrazione che raggruppa i run di testo solo per nome font, senza anche raggruppare per ambito risorsa, unirà due font visivamente diversi che capitano di condividere un nome, e l'errore non emergerà finché qualcuno non nota numeri del carattere tipografico sbagliato seduti dentro ciò che avrebbe dovuto leggersi come un font coerente

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

Leggere FormTraversalStatus nella tua pipeline

FormTraversalStatus trasforma da sé ogni snapshot Do in un piccolo report diagnostico, e una pipeline che lo ignora sta buttando via esattamente l'informazione che spiegherebbe un'estrazione incompleta. ftsNotApplicable significa che l'istruzione non è mai stata affatto un'invocazione Form risolta; ftsNotRequested significa che la ricorsione era disattivata per questa chiamata; ftsEnumerated significa che il Form è stato analizzato e percorso con successo; ftsDepthLimit e ftsCycle segnano i due modi in cui una discesa viene tagliata corta apposta; e ftsMalformed copre tutto il resto che ha fermato il percorso — un riferimento a stream non risolvibile, una /Matrix o /BBox che non è riuscita ad analizzarsi, o un'eccezione sollevata durante l'esecuzione del contenuto proprio del Form. Quest'ultimo caso conta operativamente, perché un percorso annidato fallito annulla qualunque output parziale avesse già prodotto per quel ramo, cosicché un chiamante non debba mai indovinare se un Form fosse genuinamente vuoto o sia semplicemente esploso due istruzioni dentro il proprio 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;

Confini, costi, e dove questo si inserisce

Il content stream di un Form viene decodificato e analizzato esattamente una volta per chiamata di enumerazione indipendentemente da quante volte il Form venga invocato, perché PDFlibPas mette in cache l'elenco di istruzioni analizzato contro l'oggetto stream sottostante invece di rianalizzarlo a ogni chiamata gemella — il timbro a tre angoli dell'esempio iniziale viene decodificato una volta e percorso tre volte, non decodificato tre volte. Ciò che viene ricostruito a ogni singola invocazione è tutto ciò che legittimamente differisce da un punto di chiamata al successivo: il tracker figlio, la CTM concatenata, il clip intersecato, e l'ambito di risorsa. Quella contabilità di CTM e clip per invocazione è lo stesso meccanismo dietro il tracker di stato CTM e clip del content stream di PDFlibPas, utile da leggere accanto a questo per qualsiasi percorso di content stream che vada oltre la sola ricorsione Form

Due limiti vale la pena impostare come aspettative prima che questa API entri in una pipeline più grande. Il tetto di profondità di 64 livelli non è una manopola di regolazione per documenti legittimamente profondi, poiché vere fatture, estratti conto, e modelli di report essenzialmente non annidano mai Form più di tre o quattro livelli in profondità — un documento che effettivamente colpisce ftsDepthLimit ha molte più probabilità di essere malformato o avversario che insolitamente elaborato, e vale la pena registrarlo come segnale di qualità dei dati piuttosto che riprovare silenziosamente con un numero più grande. EnumPageContentStatesEx è anche un'API di analisi solo in lettura: riporta cosa fa un content stream, non se un Form dovrebbe essere visibile affatto, che è una domanda separata a cui risponde lo stato di visibilità dei gruppi di contenuto opzionale quando un Form timbro o filigrana risiede dietro un livello che un visualizzatore potrebbe aver disattivato. Il rilevamento cicli tramite catena di chiamata, l'isolamento per invocazione, e l'ambito delle risorse insieme compongono un angolo della superficie di ispezione dei content stream nel componente PDFlibPas per Delphi e C++Builder