Technisch artikel

Geparseerde decimale precisie bewaren bij PDF-save in Delphi

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

Waar PDFlibPas CalRGB-precisie verloor bij een no-op save in Delphi: de geparseerde /Gamma 2.22221 en een matrixentry 0.71519 leven als Double in TPDFNumeric, Output formatteert ze via PLDoubleToStr met PDFPrecNum op vier decimalen, elke structurele check meldt het document ongewijzigd, en alleen de gerenderde vergelijking laat alle 35 headerafbeeldingen verschoven zien
De imagebytes waren onschuldig: TPDFNumeric herformatteerde de calibratiegetallen eromheen via PDFPrecNum, dus streamhashes en de fingerprint-diff meldden identieke revisies terwijl de renderer op elke pagina iets andere kleuren produceerde

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

Hoe PDFlibPas geparseerde decimale tekst bewaart in Delphi: de tokenizer in TPDFStructure.Decode bewaart het bronliteral in FOriginalText voor elk token met een decimale punt en zonder exponent, Output schrijft die tekst woordelijk uit in plaats van PLDoubleToStr aan te roepen, en SetTo wist hem omdat een bewerkt getal een nieuw getal is
De waarde die je decodeert en het literal dat je uitschrijft zijn twee verschillende dingen: de geparseerde tekst verkiezen houdt 2.22221 exact, terwijl door de library gemaakte en bewerkte getallen nog steeds PDFPrecNum volgen en de instelling onaangeroerde input nooit raakt
// 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

Waarom het contentmodel van PDFlibPas getallen toch normaliseert: NormalizeContentNumbers loopt waar de parser elke operand erop duwt en nog eens binnen SetOperand, recursiet door arrays en dictionaries zodat dashpatronen, TJ-arrays en property-dictionaries van marked content gedekt zijn, en SetTo AsDouble wist de originele tekst zodat 0.50000 en 0.5 identiek uitkomen
Canonieke numerieke operanden zijn de belofte van het contentmodel: ruwe inline-imagedata blijft met rust, en onaangeroerde dictionarygetallen buiten contentstreams houden de woordelijke garantie, dus een gewone LoadFromFile en SaveToFile bewaren ze nog steeds
// 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