Articolo tecnico

Object Stream e Cross-Reference Stream PDF in Delphi

Gli object stream di PDF 1.5 impacchettano molti piccoli oggetti indiretti in un unico contenitore compresso Flate, e losLab PDF Library li emette su un salvataggio completo tramite il suo flag PackObjectStreams. Il guadagno è reale: centinaia di dizionari di pagina, font e annotazione che costano ciascuno decine di byte non compressi si riducono a una manciata di blob compressi. Il costo è che ogni oggetto impacchettato ora richiede uno stream di cross-reference per descriverlo

È in questa seconda metà che i writer si rompono. Costruire un contenitore /ObjStm è aritmetica; insegnare al meccanismo di cross-reference a puntare al suo interno è una riprogettazione. Un writer che produce un contenitore perfettamente valido e poi descrive i suoi membri con normali offset di tipo 1 ha prodotto un file che Acrobat aprirà giusto il tempo di dichiararlo danneggiato. Le due funzionalità sono in realtà una sola, e questo articolo copre il lato scrittura di entrambe, come definito in ISO 32000-1 §7.5.7 e §7.5.8

Cosa contiene realmente un contenitore ObjStm

Un object stream è uno stream i cui byte decodificati formano due regioni concatenate, e ISO 32000-1 §7.5.7 assegna al dizionario esattamente tre chiavi rilevanti per la costruzione. /Type /ObjStm lo identifica, /N fornisce il numero di membri, e /First fornisce la lunghezza in byte della regione di intestazione — equivalentemente, l'offset a cui inizia il corpo. L'intestazione è costituita da coppie separate da spazi di numero di oggetto e offset; il corpo è formato dai membri serializzati uno dopo l'altro, con ogni offset misurato dall'inizio del corpo anziché dall'inizio del payload decodificato. Leggere un contenitore completamente decodificato lo rende evidente: qui sotto, /First vale 14 perché le tre righe di intestazione occupano quattordici byte, e l'oggetto 7 si trova 55 byte dentro il corpo perché l'oggetto 4 si serializza in 54 caratteri più un separatore

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

Due regole di appartenenza sono assolute ed entrambe derivano direttamente da §7.5.7. Un oggetto stream non può mai essere un membro, perché uno stream trasporta byte grezzi che dovrebbero essere annidati dentro un altro stream. E un membro deve essere un valore di oggetto completo, mai un semplice riferimento indiretto — un oggetto compresso che è solo 5 0 R crea un'indirezione che il lettore non può risolvere senza già sapere dove punta. losLab PDF Library filtra entrambi i casi durante la raccolta dei candidati, insieme al dizionario di crittografia e all'oggetto 0, poi impacchetta ciò che sopravvive in gruppi di 200 per contenitore. Quel limite è una decisione legata all'accesso casuale, non un limite di specifica: un lettore che vuole un solo membro deve decomprimere l'intero contenitore, quindi contenitori sovradimensionati rendono costose anche le piccole ricerche

Perché i membri di ObjStm devono usare voci di cross-reference di tipo 2?

Perché un oggetto impacchettato non ha alcun offset di file da registrare. ISO 32000-1 §7.5.8 risponde con tre tipi di voce in uno stream di cross-reference binario: tipo 0 per gli oggetti liberi, tipo 1 per i normali oggetti in uso memorizzati a un offset di byte, e tipo 2 per gli oggetti compressi, i cui due campi dati contengono il numero di oggetto del contenitore e l'indice del membro al suo interno. Non c'è modo di esprimere un oggetto impacchettato nella classica tabella xref in chiaro, ed è proprio per questo che PDF 1.5 ha introdotto entrambe le funzionalità insieme

L'ordinamento che ne consegue manda in confusione quasi ogni prima implementazione, inclusa la nostra. Gli oggetti ordinari ricevono voci di tipo 1. Gli stessi contenitori /ObjStm ricevono voci di tipo 1, perché un contenitore è un oggetto stream indiretto perfettamente normale scritto a un offset reale. Solo i membri ricevono voci di tipo 2. E lo stream di cross-reference è esso stesso un oggetto indiretto nel file, quindi necessita di una propria voce di tipo 1 che punti all'offset a cui è appena stato scritto — lo stesso offset registrato da startxref. Una prima versione del nostro writer escludeva i numeri di oggetto dei contenitori dal ciclo di scrittura invece di escludere i membri, e il risultato era un file con uno stream di cross-reference e senza alcun object stream: strutturalmente coerente, semanticamente vuoto, respinto a valle. Il valore /Size nasconde un off-by-one analogo, poiché è il numero di oggetto più alto più uno e lo stream di cross-reference è allocato come il numero di oggetto più alto, quindi va contato anch'esso

Dimensionare l'array /W: perché quattro byte non bastano

L'array /W dichiara la larghezza in byte di ciascuno dei tre campi, e losLab PDF Library lo scrive come /W [1 Field2 Field3] con il campo 1 fissato a un byte per il codice di tipo e il campo 3 fissato a due byte, il che copre sia i numeri di generazione fino a 65535 sia gli indici di membro. Il campo 2 è quello che non può essere costante, perché trasporta due quantità non correlate: in una voce di tipo 1 è un offset di byte limitato solo dalla dimensione del file, mentre in una voce di tipo 2 è un numero di oggetto contenitore e in una voce di tipo 0 è il prossimo oggetto libero nella catena. Un campo 2 fisso a quattro byte funziona bene finché il file non supera i 4 GB, momento in cui ogni offset oltre quel limite viene troncato silenziosamente e l'intera tabella diventa spazzatura. Il writer quindi scansiona la tabella assemblata per il valore più grande che qualunque slot di campo 2 dovrà mai contenere, incluso l'offset dello stream di cross-reference stesso, e allarga il campo fino a otto byte

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

Una volta note le larghezze, la dimensione del payload è nota esattamente, quindi il writer preassegna l'intero buffer e lo riempie per indice; aggiungere voci byte per byte a un AnsiString rende la costruzione della tabella quadratica, cosa che nessuno nota su una fattura di dieci pagine e che tutti notano su un documento con duecentomila oggetti. Altri due dettagli mantengono soddisfatti i lettori rigorosi. /Index dichiara quali intervalli di numeri di oggetto copre la tabella, e per una riscrittura completa è semplicemente [0 N] senza buchi. E ogni slot che il writer non ha effettivamente emesso deve avere come default libero anziché in uso: l'oggetto 0 apre la catena dei liberi, ogni slot libero è collegato al successivo, e uno slot che un tempo conteneva un oggetto cancellato mantiene il proprio numero di generazione incrementato di uno. La nota di accompagnamento su sicurezza della memoria durante il parsing di PDF non attendibili propone lo stesso ragionamento sui limiti dal lato della lettura

Perché lo stream di cross-reference non deve mai essere crittografato?

Perché un lettore deve analizzarlo prima di poter sapere come decrittografare qualunque cosa. Lo stream di cross-reference è ciò che indica al lettore dove si trova il dizionario /Encrypt; se i suoi byte fossero a loro volta crittografati, il lettore avrebbe bisogno della chiave del file per trovare l'oggetto che descrive la chiave del file. losLab PDF Library impone questo con un unico predicato: ShouldCryptStreamData restituisce False ogni volta che il dizionario dello stream porta /Type /XRef, così l'esenzione vale indipendentemente da quale percorso raggiunga il serializzatore

Il contenitore /ObjStm riceve il trattamento opposto, e l'asimmetria è deliberata. Un contenitore viene crittografato per intero, con chiave basata sul proprio numero di oggetto, esattamente come qualunque altro stream. I suoi membri non vengono crittografati individualmente — sono impacchettati nella loro forma decrittografata in chiaro, e il singolo passaggio sul contenitore assemblato li copre, stringhe incluse. Crittografare due volte i membri produce un file che si decrittografa in testo cifrato, e poiché il livello esterno ha successo, il fallimento emerge come errore di parsing in profondità nel grafo degli oggetti anziché come errore di autenticazione. Un oggetto resta quindi completamente fuori dallo schema: in un documento crittografato il Catalog viene mantenuto come oggetto diretto di tipo 1 e non viene mai impacchettato, perché impacchettarlo costringerebbe il loader a decomprimere e decrittografare un object stream per raggiungere la radice del documento, prima che il contesto di decrittografia che la radice contribuisce a stabilire sia completamente costruito

Attivare il packing da Delphi

L'interruttore pubblico è PackObjectStreams, esposto come campo su TPDFlibSaveOptions, come setter autonomo SetPackObjectStreams, e come proprietà sull'oggetto documento. È abilitato di default ed è vincolato automaticamente dalla versione: il writer impacchetta solo quando il documento è già PDF 1.5 o successivo, e chiama la guardia interna di versione minima così un documento impacchettato viene portato a 1.5 anziché etichettato in modo errato. Dopo il salvataggio, GetLastSaveUsedObjectStreams riporta se il vincolo si è effettivamente aperto, che è l'asserzione che vuoi in un test di regressione piuttosto che un confronto di dimensione in byte

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

L'ordine conta tra packing e garbage collection. L'analisi di raggiungibilità deve essere eseguita per prima, perché un membro che sopravvive dentro un contenitore trascina con sé il contenitore — se un oggetto vivo è impacchettato, il numero del suo contenitore è raggiungibile per definizione, e spazzare via il contenitore lascia il membro senza modo di essere localizzato. Eseguire prima il collector significa anche che gli oggetti morti non entrano mai in un contenitore, ed è da lì che deriva il guadagno di dimensione cumulativo. Il packing completa le altre leve di dimensione anziché sostituirle; la trattazione di ottimizzazione della dimensione del file PDF e subsetting dei font copre le leve che agiscono sui payload degli stream, mentre gli object stream agiscono sulla struttura

Limiti da conoscere prima di attivarlo

I salvataggi incrementali non impacchettano mai. Un aggiornamento incrementale accoda nuovi oggetti e una nuova sezione di cross-reference lasciando fisicamente intatte le revisioni precedenti, quindi reimpacchettare oggetti esistenti in contenitori nuovi renderebbe orfane le voci di tipo 1 ancora referenziate dalla revisione precedente; losLab PDF Library disabilita il packing ogni volta che la modalità append è attiva, e l'articolo su aggiornamenti incrementali e streaming in modalità append tratta quel percorso per intero. I documenti sotto PDF 1.5 mantengono incondizionatamente la tabella di cross-reference in chiaro: un consumer 1.4 non ha idea di cosa significhi /ObjStm, e promuovere silenziosamente un documento perché il writer ha preferito un file più piccolo sarebbe il compromesso sbagliato da fare per conto del chiamante. Una chiave opzionale che deliberatamente non emettiamo è /Extends, che ISO 32000-1 §7.5.7 definisce affinché un contenitore possa nominare un predecessore e i lettori possano trattare una catena di contenitori come un gruppo logico. È genuinamente opzionale, ogni contenitore che scriviamo è autonomo e decodificabile indipendentemente, e ometterla rimuove dal writer un'intera classe di bug di cicli e riferimenti pendenti — sebbene i lettori debbano ovviamente ancora rispettare /Extends quando lo incontrano in file di altri produttori

Il packing degli object stream e l'output degli stream di cross-reference fanno parte di losLab PDF Library per Delphi e C++Builder, insieme al garbage collector e all'ottimizzatore di content stream con cui si compongono; la pagina prodotto riporta il riferimento completo delle opzioni di salvataggio