Technický článek

Přesnost desetinných čísel PDF při uložení v Delphi

PDFlibPas, losLab PDF Developer Library, si pro každé reálné číslo v dokumentu pamatuje přesný desetinný text, který rozparsval, a zapisuje ten text slovo od slova zpátky, kdykoli hodnota nebyla nikdy změněna. Od v3.539.19 nastavení SetPrecision řídí jen čísla, která knihovna sama vytvoří nebo edituje, takže obyčejné load-and-save už nezaokrouhlí CalRGB /Gamma z 2.22221 na 2.2222 a neposune barvy stránky, které se nikdo nedotkl. Změna je malá v kódu a velká v tom, co říká o parserech: hodnota, kterou dekódujete, a literal, který emitujete, jsou dvě různé věci a Double round trip není identická transformace

Proč uložení, které nic nezměnilo, posunulo barvy stránky?

Protože se přeformátovávaly parametry barevného prostoru, ne obrázek. Soubor, který to odhalil, je 35stránkový kancelářský dokument v lokálním regresním korpusu, s hlavičkovým obrázkem znovu použitým na každé stránce. Načtení a přímé uložení zpátky vyprodukovalo image streamy bajt od bajtu identické se vstupem a srovnání hashů streamů hlásilo dokument beze změny. Vykreslené srovnání nesouhlasilo: každá ze 35 stránek ukazovala pixelové rozdíly v hlavičce a nikde jinde

Hlavičkový obrázek se kreslí přes CalRGB barevný prostor, který ISO 32000-1 §8.6.5.3 definuje /WhitePoint, nepovinným tříprvkovým polem /Gamma a nepovinným devítiprvkovým polem /Matrix. Ta pole jsou obyčejné numerické objekty ve slovníku barevného prostoru. TPDFNumeric je ukládal každé jako Double a nic víc a TPDFNumeric.Output formátoval ten Double přes PDFPrecNum, které defaultně dává čtyři desetinná místa. Takže /Gamma šla z 2.22221 na 2.2222, položka matice šla z 0.71519 na 0.7152 a renderer věrně vyprodukoval mírně odlišné barvy z mírně odlišné kalibrace. Bajty obrázku byly nevinné; čísla kolem nich nebyla. Nepříjemné na tom je, jak neviditelné to bylo. Srovnávání dekódovaných bajtů streamu to nevidí, protože čísla bydlí ve slovníku, ne ve streamu. Srovnávání payloadů příloh to nevidí. I revision diff popsaný v článku o modification levelu fingerprintuje normalizované tělo objektu, takže obě revize se hashují na stejnou hodnotu a diff je hlásí jako identické. Chytilo to jen vykreslení, proto korpusová baseline vykresluje každou stránku místo toho, aby věřila jen strukturálním kontrolám

Kde PDFlibPas ztratil CalRGB přesnost při no-op uložení v Delphi: rozparsvané /Gamma 2.22221 a položka matice 0.71519 bydlí v TPDFNumeric jako Double, Output je formátuje přes PLDoubleToStr s PDFPrecNum na čtyři desetinná místa, každá strukturální kontrola hlásí dokument beze změny a jen vykreslené srovnání ukazuje všech 35 posunutých hlavičkových obrázků
Bajty obrázku byly nevinné: TPDFNumeric přeformátoval kalibrační čísla kolem nich přes PDFPrecNum, takže hashe streamů i fingerprint diff hlásily identické revize, zatímco renderer na každé stránce produkoval mírně odlišné barvy

Hodnota, kterou jste rozparsvali, není literal, který máte zapsat

Reálné číslo v PDF je desetinný řetězec a ISO 32000-1 §7.3.3 výslovně říká, že je to jen desetinný řetězec: žádný zápis s radixem, žádný exponenciální tvar. Příloha C pak vyjmenovává přesnost, které se od implementace čeká, že ji bude respektovat, zhruba pět významných desetinných číslic v zlomkové části. Defaultní output precision čtyři je už pod tím a u nuly je to horší: PLDoubleToStr škáluje hodnotu, zaokrouhlí na celé číslo a emituje 0, když výsledek je nula, takže položka matice -0.000012345 neztratí číslici, zmizí úplně

Zvýšení defaultu by jen přesunulo útes. Oprava je přestat předstírat, že Double je to číslo. Když tokenizer v TPDFStructure.Decode pozná standardní reálné, což znamená, že token obsahuje desetinnou tečku a žádný exponent marker, uloží zdrojový text do nového pole FOriginalText vedle převedené hodnoty. Output pak dá přednost tomu textu a k formátování se uchýlí jen tehdy, když není co preferovat

Jak PDFlibPas zachovává rozparsvaný desetinný text v Delphi: tokenizer v TPDFStructure.Decode drží zdrojový literal v FOriginalText pro každý token s desetinnou tečkou a bez exponentu, Output zapisuje ten text slovo od slova místo volání PLDoubleToStr a SetTo ho maže, protože editované číslo je nové číslo
Hodnota, kterou dekódujete, a literal, který emitujete, jsou dvě různé věci: přednost rozparsvanému textu drží 2.22221 exaktní, zatímco knihovnou vytvořená a editovaná čísla nadále následují PDFPrecNum a nastavení se nedostane k nedotčenému vstupu
// Lib/PDFlibStruct.pas — celá oprava na výstupní straně
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:= '';   // editované číslo je nové číslo
  FValue:= Value;
  FChanged:= True;
End;

Dvě hranice jsou záměrné. Celá čísla se nezachovávají, protože formátování celých čísel už je bezztrátové. Exponenciální tvary jako 6.02E23 se na vstupu tolerují kvůli rozbitým producentům, ale na výstupu se nezachovávají, protože jejich zápis zpátky by udržoval při životě syntaxi, kterou §7.3.3 zakazuje; jdou formatterem jako jakékoli knihovnou generované číslo. Tokenizer před uložením textu navíc aplikuje svou obvyklou minimální opravu, takže literal s úvodní tečkou jako .5 se drží jako 0.5 a literal s koncovou tečkou jako 5. jako 5.0. Obojí je pro každého čtenáře totéž číslo a je přijímáno mnohem šířeji

Co garantuje SetPrecision po v3.539.19?

TPDFlib.SetPrecision teď řídí počet desetinných míst čísel, která knihovna sama produkuje: hodnoty kreslené přes painter, čísla vytvořená z Double, třeba přes NewNumeric, a jakoukoli rozparsvanou hodnotu, která byla od té doby editovaná přes SetTo. Všimněte si, že text dekódovaný přes object API, třeba literal předaný SetObjectFromString, jde stejným tokenizerem a zachovává se stejným způsobem. Rozparsvané desetinné, které nikdo nezměnil, si drží svou vstupní přesnost bez ohledu na nastavení a změna nastavení po načtení se k němu retroaktivně nedostane. Referenční položka SetPrecision byla ve stejném vydání aktualizována, aby říkala přesně tohle, protože staré znění naznačovalo, že nastavení platí pro každé číslo v souboru

Mazání se děje v SetTo místo aby se odvozovalo z příznaku Changed a tenhle rozdíl má význam. Ukládací pipeline resetuje Changed na objektech, jakmile byly zapsány, takže kontrola ve stylu „emituj originální text, pokud nezměněno" by začala emitovat zaostalý text pro hodnotu, která byla editovaná, uložená a v téže session editovaná znovu. Svázání originálního textu s přiřazením samotným znemožňuje, aby se v nich ta dva rozešla. Regresní test každé z těchto chování přibíje hodnotami z původního souboru

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]'));
    // Needitovaný vstup přežije slovo od slova, včetně hodnoty, kterou
    // by čtyřmístné formátování srovnalo na 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Editace zahodí originální text a následuje PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Následné snížení přesnosti se k needitovanému vstupu nedostane
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Proč content model pořád normalizuje čísla?

Protože TPDFContentProgram slibuje kanonické numerické operandy a tenhle slib stojí za víc než slovo od slova zachovaný text uvnitř content streamu. Editovatelný content model, tentýž, na kterém stojí graphics-state tracker, existuje proto, aby NormalizeContentStreams, optimalizér a Emit produkovaly stabilní, srovnatelný výstup z libovolného vstupu. Kdyby rozparsvaný operand nesl svůj originální text do modelu, operátorová sekvence jako 0.50000 0 0 RG by se emitovala jinak než 0.5 0 0 RG a každé downstream srovnání by se vleklo za formátovacími návyky producenta

Takže model shazuje originální text na svých dvou vstupních bodech. NormalizeContentNumbers běží na každém operandu, jak ho parser postrkuje dál, a znovu uvnitř SetOperand, když se dekóduje zdroj od volajícího, a rekurzuje polemi a slovníky, takže dash vzory, pole TJ a property slovníky marked contentu jsou pokryté. Zavolat SetTo(AsDouble) na každém numeriku stačí, protože to je přesně ta operace, která text maže. Surová inline image data se nechávají na pokoji, jak to bylo vždycky

Proč PDFlibPas content model pořád normalizuje čísla: NormalizeContentNumbers běží tam, kde parser postrkuje každý operand, a znovu uvnitř SetOperand, rekurzuje polemi a slovníky, takže dash vzory, pole TJ a property slovníky marked contentu jsou pokryté, a SetTo AsDouble maže originální text, takže 0.50000 a 0.5 emitují identicky
Kanonické numerické operandy jsou slib content modelu: surová inline image data se nechávají na pokoji a nedotčená čísla ve slovnících mimo content streamy si drží garanci slovo od slova, takže prostá dvojice LoadFromFile a SaveToFile je pořád zachová
// Lib/PDFlibContentModel.pas — content model drží svou smlouvu
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;

Praktické pravidlo pro volající je proto jednoduché. Prosté LoadFromFile následované SaveToFile nechá nedotčené content streamy a nedotčená čísla ve slovnících tak, jak byla. Stránka, která projde NormalizeContentStreams, nebo jakákoli editace udělaná přes content model, vychází kanonická ze své podstaty a zbytek dokumentu je pořád zachován. To jsou dva různé požadavky a teď dělají dvě různé věci

Co to stojí a kde garance končí

Každý TPDFNumeric teď nese jednu AnsiString referenci navíc a každé rozparsvané desetinné drží svůj zdrojový text naživu po celou životnost objektu. U dokumentu s miliony reálných čísel je to reálná paměť a patří do jakéhokoli měření na velkých dokumentech místo aby se odmávala. Garance se také vztahuje na číslovo vlastní dokument: kopírování objektů mezi dokumenty nebo rekonstrukce hodnot přes object API produkuje nová čísla, která následují output precision jako každé jiné nové číslo. Stojí za to být precizní v tom, co vydání tvrdí a netvrdí. Load-and-save nedotčeného dokumentu teď zachovává kalibrační čísla, která renderer doopravdy konzumuje, a to je vlastnost, kterou korpusová baseline kontroluje. Netvrdí bajt od bajtu identický výstup, na kterém kromě toho závisí číslování objektů, komprese streamů a trailer identifier rozebraný v článku o deterministickém PDF ID. A nedělá z fingerprint diffu věc, která vidí zaokrouhlovací rozdíly v souborech od jiného softwaru, protože ty pořád hashují normalizované tělo. Lekce se generalizuje dobře daleko za CalRGB: když parser drží jen převedenou hodnotu, každé uložení je editace a jediný způsob, jak si toho všimnout, je podívat se na vykreslený výsledek. Obsluha numeriků a sémantika SetPrecision jsou zdokumentované na produktové stránce losLab PDF Developer Library