PDFlibPas, de losLab PDF Developer Library, bewaart de exacte decimale tekst die het voor elk reëel getal in een document parste en schrijft die tekst woordelijk terug wanneer de waarde nooit is gewijzigd. Sinds v3.539.19 stuurt de instelling SetPrecision alleen getallen die de library zelf aanmaakt of bewerkt, dus een gewone load-and-save rondt een CalRGB-/Gamma van 2.22221 niet meer af naar 2.2222 en verschuift hij de kleuren van een pagina die niemand heeft aangeraakt niet meer. De wijziging is klein in code en groot in wat hij over parsers zegt: de waarde die je decodeert en het literal dat je uitschrijft zijn twee verschillende dingen, en een Double-roundtrip is geen identieke transformatie
Waarom verschoof een save die niets wijzigde de paginakleuren?
Omdat de kleurruimteparameters opnieuw werden geformatteerd, niet de afbeelding. Het bestand dat dit blootlegde is een kantoordocument van 35 pagina's in het lokale regressiecorpus, met een headerafbeelding die op elke pagina terugkomt. Het laden en direct weer opslaan leverde imagestreams op die byte voor byte identiek waren aan de input, en een streamhashvergelijking meldde het document ongewijzigd. Een gerenderde vergelijking was het daar niet mee eens: alle 35 pagina's vertoonden pixelverschillen in de header, en nergens anders
De headerafbeelding tekent via een CalRGB-kleurruimte, die ISO 32000-1 §8.6.5.3 definieert met een /WhitePoint, een optionele /Gamma-array van drie elementen en een optionele /Matrix van negen elementen. Die arrays zijn gewone numerieke objecten in de kleurruimtedictionary. TPDFNumeric bewaarde elk ervan als een Double en verder niets, en TPDFNumeric.Output formatteerde die Double via PDFPrecNum, dat standaard op vier decimalen staat. Zo ging /Gamma van 2.22221 naar 2.2222, ging een matrixentry van 0.71519 naar 0.7152, en produceerde de renderer trouw iets andere kleuren uit een iets andere calibratie. De imagebytes waren onschuldig; de getallen eromheen niet. Het ongemakkelijke deel is hoe onzichtbaar dit was. Gededecodeerde streambytes vergelijken ziet het niet, want de getallen wonen in een dictionary, niet in een stream. Bijlagepayloads vergelijken ziet het niet. Zelfs de revision diff uit het artikel over modification levels vingerafdrukt een genormaliseerde objectbody, dus beide revisies hashen naar dezelfde waarde en de diff meldt ze identiek. Alleen renderen ving het, en daarom rendert de corpusbaseline elke pagina in plaats van alleen op structurele checks te vertrouwen
De waarde die je parste is niet het literal dat je moet schrijven
Een reëel getal in PDF is een decimale string, en ISO 32000-1 §7.3.3 is er expliciet over dat het alleen een decimale string is: geen radixnotatie, geen exponentvorm. Annex C somt vervolgens de precisie op die een implementatie hoort te respecteren, ongeveer vijf significante decimale cijfers in het fractionele deel. Een standaard uitvoerprecisie van vier zit daar al onder, en dicht bij nul wordt het erger: PLDoubleToStr schaalt de waarde, rondt naar een integer af en schrijft 0 uit wanneer het resultaat nul is, dus een matrixentry van -0.000012345 verliest geen cijfer, hij verdwijnt helemaal
De standaardwaarde verhogen zou de klif alleen verplaatsen. De fix is ophouden te doen alsof een Double het getal is. Wanneer de tokenizer in TPDFStructure.Decode een standaard reëel getal herkent, dat wil zeggen een token met een decimale punt en zonder exponentmarker, slaat hij de brontekst op in het nieuwe veld FOriginalText, naast de omgerekende waarde. Output geeft daarna de voorkeur aan die tekst en valt alleen terug op formatteren wanneer er niets te verkiezen is
// Lib/PDFlibStruct.pas — de hele fix aan de uitvoerkant
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:= ''; // een bewerkt getal is een nieuw getal
FValue:= Value;
FChanged:= True;
End;
Twee grenzen zijn bewust. Integers worden niet bewaard, want integerformattering is al verliesvrij. Exponentvormen zoals 6.02E23 worden bij de input getolereerd voor kapotte producers, maar niet bewaard bij de output, want ze terugschrijven zou een syntaxis in stand houden die §7.3.3 verbiedt; ze gaan door de formatter zoals elk door de library gegenereerd getal. De tokenizer past ook zijn gebruikelijke minimale reparatie toe voordat hij de tekst opslaat, dus een literal met een voorloop-punt zoals .5 blijft bewaard als 0.5 en een literal met een sluitpunt zoals 5. als 5.0. Beide zijn voor elke reader hetzelfde getal en worden veel breder geaccepteerd
Wat garandeert SetPrecision na v3.539.19?
TPDFlib.SetPrecision stuurt nu de decimalen van getallen die de library zelf produceert: waarden die via de painter worden getekend, getallen die uit een Double zijn gemaakt, bijvoorbeeld via NewNumeric, en elke geparseerde waarde die daarna met SetTo is bewerkt. Let op dat tekst die via de object-API wordt gedecodeerd, bijvoorbeeld een literal die aan SetObjectFromString wordt meegegeven, door dezelfde tokenizer gaat en op dezelfde manier bewaard blijft. Een geparseerd decimaal dat nooit is gewijzigd houdt zijn inputprecisie ongeacht de instelling, en de instelling na het laden veranderen raakt het niet met terugwerkende kracht. De referentietekst van SetPrecision is in dezelfde release bijgewerkt om precies dit te zeggen, omdat de oude formulering suggereerde dat de instelling op elk getal in het bestand van toepassing was
Het wissen gebeurt in SetTo in plaats van te worden afgeleid uit de Changed-flag, en dat onderscheid is belangrijk. De save-pipeline reset Changed op objecten zodra ze zijn geschreven, dus een check in de vorm "schrijf de originele tekst uit tenzij gewijzigd" zou verouderde tekst gaan uitschrijven voor een waarde die in dezelfde sessie is bewerkt, opgeslagen en opnieuw bewerkt. De originele tekst aan de toewijzing zelf koppelen maakt het onmogelijk dat de twee het oneens worden. De regressietest pint elk van deze gedragingen vast met de waarden uit het originele bestand
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]'));
// Onbewerkte input overleeft woordelijk, inclusief de waarde die
// formattering op vier decimalen tot 0 zou hebben platgeslagen
Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');
// Een bewerking gooit de originele tekst weg en volgt PDFPrecNum
Number := TPDFNumeric(Values.Item[0]);
Number.SetTo(0.123456);
Assert(Number.Output = '0.1235');
Assert(Structure.NewNumeric(0.123456).Output = '0.1235');
// De precisie daarna verlagen raakt onbewerkte input niet
Structure.PDFPrecNum := 2;
Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
finally
Structure.Free;
end;
end;
Waarom normaliseert het contentmodel getallen nog steeds?
Omdat TPDFContentProgram canonieke numerieke operanden belooft, en die belofte is meer waard dan woordelijke tekst binnen een contentstream. Het bewerkbare contentmodel, hetzelfde model waarop de graphics-state-tracker is gebouwd, bestaat zodat NormalizeContentStreams, de optimizer en Emit stabiele, vergelijkbare output uit willekeurige input produceren. Als een geparseerde operand zijn originele tekst het model in droeg, zou een operatoreeks als 0.50000 0 0 RG er anders uitkomen dan 0.5 0 0 RG, en zou elke vergelijking stroomafwaarts meedrijven met de formatteringsgewoonten van de producer
Dus stript het model de originele tekst bij zijn twee ingangspunten. NormalizeContentNumbers loopt over elke operand terwijl de parser hem erop duwt en nog eens binnen SetOperand wanneer door de aanroeper aangeleverde source wordt gedecodeerd, en hij recursiet door arrays en dictionaries zodat dashpatronen, TJ-arrays en de property-dictionaries van marked content gedekt zijn. SetTo(AsDouble) op elk numeriek object aanroepen is genoeg, want dat is precies de operatie die de tekst wist. Ruwe inline-imagedata wordt met rust gelaten, zoals altijd
// Lib/PDFlibContentModel.pas — het contentmodel houdt zijn contract
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;
De praktische regel voor aanroepers is dus simpel. Een gewone LoadFromFile gevolgd door SaveToFile laat onaangeroerde contentstreams en onaangeroerde dictionarygetallen zoals ze waren. Een pagina die door NormalizeContentStreams gaat, of welke bewerking dan ook via het contentmodel, komt per ontwerp canoniek eruit, en de rest van het document blijft bewaard. Dat zijn twee verschillende verzoeken, en ze doen nu twee verschillende dingen
Wat het kost, en waar de garantie ophoudt
Elke TPDFNumeric draagt nu één extra AnsiString-referentie, en elk geparseerd decimaal houdt zijn brontekst in leven zolang het object bestaat. Op een document met miljoenen reële getallen is dat echte hoeveelheid geheugen, en dat hoort in elke meting van grote documenten te zitten in plaats van te worden weggewuifd. De garantie is ook beperkt tot het document van het getal zelf: objecten tussen documenten kopiëren of waarden reconstrueren via de object-API levert nieuwe getallen op, die net als elk ander nieuw getal de uitvoerprecisie volgen. Het is de moeite waard precies te zijn over wat de release wel en niet claimt. Een load-and-save van een onaangeroerd document bewaart nu de calibratiegetallen die de renderer werkelijk verbruikt, en dat is de eigenschap die de corpusbaseline controleert. De release claimt geen byte-identieke output, want die hangt ook af van objectnummering, streamcompressie en de trailer-identifier uit het artikel over deterministische PDF-ID's. En hij zorgt er niet voor dat de fingerprint-diff afrondingsverschillen ziet in bestanden die andere software heeft geproduceerd, want die hashen nog steeds de genormaliseerde body. De les gaat veel verder dan CalRGB: wanneer een parser alleen de omgerekende waarde bewaart, is elke save een bewerking, en de enige manier om het te merken is naar het gerenderde resultaat kijken. De getalbehandeling en de semantiek van SetPrecision staan gedocumenteerd op de productpagina van de losLab PDF Developer Library