Articolo tecnico

Output PDF linearizzato in Delphi: hint table HotPDF

Il primo oggetto di un PDF linearizzato deve dichiarare la lunghezza esatta del file in cui si trova, al byte, e deve comparire entro i primi 1024 byte. L'Allegato F di ISO 32000-1 chiama quella voce /L, e a un reader che la trova in disaccordo con la reale dimensione del file viene detto di trattare il documento come un PDF ordinario e ignorare ogni hint di linearizzazione al suo interno. Quindi il writer deve stampare la lunghezza di un file che non ha finito di scrivere, accanto all'offset di uno hint stream, alla fine della prima pagina e alla posizione di una tabella dei riferimenti incrociati vicino alla fine del file. Tutti e quattro i numeri si muovono ogni volta che qualcosa davanti a loro cambia dimensione

Il premio è il Fast Web View. Un PDF ordinario tiene la sua tabella dei riferimenti incrociati alla fine, quindi un browser a cui viene consegnato un report scansionato di 200 pagine non può localizzare la pagina 1 finché l'ultimo byte non arriva; un file linearizzato sposta la pagina 1 e una piccola sezione di riferimenti incrociati in testa, così un client che usa richieste HTTP range dipinge la prima pagina presto. Quello che segue è il lato della scrittura. Il quadro dal lato lettura è nella spiegazione della linearizzazione PDF e del Fast Web View

Come HotPDF impagina un file linearizzato

HotPDF scrive output linearizzato quando THotPDF.LinearizeOutput è True, e il file segue l'ordine fisico che l'Allegato F prescrive, parte per parte. Dopo l'intestazione viene il dizionario dei parametri di linearizzazione, poi la sezione di riferimenti incrociati della prima pagina con il suo trailer, il catalogo con gli altri oggetti a livello documento, lo hint stream primario, la prima pagina con ogni oggetto di cui ha bisogno, le pagine rimanenti in ordine, gli oggetti condivisi tra le pagine successive, tutto il resto, e infine la tabella principale dei riferimenti incrociati

Diagramma dell'ordine dei byte di un PDF linearizzato scritto da HotPDF in Delphi, dal dizionario dei parametri di linearizzazione e dallo hint stream primario fino alla tabella principale dei riferimenti incrociati
HotPDF emette le parti dell'Allegato F in un ordine di byte fisso, con il dizionario dei parametri e lo hint stream primario piazzati abbastanza presto perché i reader a intervalli di byte iniziino a renderizzare la pagina uno quasi subito

Niente nel modello del documento dice a quale parte appartenga un oggetto. Il writer lo ricava dal grafo dei riferimenti quando EndDoc salva, camminando da ogni oggetto pagina e contando quante pagine raggiungono ogni oggetto indiretto. Un oggetto raggiunto da una pagina va nella sezione di quella pagina. Un oggetto raggiunto da più pagine è condiviso, con una torsione che una lettura frettolosa della spec si perde: quando la pagina 1 è tra le pagine che lo usano, l'oggetto va comunque nella sezione della prima pagina, perché la pagina 1 deve renderizzare dai soli byte in testa. Solo gli oggetti condivisi soltanto tra pagine successive aspettano nella parte condivisa. Il catalogo, qualunque cosa indichino le sue voci /ViewerPreferences, /OpenAction, /Threads e /AcroForm, e il dizionario di cifratura di un documento protetto compongono il gruppo a livello documento. I nodi dell'albero delle pagine vengono spinti nella parte finale così non gonfiano mai la prima pagina

Aprite l'output in un editor esadecimale e la numerazione sembra al contrario. Il blocco in testa, dal dizionario di linearizzazione fino allo hint stream, porta i numeri di oggetto più alti del file, mentre tutto ciò che viene dopo è numerato da 1. La piccola sezione di riferimenti incrociati in copre esattamente quel blocco alto, e la tabella principale copre da 1 a N

Una trappola di inizializzazione di record nelle liste di partizione

Liste come queste si scrivono naturalmente come un record che contiene un array dinamico più un conteggio separato, e quella forma nasconde una trappola Delphi. Il compilatore inizializza i campi gestiti di un record locale. Il Integer semplice accanto a loro non riceve nulla. Sotto Win32 lo slot di stack capita spesso di contenere zero, quindi il primo append atterra all'indice 0 e ogni test passa; sotto Win64 lo slot può contenere avanzi e il primo append scrive a un indice arbitrario

type
  TPartList = record
    Values: TArray<Integer>;  // gestito: azzerato dal compilatore
    Count: Integer;           // non gestito: ciò che lo stack conteneva
  end;

procedure Append(var List: TPartList; Value: Integer);
begin
  if List.Count = Length(List.Values) then
    SetLength(List.Values, List.Count * 2 + 16);
  List.Values[List.Count] := Value;
  Inc(List.Count);
end;

procedure BuildParts;
var
  FirstPage, Shared: TPartList;
begin
  FirstPage := Default(TPartList);   // obbligatorio, non estetico
  Shared := Default(TPartList);
  Append(FirstPage, 12);
  Append(Shared, 40);
end;

Il writer di HotPDF assegna Default a ogni lista di partizione prima di toccarla. Un'esecuzione verde su Win32 non dice nulla sul fatto che un simile record sia stato inizializzato

Perché l'impaginazione deve convergere prima che il primo byte sia scritto?

Perché la testa del file contiene offset che puntano verso il fondo, e il numero di cifre di quegli offset sposta tutto ciò che sta dietro di loro. Il trailer della prima pagina porta una voce /Prev con l'offset in byte della tabella principale dei riferimenti incrociati. Scrivete quell'offset come 99998, poi scoprite che la tabella atterra davvero a 100003, e il trailer cresce di una cifra. La sezione di riferimenti incrociati della prima pagina cresce con essa, ogni oggetto dietro si sposta di un byte, e la tabella principale ora siede a 100004. Un singolo passaggio becca il giusto solo per fortuna

HotPDF rompe il ciclo in due mosse. Il dizionario dei parametri va in uno slot fisso di 384 byte riempito con spazi, così /L, /H, /E e /T possono prendersi le cifre che gli servono senza spostare un solo oggetto; se il testo del dizionario servisse mai più di 384 byte, la scrittura solleverebbe un'eccezione anziché spostare il file. Resta /Prev come unica variabile libera, e passa per un loop a punto fisso. Ogni oggetto viene prima serializzato in uno stream di conteggio che registra le lunghezze e non tiene nessun byte. Un passaggio di impaginazione assegna poi gli offset sezione per sezione e riferisce dove atterrebbe la tabella principale, e quella risposta diventa l'input del passaggio successivo. Il loop si ferma quando l'offset proposto smette di muoversi e si arrende con un'eccezione dopo otto tentativi

Diagramma di flusso del loop di convergenza dell'impaginazione linearizzata di HotPDF in Delphi, dove gli offset proposti dei riferimenti incrociati vengono misurati ripetutamente finché raggiungono un punto fisso o un'eccezione abortisce la scrittura
Fino a otto passaggi di misura convergono l'offset della tabella principale dei riferimenti incrociati prima che venga commesso un qualsiasi byte; la non convergenza solleva un'eccezione anziché scrivere offset indovinati

Solo un cambio nel numero di cifre muove qualcosa, quindi un terzo passaggio serve solo vicino a un confine di potenza di dieci, come nel caso del 99998. Dopo la convergenza il writer esegue un passaggio di conferma, ricostruisce lo hint stream con gli offset reali e verifica che la sua lunghezza non sia cambiata; gli offset siedono in campi a larghezza fissa, quindi la versione segnaposto e quella finale devono combaciare. Poi l'output va dritto nello stream di destinazione senza una seconda copia bufferizzata, e a ogni confine di sezione i byte scritti vengono confrontati con l'offset promesso. Uno scostamento solleva un'eccezione da EndDoc

Hint table, e dove l'Allegato F si legge in due modi

Lo hint stream primario porta due tabelle. La tabella degli offset di pagina registra, per ogni pagina, quanti oggetti contiene la sua sezione, quanto è lunga in byte e da quali oggetti condivisi dipende. La tabella degli oggetti condivisi elenca la lunghezza di ogni gruppo di oggetti condivisi. Con entrambe, un reader può richiedere direttamente la pagina 37, e la voce /S dello stream dà la posizione dentro lo stream dove inizia la tabella condivisa

Due dettagli dell'Allegato F F.4 sono facili da sbagliare. Il primo è che gli offset conservati nelle hint table sono offset logici, calcolati come se lo hint stream primario non esistesse. L'oggetto prima pagina e il primo oggetto condiviso siedono fisicamente entrambi dopo lo hint stream, quindi il writer sottrae la lunghezza dell'oggetto hint prima di registrare i loro offset. Un writer che registra invece offset fisici fa atterrare ogni richiesta di intervallo risultante sbagliata esattamente della taglia dell'oggetto hint

Il secondo è una genuina ambiguità. Il testo del F.4 descrive ogni tabella come un unico flusso di bit continuo, bit più significativo per primo, ed elenca i gruppi condivisi della prima pagina e i gruppi condivisi successivi come due sequenze separate. Gli hint stream in circolazione riempiono ogni colonna fino a un confine di byte e trattano i gruppi condivisi come un'unica lista combinata, ed è quella la forma che i reader che consumano hint sono noti accettare. HotPDF scrive il layout allineato al byte e combinato. È il lato giusto da scegliere: una tabella che soddisfa la lettera del F.4 ma disallinea per i client che la leggono fallisce esattamente dove la linearizzazione doveva dare i suoi frutti, e uno scaricamento dell'intero file non espone mai il problema

Accendere l'output linearizzato da Delphi

Sul lato API l'interruttore è un Boolean. LinearizeOutput vale False per default, e il writer lo consulta quando EndDoc serializza il documento, quindi impostatelo con le altre opzioni di output prima di BeginDoc e lasciatelo in pace. Ogni fallimento descritto sopra (non convergenza, un dizionario dei parametri sovradimensionato, uno scostamento su un confine, il tetto di 4 GiB trattato qui sotto) affiora come un'eccezione da EndDoc, il che rende un fallback semplice

procedure WriteReport(const FileName: string; Linearize: Boolean);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := FileName;
    PDF.Version := pdf17;
    PDF.LinearizeOutput := Linearize;   // impostare prima di BeginDoc
    PDF.BeginDoc;                       // crea la pagina 1
    PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
    PDF.AddPage;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
    PDF.EndDoc;                         // impaginazione e scrittura avvengono qui
  finally
    PDF.Free;
  end;
end;

procedure PublishReport(const FileName: string);
begin
  try
    WriteReport(FileName, True);
  except
    on E: Exception do
    begin
      LogWarning('Linearization failed: ' + E.Message);  // il vostro logger
      WriteReport(FileName, False);                       // invece un PDF semplice
    end;
  end;
end;

Il fallback produce un PDF ordinario che qualsiasi viewer apre; perde solo la prima pagina veloce. Per una pipeline di report lo scambio è semplice, dato che una prima lenta dipintura è un fastidio e un report mancante è un incidente

Il lato della messa in produzione soprasta il codice. La linearizzazione dà i suoi frutti solo quando il server onora le richieste HTTP Range; un endpoint che trasmette il file intero vi compra una scrittura più lenta e un file più grande per niente. Lasciate LinearizeOutput spento per i file che vengono inviati per email, allegati o scaricati prima che qualcuno li veda. Accendetelo per i documenti che un browser apre direttamente da un URL, dopo aver confermato che la risposta porta Accept-Ranges: bytes

Perché LinearizeOutput spegne gli object stream?

Perché ogni voce delle hint table descrive un intervallo di byte, e un oggetto impacchettato in un contenitore /ObjStm non ha un intervallo di byte proprio; esiste solo come fetta di un altro stream compresso che va recuperato e inflato tutto intero. Con LinearizeOutput attivo, HotPDF scrive tabelle di riferimenti incrociati testuali tradizionali e oggetti indiretti scompattati anche quando UseXRefStream o UseObjectStreams è impostato. Il percorso linearizzato ha la precedenza al momento del salvataggio, quindi al vostro codice non resta nessun conflitto da risolvere

Il costo è la dimensione del file, dato che i piccoli dizionari che uno object stream comprimerebbe insieme ora restano sciolti. Se l'output compatto conta più della latenza della prima pagina, il compromesso è esposto in object stream e aggiornamenti incrementali in HotPDF. Gli exporter Office inciampano nella stessa tensione dal lato opposto, ecco perché alcuni di loro emettono il layout descritto in cross-reference stream ibridi nei PDF generati da Office

La linearizzazione richiede anche PDF 1.2 o successivo. Con una Version più bassa, HotPDF promuove il documento a 1.2 al salvataggio. Con StrictVersionLock impostato solleva invece un'eccezione, perché una versione che avete fissato apposta non dovrebbe cambiare alle vostre spalle

Il tetto di 4 GiB sull'output linearizzato

Le hint table conservano offset e lunghezze in campi a 32 bit, quindi un file linearizzato non può indirizzare nulla oltre i 4 GiB, e HotPDF rifiuta di produrne uno anziché scrivere offset che si avvolgono su se stessi. Il tetto viene dalle larghezze di campo che l'Allegato F definisce

Diagramma dei tre checkpoint HotPDF che misurano le dimensioni degli oggetti, le lunghezze delle sezioni di pagina e la lunghezza finale del file contro il limite di 4 GiB dei campi a 32 bit delle hint table di linearizzazione in Delphi
Le misure di oggetto, di sezione di pagina e di intero file vengono ciascuna verificate contro il tetto dei campi hint a 32 bit prima che l'output venga prodotto

Il controllo gira in tre punti, tutti prima che il primo byte raggiunga la destinazione: su ogni oggetto una volta nota la sua lunghezza serializzata, su ogni lunghezza di sezione di pagina mentre le voci hint vengono costruite, e sulla lunghezza finale del file una volta dimensionata la tabella principale dei riferimenti incrociati. Offset avvolti sarebbero il modo peggiore di fallire: un viewer che scarica l'intero file può ignorare gli hint, mentre il client a intervalli di byte salta sui byte sbagliati, quindi i test su desktop non riproducono mai il guasto. Per output di più gigabyte, la via streaming dell'API Direct File per flussi di lavoro con PDF grandi è dove guardare

Verificare se un file in arrivo è linearizzato

THotPDF.IsLoadedLinearized riferisce se il documento caricato si dichiara linearizzato. Il controllo avviene prima dell'analisi: HotPDF legge fino a 1024 byte dalla posizione zero della sorgente, trova il primo oggetto e guarda dentro quell'oggetto per /Linearized con valore 1. Commenti, literal string e stringhe esadecimali vengono saltati, così il nome che compare dentro una stringa non conta, e la scansione si ferma a stream o endobj. Solo 1 o un equivalente come 1.0 viene accettato

La risposta viene messa in cache al caricamento perché, quando il codice dell'applicazione la chiede, il parser ha già spostato la posizione dello stream e LoadFromFile ha già rilasciato il suo stream sorgente

Il flag riferisce ciò che il file dichiara. L'Allegato F aggiunge una seconda condizione: /L deve combaciare con la lunghezza reale del file, e un aggiornamento incrementale aggiunto dopo la linearizzazione rompe quella corrispondenza, così i reader tornano a trattare il file come ordinario. Una pipeline che accetta file da editor che aggiungono i loro salvataggi può confrontare i due numeri da sé

// Un vostro helper, non parte di HotPDF: confronta /L con la taglia reale
// Analisi semplificata; presume un singolo spazio dopo /L
function DeclaredLengthMatches(const FileName: string): Boolean;
var
  FS: TFileStream;
  Head: AnsiString;
  ActualSize: Int64;
  P, Q: Integer;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    ActualSize := FS.Size;
    SetLength(Head, 1024);
    SetLength(Head, FS.Read(Head[1], 1024));
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/L '), Head);
  Result := P > 0;
  if not Result then
    Exit;
  Inc(P, 3);
  Q := P;
  while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
    Inc(Q);
  Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;

procedure InspectUpload(const FileName: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    if PDF.LoadFromFile(FileName) <= 0 then
      Exit;
    if not PDF.IsLoadedLinearized then
      Writeln('Not linearized')
    else if not DeclaredLengthMatches(FileName) then
      Writeln('Linearized, but modified after linearization')
    else
      Writeln('Ready for Fast Web View');
  finally
    PDF.Free;
  end;
end;

LinearizeOutput e IsLoadedLinearized fanno parte dell'HotPDF Delphi Component per Delphi e C++Builder, e la sua pagina di prodotto documenta come interagiscono con i cross-reference stream, gli object stream e il blocco di versione