Articolo tecnico

Precisione decimale preservata nel salvataggio PDF in Delphi

PDFlibPas, la losLab PDF Developer Library, conserva il testo decimale esatto che ha letto per ogni numero reale di un documento e riscrive quel testo alla lettera ogni volta che il valore non è mai stato modificato. Dalla v3.539.19 l'impostazione SetPrecision governa solo i numeri che la libreria crea o modifica, quindi un normale load-and-save non arrotonda più un /Gamma di CalRGB da 2.22221 a 2.2222 e non sposta i colori di una pagina che nessuno ha toccato. Il cambio è piccolo nel codice e grande in quello che dice sui parser: il valore che decodifichi e il letterale che emetti sono due cose diverse, e un round trip su Double non è una trasformazione identità

Perché un salvataggio che non cambiava nulla spostava i colori della pagina?

Perché venivano riformattati i parametri del color space, non l'immagine. Il file che ha esposto il problema è un documento office di 35 pagine nel corpus di regressione locale, con un'immagine di intestazione riusata su ogni pagina. Caricarlo e risalvarlo così com'era produceva stream immagine identici byte per byte all'input, e un confronto di hash sugli stream riportava il documento come invariato. Un confronto sui render non era d'accordo: tutte e 35 le pagine mostravano differenze di pixel nell'intestazione, e da nessun'altra parte

L'immagine di intestazione disegna attraverso un color space CalRGB, che ISO 32000-1 §8.6.5.3 definisce con un /WhitePoint, un array /Gamma opzionale di tre elementi e una /Matrix opzionale di nove elementi. Quegli array sono oggetti numerici puri nel dizionario del color space. TPDFNumeric memorizzava ognuno come Double e nient'altro, e TPDFNumeric.Output formattava quel Double tramite PDFPrecNum, che per default usa quattro cifre decimali. Così /Gamma passava da 2.22221 a 2.2222, una entry della matrice passava da 0.71519 a 0.7152, e il renderer produceva fedelmente colori leggermente diversi da una calibrazione leggermente diversa. I byte dell'immagine erano innocenti; i numeri intorno a essi no. La parte scomoda è quanto tutto questo fosse invisibile. Confrontare i byte degli stream decodificati non lo vede, perché i numeri vivono in un dizionario, non in uno stream. Confrontare i payload degli allegati non lo vede. Persino il revision diff descritto in l'articolo sui modification level prende l'impronta di un corpo di oggetto normalizzato, quindi entrambe le revisioni producono lo stesso hash e il diff le riporta identiche. Solo il rendering l'ha colto, ed è per questo che il baseline del corpus renderizza ogni pagina invece di fidarsi dei soli controlli strutturali

Dove PDFlibPas perdeva precisione CalRGB in un salvataggio no-op su Delphi: il /Gamma 2.22221 letto e una entry di matrice 0.71519 vivono in TPDFNumeric come Double, Output li formatta tramite PLDoubleToStr con PDFPrecNum a quattro cifre decimali, ogni controllo strutturale riporta il documento invariato, e solo il confronto sui render mostra tutte e 35 le immagini di intestazione spostate
I byte dell'immagine erano innocenti: TPDFNumeric riformattava i numeri di calibrazione intorno a essi tramite PDFPrecNum, quindi gli hash degli stream e il diff di impronte riportavano entrambi revisioni identiche mentre il renderer produceva colori leggermente diversi su ogni pagina

Il valore che hai letto non è il letterale che dovresti scrivere

Un numero reale PDF è una stringa decimale, e ISO 32000-1 §7.3.3 è esplicito sul fatto che è solo una stringa decimale: nessuna notazione in base diversa, nessuna forma esponenziale. L'Annex C elenca poi la precisione che un'implementazione dovrebbe rispettare, circa cinque cifre decimali significative nella parte frazionaria. Una precisione di output di default pari a quattro è già sotto quella soglia, e peggiora vicino allo zero: PLDoubleToStr scala il valore, arrotonda a un intero ed emette 0 quando il risultato è zero, quindi una entry di matrice pari a -0.000012345 non perde una cifra, sparisce del tutto

Alzare il default sposterebbe solo il precipizio. La correzione è smettere di far finta che un Double sia il numero. Quando il tokenizer in TPDFStructure.Decode riconosce un real standard, cioè un token che contiene un punto decimale e nessun marcatore di esponente, memorizza il testo sorgente nel nuovo campo FOriginalText insieme al valore convertito. Output preferisce poi quel testo e torna alla formattazione solo quando non c'è nulla da preferire

Come PDFlibPas preserva il testo decimale letto in Delphi: il tokenizer in TPDFStructure.Decode conserva il letterale sorgente in FOriginalText per ogni token con punto decimale e senza esponente, Output scrive quel testo alla lettera invece di chiamare PLDoubleToStr, SetTo lo azzera perché un numero modificato è un numero nuovo
Il valore che decodifichi e il letterale che emetti sono due cose diverse: preferire il testo letto mantiene 2.22221 esatto, mentre i numeri creati o modificati dalla libreria seguono ancora PDFPrecNum e l'impostazione non raggiunge mai l'input non toccato
// Lib/PDFlibStruct.pas — l'intera correzione sul lato output
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // un numero modificato è un numero nuovo
  FValue:= Value;
  FChanged:= True;
End;

Due confini sono voluti. Gli interi non vengono preservati, perché la formattazione degli interi è già senza perdita. Le forme esponenziali come 6.02E23 vengono tollerate in input per amore dei producer rotti ma non vengono preservate in output, dato che riscriverle perpetuerebbe una sintassi che la §7.3.3 vieta; passano per il formatter come qualunque numero generato dalla libreria. Il tokenizer applica anche la sua solita riparazione minima prima di memorizzare il testo, quindi un letterale con punto iniziale come .5 viene tenuto come 0.5 e un letterale con punto finale come 5. come 5.0. Per ogni lettore sono lo stesso numero e sono accettati molto più largamente

Cosa garantisce SetPrecision dopo la v3.539.19?

TPDFlib.SetPrecision ora controlla le cifre decimali dei numeri che la libreria stessa produce: i valori tracciati dal painter, i numeri creati da un Double come tramite NewNumeric, e qualunque valore letto che sia stato poi modificato con SetTo. Da notare che il testo decodificato attraverso l'object API, per esempio un letterale passato a SetObjectFromString, passa per lo stesso tokenizer e viene preservato allo stesso modo. Un decimale letto e mai modificato conserva la sua precisione di input qualunque sia l'impostazione, e cambiare l'impostazione dopo il load non lo tocca retroattivamente. La voce di riferimento di SetPrecision è stata aggiornata nella stessa release per dire esattamente questo, perché la vecchia formulazione lasciava intendere che l'impostazione valesse per ogni numero del file

L'azzeramento avviene in SetTo invece di essere derivato dal flag Changed, e questa distinzione conta. La pipeline di salvataggio resetta Changed sugli oggetti una volta che sono stati scritti, quindi un controllo del tipo "emetti il testo originale a meno che sia cambiato" inizierebbe a emettere testo vecchio per un valore modificato, salvato e modificato di nuovo nella stessa sessione. Legare il testo originale all'assegnazione stessa rende impossibile che i due siano in disaccordo. Il test di regressione fissa ognuno di questi comportamenti con i valori del file originale

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // L'input non modificato sopravvive alla lettera, incluso il valore che
    // la formattazione a quattro cifre avrebbe ridotto a 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Una modifica scarta il testo originale e segue PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Abbassare la precisione dopo non raggiunge l'input non modificato
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Perché il content model normalizza comunque i numeri?

Perché TPDFContentProgram promette operandi numerici canonici, e quella promessa vale più del testo alla lettera dentro un content stream. Il content model modificabile, lo stesso su cui è costruito il graphics-state tracker, esiste perché NormalizeContentStreams, l'optimizer e Emit producano output stabile e confrontabile da input arbitrario. Se un operando letto portasse il suo testo originale dentro il modello, una sequenza di operatori come 0.50000 0 0 RG verrebbe emessa diversamente da 0.5 0 0 RG, e ogni confronto a valle andrebbe alla deriva seguendo le abitudini di formattazione del producer

Quindi il modello elimina il testo originale nei suoi due entry point. NormalizeContentNumbers gira su ogni operando mentre il parser lo inserisce e di nuovo dentro SetOperand quando viene decodificata una sorgente fornita dal chiamante, e ricorre attraverso array e dizionari, così dash pattern, array TJ e i property dictionary dei marked content sono coperti. Chiamare SetTo(AsDouble) su ogni numerico basta, dato che è esattamente l'operazione che azzera il testo. I dati grezzi delle inline image restano invece intatti, come sono sempre stati

Perché il content model di PDFlibPas normalizza comunque i numeri: NormalizeContentNumbers gira dove il parser inserisce ogni operando e di nuovo dentro SetOperand, ricorre attraverso array e dizionari così dash pattern, array TJ e property dictionary dei marked content sono coperti, e SetTo AsDouble azzera il testo originale così 0.50000 e 0.5 vengono emessi in modo identico
Gli operandi numerici canonici sono la promessa del content model: i dati grezzi delle inline image restano intatti, e i numeri dei dizionari fuori dai content stream mantengono la garanzia alla lettera, quindi una semplice coppia LoadFromFile e SaveToFile li preserva ancora
// Lib/PDFlibContentModel.pas — il content model mantiene il suo contratto
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

La regola pratica per chi chiama è quindi semplice. Un semplice LoadFromFile seguito da SaveToFile lascia i content stream non toccati e i numeri dei dizionari non toccati come stavano. Una pagina che passa per NormalizeContentStreams, o qualunque modifica fatta attraverso il content model, esce canonica per progetto, e il resto del documento è comunque preservato. Sono due richieste diverse, e ora fanno due cose diverse

Quanto costa, e dove si ferma la garanzia

Ogni TPDFNumeric ora porta un riferimento AnsiString in più, e ogni decimale letto tiene vivo il suo testo sorgente per tutta la vita dell'oggetto. Su un documento con milioni di numeri reali è memoria vera, e va messa in qualsiasi misurazione su documenti grandi invece di essere liquidata con un gesto. La garanzia è anche circoscritta al documento del numero stesso: copiare oggetti tra documenti o ricostruire valori tramite l'object API produce numeri nuovi, che seguono la precisione di output come qualunque altro numero nuovo. Vale la pena essere precisi su cosa la release afferma e cosa no. Un load-and-save di un documento non toccato ora preserva i numeri di calibrazione che il renderer consuma davvero, che è la proprietà che il baseline del corpus controlla. Non afferma un output identico byte per byte, che dipende anche dalla numerazione degli oggetti, dalla compressione degli stream e dall'identificatore del trailer discusso in l'articolo sul PDF ID deterministico. E non fa vedere al diff di impronte le differenze di arrotondamento in file prodotti da altro software, dato che quelli continuano a fare l'hash del corpo normalizzato. La lezione si generalizza ben oltre CalRGB: quando un parser conserva solo il valore convertito, ogni salvataggio è una modifica, e l'unico modo di accorgersene è guardare il risultato renderizzato. La gestione dei numeri e la semantica di SetPrecision sono documentate nella pagina prodotto di losLab PDF Developer Library