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
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
// 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
// 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