Tehnički članak

Sačuvana preciznost decimala pri snimanju PDF-a u Delphi-ju

PDFlibPas, losLab PDF Developer Library, čuva tačan decimalni tekst koji je parsirao za svaki realan broj u dokumentu i upisuje taj tekst verbatim kada god vrednost nikada nije menjana. Od v3.539.19 podešavanje SetPrecision upravlja samo brojevima koje biblioteka kreira ili menja, pa obično učitavanje-i-čuvanje više ne zaokružuje CalRGB /Gamma od 2.22221 na 2.2222 i ne pomera boje stranice koju niko nije dirao. Promena je mala u kodu a velika u onome što govori o parserima: vrednost koju dekodirate i literal koji emitujete su dve različite stvari, a Double round trip nije identitetska transformacija

Zašto je čuvanje bez izmena pomerilo boje stranice?

Zato što su se preformatirali parametri color space-a, a ne slika. Fajl koji je to razotkrio je kancelarijski dokument od 35 stranica u lokalnom regresionom korpusu, sa header slikom koja se ponavlja na svakoj stranici. Njegovo učitavanje i čuvanje odmah nazad dalo je image stream-ove bajt za bajt identične ulazu, a poređenje hash-eva stream-ova prijavilo je dokument kao nepromenjen. Poređenje renderovanog izlaza se nije složilo: svaka od 35 stranica pokazivala je razlike u pikselima u header-u, i nigde drugde

Header slika se crta kroz CalRGB color space, koji ISO 32000-1 §8.6.5.3 definiše kroz /WhitePoint, opcioni /Gamma niz od tri elementa i opcioni /Matrix od devet elemenata. Ti nizovi su obični numerički objekti u rečniku color space-a. TPDFNumeric je svaki čuvao kao Double i ništa drugo, a TPDFNumeric.Output je formatirao taj Double kroz PDFPrecNum, koji podrazumevano ima četiri decimalna mesta. Tako je /Gamma otišao sa 2.22221 na 2.2222, unos matrice sa 0.71519 na 0.7152, a renderer je verno proizveo malo drugačije boje iz malo drugačije kalibracije. Bajtovi slike bili su nevini; brojevi oko njih nisu. Neprijatni deo je koliko je to bilo nevidljivo. Poređenje dekodiranih bajtova stream-a to ne može da vidi, jer brojevi žive u rečniku, a ne u stream-u. Poređenje payload-a priloga to ne može da vidi. Čak i diff revizija opisan u članku o nivoima izmena fingerprint-uje normalizovano telo objekta, pa obe revizije daju isti hash i diff ih prijavljuje kao identične. Samo je renderovanje to uhvatilo, i zato baseline korpusa renderuje svaku stranicu umesto da veruje samo strukturnim proverama

Gde je PDFlibPas izgubio CalRGB preciznost pri no-op snimanju u Delphi-ju: parsirani /Gamma 2.22221 i unos matrice 0.71519 žive u TPDFNumeric kao Double, Output ih formatira kroz PLDoubleToStr sa PDFPrecNum na četiri decimalna mesta, svaka strukturna provera prijavljuje dokument kao nepromenjen, a samo renderovano poređenje pokazuje da je svih 35 header slika pomereno
Bajtovi slike bili su nevini: TPDFNumeric je preformatirao kalibracione brojeve oko njih kroz PDFPrecNum, pa su i hash-evi stream-ova i fingerprint diff prijavljivali identične revizije dok je renderer davao malo drugačije boje na svakoj stranici

Vrednost koju ste parsirali nije literal koji treba da upišete

PDF realan broj je decimalni string, i ISO 32000-1 §7.3.3 je eksplicitan da je on samo decimalni string: bez radix notacije, bez eksponentnog oblika. Aneks C zatim navodi preciznost koju implementacija treba da poštuje, otprilike pet značajnih decimalnih cifara u frakcionom delu. Podrazumevana izlazna preciznost od četiri je već ispod toga, a blizu nule postaje gore: PLDoubleToStr skalira vrednost, zaokružuje na ceo broj i emituje 0 kada je rezultat nula, pa unos matrice -0.000012345 ne gubi cifru, nego nestaje u potpunosti

Podizanje podrazumevane vrednosti samo bi pomerilo liticu. Popravka je da se prestane sa pretvaranjem da je Double taj broj. Kada tokenizer u TPDFStructure.Decode prepozna standardni real, što znači da token sadrži decimalnu tačku i nema marker eksponenta, on smešta izvorni tekst u novo polje FOriginalText uporedo sa konvertovanom vrednošću. Output zatim preferira taj tekst i vraća se na formatiranje samo kada nema šta da preferira

Kako PDFlibPas čuva parsirani decimalni tekst u Delphi-ju: tokenizer u TPDFStructure.Decode drži izvorni literal u FOriginalText za svaki token sa decimalnom tačkom i bez eksponenta, Output upisuje taj tekst verbatim umesto da poziva PLDoubleToStr, SetTo ga briše jer je izmenjen broj novi broj
Vrednost koju dekodirate i literal koji emitujete su dve različite stvari: preferiranje parsiranog teksta čuva 2.22221 tačnim, dok brojevi koje je biblioteka kreirala i izmenila i dalje prate PDFPrecNum i podešavanje nikada ne dopire do netaknutog ulaza
// Lib/PDFlibStruct.pas — cela popravka na izlaznoj strani
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:= '';   // izmenjen broj je novi broj
  FValue:= Value;
  FChanged:= True;
End;

Dve granice su namerne. Celi brojevi se ne čuvaju, jer je formatiranje celih brojeva već bez gubitka. Eksponentni oblici kao 6.02E23 tolerišu se na ulazu zbog pokvarenih proizvođača, ali se ne čuvaju na izlazu, jer bi njihovo ponovno upisivanje održavalo sintaksu koju §7.3.3 zabranjuje; oni idu kroz formater kao svaki broj koji je biblioteka generisala. Tokenizer takođe primenjuje svoju uobičajenu minimalnu popravku pre nego što sačuva tekst, pa se literal koji počinje tačkom kao .5 čuva kao 0.5, a literal koji se tačkom završava kao 5. kao 5.0. Oba su isti broj za svakog čitača i daleko su šire prihvaćena

Šta SetPrecision garantuje posle v3.539.19?

TPDFlib.SetPrecision sada kontroliše decimalna mesta brojeva koje biblioteka sama proizvodi: vrednosti iscrtane kroz painter, brojeve kreirane iz Double kao kroz NewNumeric, i svaku parsiranu vrednost koja je od tada izmenjena sa SetTo. Primetite da tekst dekodiran kroz object API, na primer literal prosleđen u SetObjectFromString, prolazi kroz isti tokenizer i čuva se na isti način. Parsirana decimala koja nikada nije menjana zadržava svoju ulaznu preciznost bez obzira na podešavanje, a promena podešavanja posle učitavanja ne dopire do nje retroaktivno. Unos u referenci za SetPrecision ažuriran je u istom izdanju da kaže upravo to, jer je stara formulacija implicirala da se podešavanje primenjuje na svaki broj u fajlu

Brisanje se dešava u SetTo, a ne izvodi se iz flag-a Changed, i ta razlika je važna. Save pipeline resetuje Changed na objektima kada su jednom upisani, pa bi provera oblika „emituj izvorni tekst osim ako je izmenjen“ počela da emituje zastareo tekst za vrednost koja je izmenjena, sačuvana i ponovo izmenjena u istoj sesiji. Vezivanje izvornog teksta za samu dodelu čini nemogućim da se njih dvoje raziđu. Regresioni test prikiva svako od ovih ponašanja vrednostima iz originalnog fajla

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]'));
    // Neizmenjen ulaz preživljava verbatim, uključujući vrednost koju bi
    // formatiranje na četiri mesta srušilo na 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Izmena odbacuje izvorni tekst i prati PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Naknadno snižavanje preciznosti ne dopire do neizmenjenog ulaza
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Zašto content model i dalje normalizuje brojeve?

Zato što TPDFContentProgram obećava kanonske numeričke operande, i to obećanje vredi više od verbatim teksta unutar content stream-a. Model sadržaja koji se može uređivati, isti onaj na kojem je izgrađen tracker graphics-state-a, postoji da bi NormalizeContentStreams, optimizator i Emit proizvodili stabilan, uporediv izlaz iz proizvoljnog ulaza. Kada bi parsirani operand nosio svoj izvorni tekst u model, niz operatora kao 0.50000 0 0 RG emitovao bi se drugačije od 0.5 0 0 RG, i svako nizvodno poređenje odstupalo bi sa formaterskim navikama proizvođača

Zato model skida izvorni tekst na svoje dve ulazne tačke. NormalizeContentNumbers se izvršava nad svakim operandom kada ga parser potisne i ponovo unutar SetOperand kada se dekodira izvor koji je zadao pozivalac, i rekurzivno prolazi kroz nizove i rečnike tako da su dash pattern-i, TJ nizovi i property rečnici označenog sadržaja pokriveni. Poziv SetTo(AsDouble) na svakom numeriku je dovoljan, jer je to upravo operacija koja briše tekst. Sirovi inline image podaci ostaju netaknuti, kao i uvek

Zašto PDFlibPas content model i dalje normalizuje brojeve: NormalizeContentNumbers se izvršava tamo gde parser potisne svaki operand i ponovo unutar SetOperand, rekurzivno prolazi kroz nizove i rečnike pa su dash pattern-i, TJ nizovi i property rečnici označenog sadržaja pokriveni, a SetTo AsDouble briše izvorni tekst pa 0.50000 i 0.5 emituju identično
Kanonski numerički operandi su obećanje content model-a: sirovi inline image podaci ostaju netaknuti, a nedirani brojevi u rečnicima van content stream-ova zadržavaju verbatim garanciju, pa prost par LoadFromFile i SaveToFile i dalje čuva njih
// Lib/PDFlibContentModel.pas — content model zadržava svoj ugovor
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;

Praktično pravilo za pozivaoce je zato jednostavno. Prost LoadFromFile praćen SaveToFile ostavlja netaknute content stream-ove i netaknute brojeve u rečnicima onakvima kakvi su bili. Stranica koja prođe kroz NormalizeContentStreams, ili bilo koja izmena napravljena kroz content model, izlazi kanonska po dizajnu, a ostatak dokumenta je i dalje sačuvan. To su dva različita zahteva, i sada rade dve različite stvari

Šta to košta, i gde se garancija zaustavlja

Svaki TPDFNumeric sada nosi jednu dodatnu AnsiString referencu, i svaka parsirana decimala drži svoj izvorni tekst živim za celi životni vek objekta. Na dokumentu sa milionima realnih brojeva to je stvarna memorija, i to pripada svakom merenju velikih dokumenata umesto da se odmahne rukom. Garancija je takođe ograničena na dokument samog broja: kopiranje objekata između dokumenata ili rekonstrukcija vrednosti kroz object API proizvodi nove brojeve, koji prate izlaznu preciznost kao svaki drugi nov broj. Vredi biti precizan o tome šta izdanje tvrdi a šta ne. Učitavanje-i-čuvanje netaknutog dokumenta sada čuva kalibracione brojeve koje renderer stvarno troši, što je svojstvo koje baseline korpusa proverava. Ne tvrdi bajt identičan izlaz, koji takođe zavisi od numerisanja objekata, kompresije stream-ova i identifikatora u trailer-u o kojem se govori u članku o determinističkom PDF ID-u. I ne čini da fingerprint diff vidi razlike u zaokruživanju u fajlovima koje je proizveo drugi softver, jer se i oni hash-uju po normalizovanom telu. Pouka se generalizuje mnogo dalje od CalRGB-a: kada parser čuva samo konvertovanu vrednost, svako čuvanje je izmena, a jedini način da se to primeti je da se pogleda renderovani rezultat. Rukovanje brojevima i semantika SetPrecision dokumentovani su na stranici proizvoda losLab PDF Developer Library