Articolo tecnico

Output PDF linearizzato in Delphi: hint table HotPDF

HotPDF scrive file PDF linearizzati, il layout che Acrobat etichetta Fast Web View, tramite la proprietà LinearizeOutput su THotPDF. Impostarla prima di BeginDoc fa sì che HotPDF riordini il grafo degli oggetti finito così che un reader consapevole degli intervalli di byte possa mostrare la pagina uno dopo aver recuperato solo la parte iniziale del file, invece di scaricare prima l'intero documento. Il meccanismo è ISO 32000-1 Annex F

Il motivo per cui questo conta è poco affascinante. Un PDF normale mette la sua tabella cross-reference alla fine, quindi un viewer deve raggiungere l'ultimo byte prima di sapere dove si trova qualsiasi cosa. Consegna a un browser un report scansionato di 200 pagine e l'utente fissa uno spinner per l'intero trasferimento, anche se l'unica cosa che voleva era la pagina 1. La linearizzazione risolve questo pagando un costo al momento della scrittura. Questo articolo tratta specificamente quel percorso di scrittura, il partizionamento, il ciclo di misurazione e i limiti rigidi; per il contesto concettuale su cosa ti offre Fast Web View, la precedente spiegazione della linearizzazione PDF e Fast Web View copre quel terreno

Cosa garantisce realmente il layout linearizzato

Un file linearizzato è un PDF ordinario con un ordinamento fisico estremamente specifico, e ogni garanzia che offre deriva da quell'ordinamento piuttosto che da qualsiasi nuovo tipo di oggetto. HotPDF emette le parti nella sequenza prescritta da Annex F: il dizionario dei parametri di linearizzazione dentro i primi 1024 byte, una tabella cross-reference anticipata, gli oggetti a livello documento, lo hint stream primario, la prima pagina e i suoi oggetti privati, poi le pagine rimanenti, poi gli oggetti condivisi, poi tutto il resto, e infine la tabella cross-reference principale

Il partizionamento è derivato, non dichiarato. HotPDF percorre il grafo dei riferimenti da ogni oggetto pagina e registra, per ogni oggetto indiretto, quante pagine lo raggiungono e quale pagina lo ha raggiunto per prima. Un oggetto usato da esattamente una pagina diventa privato di quella pagina. Un oggetto raggiunto da più di una diventa condiviso. Il catalogo, più tutto ciò a cui fa riferimento sotto /ViewerPreferences, /OpenAction, /Threads e /AcroForm, più il dizionario di cifratura quando la protezione è attiva, formano il gruppo a livello documento che deve precedere tutto. I nodi dell'albero delle pagine vengono trattenuti deliberatamente così da non inquinare la sezione della prima pagina

Il dizionario dei parametri porta i numeri di cui un reader ha bisogno prima di aver letto qualsiasi altra cosa: /L per la lunghezza totale del file, /H per l'offset e la lunghezza dello hint stream, /O per il numero oggetto della prima pagina, /E per il byte in cui termina la sezione della prima pagina, /N per il numero di pagine e /T per l'offset della voce della tabella cross-reference principale. Ognuno di questi è un offset di byte in un file che non esiste ancora nel momento in cui devi scriverli

Perché gli offset delle hint table devono convergere?

Perché i numeri nel dizionario dei parametri descrivono il file che li contiene, e cambiare uno qualsiasi di essi cambia il file. Questa è la difficoltà centrale di uno scrittore linearizzato, ed è il motivo per cui HotPDF misura ripetutamente invece di scrivere una sola volta. Allarga /T da 6 cifre a 7 e il dizionario dei parametri cresce di un byte; l'header cresce; ogni oggetto si sposta; la tabella cross-reference principale si sposta; /T ora ha bisogno di un valore diverso. Il layout deve raggiungere un punto fisso prima che un solo byte di output reale venga confermato

HotPDF gestisce questo con un'iterazione limitata. Prima serializza ogni oggetto in uno stream di conteggio che registra la lunghezza senza mantenere i byte, così ogni oggetto ha una dimensione serializzata nota. Poi esegue una passata di layout che assegna offset al gruppo a livello documento, allo hint stream, al gruppo della prima pagina, ai gruppi delle pagine successive, al gruppo condiviso e al resto, e riporta dove atterrerebbe la tabella cross-reference principale. Quel risultato viene reintrodotto come input della passata successiva. Il ciclo è limitato a otto tentativi, e la mancata convergenza solleva un'eccezione invece di produrre un file con offset sbagliati ma dall'aspetto plausibile

CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
  CalculateLayout(CandidateMainOffset, FirstXRefData,
    HintOffset, EndFirstPage, NewMainOffset);
  if NewMainOffset = CandidateMainOffset then
    Break;
  CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
  raise Exception.Create('Linearization layout did not converge');

Due dettagli impediscono al ciclo di andare in stallo. Il dizionario dei parametri viene scritto in uno slot fisso di 384 byte, riempito con spazi, così la sua stessa crescita non può mai destabilizzare il layout; se il testo del dizionario superasse mai quella riserva HotPDF solleva un'eccezione invece di spostare tutto silenziosamente. E dopo la convergenza HotPDF esegue una passata di layout di conferma in più e ricontrolla la lunghezza dello hint stream, perché lo hint stream stesso codifica offset noti solo dopo che il layout si è stabilizzato. Il guadagno di tutta questa misurazione è che HotPDF non bufferizza mai una seconda copia del documento: una volta fissati gli offset, gli oggetti vengono serializzati direttamente nello stream di destinazione, con un'asserzione a ogni confine di sezione che verifica che i byte scritti corrispondano all'offset che era stato promesso

Attivarla da Delphi

La superficie API è un solo Booleano, e il suo unico requisito è che tu lo imposti prima che la generazione inizi. LinearizeOutput ha valore predefinito False, e la passata di layout viene eseguita quando il documento viene scritto, quindi assegnarla dopo EndDoc non ottiene nulla

var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := 'fast-view.pdf';
    PDF.Version := pdf17;
    PDF.LinearizeOutput := True;      // must precede BeginDoc
    PDF.BeginDoc;
    PDF.Canvas.TextOut(72, 72, 'First page');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

Un avvertimento sul deployment supera in importanza tutto ciò che riguarda il codice. La linearizzazione paga solo quando il trasporto supporta le richieste HTTP range. Servi lo stesso file da un endpoint che lo trasmette per intero, o da una configurazione CDN che ignora Range, e ti sei comprato un percorso di scrittura più lento e un file più grande senza alcun guadagno visibile all'utente. Controlla il server prima di controllare il codice

Perché la linearizzazione sovrascrive UseXRefStream e UseObjectStreams?

Perché lo scrittore linearizzato ha bisogno che ogni oggetto abbia il proprio offset di byte direttamente indirizzabile, ed entrambe queste funzionalità lo tolgono. HotPDF quindi emette tabelle cross-reference testuali tradizionali e oggetti indiretti non impacchettati ogni volta che LinearizeOutput è abilitato, anche se il chiamante ha impostato anche UseXRefStream o UseObjectStreams. Questa è una sovrascrittura deliberata, non un conflitto che devi risolvere tu

Il ragionamento segue dalle hint table. Una hint table descrive dove inizia una sezione di pagina e quanto è lunga, così un reader può richiedere esattamente quell'intervallo. Un oggetto impacchettato in un contenitore /ObjStm non ha alcun offset indipendente; esiste solo come una fetta dentro un altro stream compresso che deve essere recuperato e decompresso come unità. Se contavi sugli object stream per la dimensione del file, tieni presente che linearizzazione e compressione tirano qui in direzioni opposte, e leggi il compromesso nell'articolo gemello su object stream e aggiornamenti incrementali in HotPDF. La stessa tensione plasma i file a riferimento ibrido, che esistono precisamente per mantenere i reader più vecchi funzionanti accanto alle tabelle basate su stream, come trattato nell'articolo su stream cross-reference ibridi nei PDF generati da Office

C'è anche un limite minimo di versione. La linearizzazione richiede PDF 1.2 o successivo. Se la versione selezionata è più vecchia, HotPDF la alza automaticamente, a meno che StrictVersionLock non sia impostato, nel qual caso la scrittura solleva un'eccezione invece di promuovere silenziosamente un documento che avevi fissato di proposito

Il muro dei 4 GiB, e perché HotPDF rifiuta invece di troncare

Le hint table di linearizzazione memorizzano gli offset come valori a 32 bit, quindi un file linearizzato non può indirizzare nulla a 4 GiB o oltre, e HotPDF rifiuta tale output con un'eccezione esplicita invece di scrivere un file con offset andati in overflow. Il limite non è una scelta implementativa di HotPDF; è la larghezza dei campi che Annex F definisce

Il controllo viene applicato in tre punti, e tutti e tre contano. HotPDF valida ogni oggetto una volta nota la sua lunghezza serializzata, valida la lunghezza di ogni sezione di pagina mentre costruisce le voci hint, e valida la lunghezza finale del file dopo che la tabella cross-reference principale è stata dimensionata. Fallire presto è l'intero punto: una hint table con un offset silenziosamente troncato produce un file che si apre correttamente in un viewer che lo scarica per intero e fallisce solo per il client byte-range che la linearizzazione esisteva per servire, il che è la peggiore modalità di fallimento possibile perché il tuo viewer di test non la riproduce mai. Se stai producendo output di più gigabyte, la linearizzazione non è lo strumento giusto, e l'approccio a streaming descritto nelle note su la Direct File API per flussi di lavoro PDF di grandi dimensioni è la direzione da guardare

Rilevare la linearizzazione su un file caricato

THotPDF.IsLoadedLinearized riporta se il documento attualmente caricato era già scritto in forma linearizzata, e risponde a partire da uno snapshot preso prima dell'analisi, non dallo stream live. HotPDF legge i primi 1024 byte dalla posizione zero dello stream sorgente, li scandisce alla ricerca della prima parola chiave obj e poi di una voce /Linearized con valore 1, e mette in cache il risultato booleano

var
  PDF: THotPDF;
  PageCount: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PageCount := PDF.LoadFromFile('incoming.pdf');
    if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
      Writeln('Source is not Fast Web View ready');
  finally
    PDF.Free;
  end;
end;

Due vincoli in quella descrizione sono portanti. Il rilevamento non può fare affidamento sulla posizione dello stream, perché nel momento in cui il codice applicativo pone la domanda il parser l'ha già spostata, e non può rileggere su richiesta perché LoadFromFile rilascia lo stream sorgente interno una volta terminato il caricamento. Da qui il design di cattura-prima-dell'analisi-e-cache. La scansione è anche deliberatamente letterale sul valore: viene accettato solo /Linearized 1 o una forma numericamente equivalente con una frazione tutta a zero, perché un file il cui dizionario dei parametri dice qualcos'altro non sta mantenendo la promessa di Annex F

Una trappola sui record Delphi che vale la pena rubare

I record locali contenenti array dinamici inizializzano i loro campi gestiti e nient'altro, e se tieni un semplice campo Count accanto all'array devi azzerarlo tu stesso. Questo ha morso il partizionamento della linearizzazione durante lo sviluppo, ed è il tipo di bug che costa una giornata proprio perché una piattaforma lo nasconde

type
  THPDFLinearIndexList = record
    Values: THPDFIntegerArray;  // managed field: cleared for you
    Count: Integer;             // plain field: whatever was on the stack
  end;

// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);

Il campo array dinamico è a conteggio di riferimenti, quindi il compilatore lo azzera. Il Count accanto ad esso è un intero ordinario senza tale garanzia, e un Count non inizializzato invia il primissimo append a un indice arbitrario. Sotto Win32 lo slot dello stack capitava contenesse zero, l'append atterrava all'indice 0, e ogni test passava. Sotto Win64 lo stesso codice scriveva oltre la fine dell'array. La lezione si generalizza ben oltre la linearizzazione: quando un record mischia campi gestiti e non gestiti, assegna Default(TRecord) e smetti di ragionare su quali campi copra il compilatore, e non trattare mai un'esecuzione Win32 verde come prova che l'inizializzazione sia corretta

I membri LinearizeOutput e IsLoadedLinearized descritti qui sono distribuiti con il HotPDF Component standard per Delphi e C++Builder; la pagina prodotto porta il riferimento completo delle proprietà, incluse le regole di interazione con cross-reference stream, object stream e version locking