Articolo tecnico

Gestione di PDF a Riferimento Ibrido da Applicazioni Office in Delphi

Esporta un documento da Microsoft Word o Excel tramite Salva come PDF e il file su disco è, nella maggior parte dei casi, un file a riferimento ibrido (hybrid-reference). Questo file trasporta due volte le proprie informazioni sui riferimenti incrociati (cross-reference): una volta come la classica tabella a larghezza fissa che concludeva ogni PDF fino alla versione 1.4, e una volta come un flusso (stream) compresso di riferimenti incrociati da cui dipende effettivamente gran parte del documento. Una singola chiave del trailer, /XRefStm, unisce le due viste, e il fatto che uno strumento riesca a vedere l'intero documento dipende unicamente dal fatto che esso segua o meno quella chiave

Questo articolo esamina i file ibridi dal lato di chi li consuma: come appaiono i byte alla fine del file, come le due viste si distaccano l'una dall'altra durante la modifica e come una pipeline Delphi può rilevare e instradare input ibridi. Come un loader (caricatore) unisce le viste, e perché l'ordine non sia negoziabile, è l'argomento del nostro articolo HotPDF sul caricamento di file a riferimento ibrido; questo si concentra innanzitutto sul riconoscimento del layout (struttura)

Perché le esportazioni di Office scrivono l'indice due volte

PDF 1.5 ha introdotto due funzionalità che hanno cambiato la forma del file: i flussi di riferimenti incrociati (cross-reference streams), che memorizzano l'indice degli oggetti come dati binari compressi invece di una tabella in testo semplice, e i flussi di oggetti (object streams), che raggruppano molti piccoli oggetti all'interno di un unico contenitore compresso con Flate. Un programma di scrittura che li utilizza produce file più piccoli, ma un lettore PDF 1.4 non è in grado di aprire il risultato, poiché le strutture su cui si basa, ovvero la parola chiave xref e il dizionario trailer, non sono più presenti

L'ISO 32000-1 §7.5.8.4 definisce il compromesso. Un file a riferimento ibrido li scrive entrambi: una classica tabella di riferimenti incrociati che indirizza gli oggetti che un vecchio lettore deve raggiungere, tra cui il catalogo e l'albero delle pagine, e un flusso di riferimenti incrociati che indicizza tutto il resto. Gli oggetti ripiegati nei flussi di oggetti sono contrassegnati come liberi (free) nella tabella classica, cosicché un lettore 1.4 li salti senza lamentarsi; le loro reali posizioni esistono solo nel flusso. Il classico trailer include quindi una chiave /XRefStm che contiene l'offset in byte di tale flusso. Un vecchio visualizzatore non legge mai la chiave ed esegue il rendering del file a partire dalla vista tabellare. Un visualizzatore moderno la segue e vede il documento completo. Word ed Excel emettono esattamente questo layout da anni, motivo per cui i file ibridi non rappresentano un caso limite ed esotico (corner case), ma una grossa fetta di ciò che le pipeline aziendali ricevono

Che aspetto ha la coda di un file ibrido

Il layout è più facile da comprendere dai byte. Questa è la coda di un piccolo file ibrido, con offset accorciati; in una vera esportazione di Office, il valore /XRefStm è tipicamente un offset di grandi dimensioni situato vicino alla fine del file. L'ordine di lettura procede a ritroso (tail-first walk) come descritto nella nostra panoramica della struttura dei file PDF: trovare %%EOF, leggere startxref, saltare alla tabella

% ... oggetti del corpo (body objects), compresi i flussi di oggetti e, al byte 116,
% il flusso dei riferimenti incrociati (un oggetto stream con /Type /XRef) ...

xref                    % sezione classica: ciò a cui punta startxref
0 4
0000000000 65535 f      % slot 0: inizio dell'elenco degli elementi liberi, sempre presente
0000000017 00000 n      % oggetto 1: il catalogo, visibile a qualsiasi lettore
0000000000 65535 f      % oggetto 2: contrassegnato come libero -- vive in un flusso di oggetti
0000000000 65535 f      % oggetto 3: lo stesso; solo la visualizzazione a flusso lo individua
trailer
<<
  /Size 4
  /Root 1 0 R
  /XRefStm 116          % offset in byte del flusso dei riferimenti incrociati
>>
startxref
7164                    % offset in byte della parola chiave 'xref' sopra
%%EOF

Due dettagli in questo dump portano il peso dell'intero meccanismo. In primo luogo, startxref punta di proposito alla sezione classica: questo è l'indirizzo su cui deve atterrare un vecchio lettore. Il flusso dei riferimenti incrociati è raggiungibile solo tramite la chiave /XRefStm posta all'interno del dizionario del trailer, pertanto un parser che non cerca mai quella chiave non scoprirà mai che il flusso esiste. In secondo luogo, gli oggetti 2 e 3 sono menzogne di tipo innocuo (benign kind). La tabella classica li dichiara liberi, ma in realtà sono oggetti veri collocati all'interno di un contenitore compresso; la marcatura di oggetto libero è ciò che impedisce a un lettore 1.4 di inciampare in voci che non è in grado di usare. Un consumatore (software) che si fida esclusivamente della visualizzazione classica arriverà alla conclusione che la maggior parte di questo documento non esista

Come le due viste si allontanano l'una dall'altra

Un file ibrido appena sfornato da Word è internamente coerente: entrambe le viste descrivono lo stesso documento, ciascuna all'interno del proprio ambito dichiarato. I guai iniziano quando il file viene modificato da uno strumento che comprende solo una delle viste. Si consideri un'utilità di timbratura (stamping utility) che aggiunge un aggiornamento incrementale in stile classico: nuovi oggetti, una nuova sezione xref, una catena /Prev verso la sezione precedente e un nuovo trailer. Se quel trailer abbandona la chiave /XRefStm, la vista a flusso (stream view) rimane orfana; se copia il vecchio valore portandolo avanti, la vista a flusso descriverà ancora il documento com'era prima della modifica. In entrambi i casi, i due indici ora sono in disaccordo riguardo a ciò che il file contiene

Il file risultante possiede una firma di errore (failure signature) distintiva: gli oggetti visibili in una vista risultano mancanti o non aggiornati (stale) nell'altra. Un lettore che esegue la risoluzione tramite la vista a flusso trova la versione pre-modifica di un oggetto aggiornato, o non trova alcuna voce per un oggetto aggiunto. Un lettore sulla visualizzazione tabellare vede la modifica ma perde le tracce degli oggetti compressi che solo il flusso è in grado di individuare. In pratica, questo si manifesta sotto forma di campi modulo (form fields) che sopravvivono in un visualizzatore ma svaniscono in un altro, annotazioni che una passata di timbratura sembra aver eliminato, o ricerche (lookups) che atterrano su un oggetto del tutto sbagliato

Ciò che rende questi file costosi da sottoporre al debug (debug) è che in genere Adobe Acrobat li apre senza lamentele: quando l'indice è in disaccordo con i byte, ricostruisce silenziosamente i dati dei riferimenti incrociati cercando le intestazioni (headers) degli oggetti, così chi ha prodotto il file danneggiato non nota nulla di anomalo. L'errore si manifesta in un secondo momento, quando il file raggiunge un consumatore severo, un validatore preflight, un servizio di firma o un processo di ingestione per l'archiviazione (archival ingest job), che si fida della struttura dichiarata e segnala oggetti mancanti o una discrepanza nei riferimenti incrociati. "Si apre bene in Acrobat" è il modo in cui inizia quasi ogni ticket legato alla desincronizzazione dei file ibridi

Rilevare un file ibrido unicamente con Delphi (plain Delphi)

Classificare gli input non richiede una libreria PDF. La chiave /XRefStm può ricorrere solo all'interno del dizionario di un trailer classico, e il trailer attivo risiede nelle ultime due coppie di kilobyte del file, perché le specifiche richiedono che %%EOF appaia in prossimità della fine fisica. Leggere una finestra di coda delimitata (bounded tail window) e fare una ricerca al suo interno è sufficiente per compiere un triage:

uses
  System.SysUtils, System.Classes, System.StrUtils, System.Math;

function IsHybridReferencePdf(const FileName: string): Boolean;
const
  TailWindow = 2048;
var
  Stream: TFileStream;
  Buf: TBytes;
  Tail: string;
  Len, TrailerPos, NextPos, KeyPos, StartXrefPos: Integer;
begin
  Result := False;
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if Stream.Size < 48 then
      Exit;
    Len := Min(TailWindow, Integer(Stream.Size));
    SetLength(Buf, Len);
    Stream.Position := Stream.Size - Len;
    Stream.ReadBuffer(Buf[0], Len);
  finally
    Stream.Free;
  end;

  // Ogni parola chiave coinvolta è in ASCII a 7 bit, quindi una decodifica byte per byte è sicura
  Tail := TEncoding.ANSI.GetString(Buf);

  // Trova l'ULTIMA parola chiave 'trailer': con gli aggiornamenti incrementali,
  // il trailer più recente è quello che governa il file
  TrailerPos := 0;
  NextPos := Pos('trailer', Tail);
  while NextPos > 0 do
  begin
    TrailerPos := NextPos;
    NextPos := PosEx('trailer', Tail, NextPos + 1);
  end;
  if TrailerPos = 0 then
    Exit;  // nessun trailer classico: un file xref-stream puro, non ibrido

  // Un trailer ibrido trasporta /XRefStm tra 'trailer' e 'startxref'
  KeyPos := PosEx('/XRefStm', Tail, TrailerPos);
  StartXrefPos := PosEx('startxref', Tail, TrailerPos);
  Result := (KeyPos > 0) and
    ((StartXrefPos = 0) or (KeyPos < StartXrefPos));
end;

I tre risultati sono allineati ai tre layout. Un file unicamente classico ha un trailer ma nessun /XRefStm: False. Un file che si affida completamente ai flussi di riferimenti incrociati non possiede affatto la parola chiave trailer, dato che le chiavi del suo trailer vivono nel dizionario del flusso: anche in questo caso False, in modo corretto, perché tale file è compresso e non ibrido. Soltanto il layout a doppia indicizzazione (double-indexed) restituisce True

Per l'uso in produzione (production use), vale la pena di aggiungere un paio di accorgimenti di rafforzamento che richiederanno righe di codice extra. Analizza l'intero (integer) posto dopo /XRefStm, spostati su (seek to) quell'offset e conferma che un oggetto stream (flusso) avente /Type /XRef si trovi effettivamente lì; un file troncato può contenere la chiave anche qualora il flusso sia scomparso, il che va inserito in un raggruppamento (bucket) diverso rispetto a un ibrido integro. Inoltre, tratta la dimensione della finestra come un parametro: 2 KB coprono i normali risultati prodotti da Office, ma un dizionario del trailer insolitamente grande può spingere la parola chiave fuori raggio; pertanto, allargare la finestra è meglio che dichiarare per sbaglio che il file è classico

Instradamento dei file ibridi tramite una pipeline Delphi

Il rilevamento (detection) ti regala una decisione d'instradamento (routing). Per i file che vengono esclusivamente letti, resi (rendered) o validati, utilizza un caricatore (loader) che risolve entrambe le viste, dopodiché verifica il comportamento invece dei byte. Il Componente PDFium analizza la catena /XRefStm durante il caricamento, pertanto la tabella degli oggetti che il tuo codice vede è quella fusa, e i controlli descritti nel nostro articolo sulla validazione dei flussi di oggetti e di riferimenti incrociati si applicano senza alcuna modifica. Qualora un ibrido desincronizzato (desynchronized) sia danneggiato in modo così grave da rifiutare il caricamento, il motore lo segnala attraverso il suo set di errori, FPDF_ERR_SUCCESS, FPDF_ERR_UNKNOWN, FPDF_ERR_FILE, FPDF_ERR_FORMAT, FPDF_ERR_PASSWORD, FPDF_ERR_SECURITY e FPDF_ERR_PAGE, dove FPDF_ERR_FORMAT è quello provocato dai danni strutturali. Tuttavia, non fare troppo affidamento su questo segnale: PDFium è permissivo per progettazione (by design) e ricostruisce la maggior parte dei file incoerenti in maniera del tutto silenziosa, sicché un caricamento andato a buon fine dimostra che il file era recuperabile, ma non che le sue due viste siano concordi. Il controllo di coerenza significativo consiste nel confrontare ciò che trova un'esplorazione (walk) completa degli oggetti con ciò che la grandezza dichiarata (/Size) del trailer stabilisce

Nel caso dei file modificati dalla tua pipeline, la politica (policy) più sicura è quella di impedire del tutto che siano ibridi. Un caricamento seguito da un salvataggio (save) completo eseguito tramite HotPDF riscrive il documento provvisto di un singolo riferimento incrociato auto-coerente e in un'unica forma: nessun /XRefStm, nessuna seconda vista destinata a perdere la sincronizzazione, ogni oggetto di proprietà di un'esatta voce dell'indice. Quella normalizzazione (normalization) è ciò di cui hai bisogno prima dell'ingestione ai fini dell'archiviazione (archival ingest), prima di un rigoroso servizio di firma (signing service) o RIP (Raster Image Processor) a valle (downstream), nonché dopo qualsiasi modifica applicata a un input ibrido. Questo funziona poiché il loader (caricatore) ha unito correttamente le viste all'ingresso, il meccanismo che l'articolo HotPDF relativo al riferimento ibrido illustra nel dettaglio

L'unica classe di file da lasciare in pace è quella dei documenti firmati digitalmente (digitally signed). Una riscrittura completa sposta ciascun byte, il che invalida qualunque firma calcolata sopra gli intervalli originari. Una modifica a un ibrido firmato deve essere inserita come un vero aggiornamento incrementale in grado di preservare entrambe le viste; un file che necessita solamente di una lettura (reading) dovrebbe passare intatto (untouched). La normalizzazione è per i file di cui detieni la proprietà; i file firmati vanno solo aggiunti in coda (append)

I PDF a riferimento ibrido non sono malformati; essi rappresentano il ponte di compatibilità proprio del formato (format's own compatibility bridge), e le applicazioni Office continueranno a produrli fintantoché i lettori PDF 1.4 sopravvivranno nella base d'installazione. Una pipeline in grado di individuare la chiave /XRefStm, di validare il documento unito per mezzo del Componente PDFium e di rigenerare risultati a indice singolo e puliti grazie al Componente HotPDF, li tratta per ciò che sono: normali input dotati di un segnale di indicazione (signpost) in più all'interno del trailer