Artykuł techniczny

Zachowanie precyzji dziesiętnej PDF przy zapisie w Delphi

PDFlibPas, czyli losLab PDF Developer Library, zachowuje dokładny tekst dziesiętny, który sparsował dla każdej liczby rzeczywistej w dokumencie, i zapisuje ten tekst dosłownie, gdy wartość nigdy nie była modyfikowana. Od wersji v3.539.19 ustawienie SetPrecision rządzi tylko liczbami, które biblioteka tworzy albo edytuje, więc zwykłe wczytanie i zapis nie zaokrągla już /Gamma w CalRGB z 2.22221 do 2.2222 ani nie przesuwa kolorów strony, której nikt nie dotykał. Zmiana jest niewielka w kodzie i duża w tym, co mówi o parserach: wartość, którą dekodujesz, i literał, który wypisujesz, to dwie różne rzeczy, a podróż w obie strony przez Double nie jest przekształceniem tożsamościowym

Dlaczego zapis, który niczego nie zmienił, przesunął kolory strony?

Bo przeformatowywane były parametry przestrzeni kolorów, a nie obraz. Plikiem, który to ujawnił, jest trzydziestopięciostronicowy dokument biurowy z lokalnego korpusu regresyjnego, z obrazem nagłówka powtarzanym na każdej stronie. Wczytanie go i zapisanie z powrotem dawało strumienie obrazów identyczne z wejściem co do bajtu, a porównanie hashy strumieni raportowało dokument jako niezmieniony. Porównanie renderów było innego zdania: każda z 35 stron pokazywała różnice pikseli w nagłówku i nigdzie indziej

Obraz nagłówka rysuje się przez przestrzeń kolorów CalRGB, którą ISO 32000-1 §8.6.5.3 definiuje przez /WhitePoint, opcjonalną trzyelementową tablicę /Gamma i opcjonalną dziewięcioelementową /Matrix. Te tablice to zwykłe obiekty numeryczne w słowniku przestrzeni kolorów. TPDFNumeric przechowywał każdą z nich jako Double i nic więcej, a TPDFNumeric.Output formatował ten Double przez PDFPrecNum, które domyślnie daje cztery miejsca po przecinku. Więc /Gamma szło z 2.22221 na 2.2222, wpis macierzy z 0.71519 na 0.7152, a renderer wiernie produkował trochę inne kolory z trochę innej kalibracji. Bajty obrazu były niewinne; liczby wokół nich już nie. Najbardziej niewygodne jest to, jak niewidoczne to było. Porównanie zdekodowanych bajtów strumieni tego nie widzi, bo liczby mieszkają w słowniku, a nie w strumieniu. Porównanie ładunków załączników tego nie widzi. Nawet porównanie rewizji opisane w artykule o poziomach modyfikacji bierze odcisk znormalizowanego ciała obiektu, więc obie rewizje hashują się do tej samej wartości i diff raportuje je jako identyczne. Złapało to tylko renderowanie i dlatego baseline korpusu renderuje każdą stronę, zamiast ufać samym sprawdzeniom strukturalnym

Gdzie PDFlibPas tracił precyzję CalRGB przy zapisie bez edycji w Delphi: sparsowane /Gamma 2.22221 i wpis macierzy 0.71519 mieszkają w TPDFNumeric jako Double, Output formatuje je przez PLDoubleToStr z PDFPrecNum na czterech miejscach po przecinku, każde sprawdzenie strukturalne raportuje dokument jako niezmieniony, a tylko porównanie renderów pokazuje przesunięte wszystkie 35 obrazów nagłówka
Bajty obrazu były niewinne: TPDFNumeric przeformatowywał liczby kalibracji wokół nich przez PDFPrecNum, więc hashe strumieni i diff odcisków raportowały identyczne rewizje, a renderer produkował trochę inne kolory na każdej stronie

Wartość, którą sparsowałeś, to nie literał, który powinieneś zapisać

Liczba rzeczywista w PDF to łańcuch dziesiętny i ISO 32000-1 §7.3.3 wyraźnie mówi, że tylko łańcuch dziesiętny: żadnej notacji pozycyjnej z inną podstawą, żadnej postaci wykładniczej. Annex C wymienia potem precyzję, którą implementacja powinna respektować, około pięciu znaczących cyfr dziesiętnych w części ułamkowej. Domyślna precyzja wyjściowa czterech miejsc jest już poniżej tego, a blisko zera jest jeszcze gorzej: PLDoubleToStr skaluje wartość, zaokrągla do liczby całkowitej i wypisuje 0, gdy wynik jest zerem, więc wpis macierzy -0.000012345 nie traci cyfry, on znika całkowicie

Podniesienie wartości domyślnej tylko przesunęłoby urwisko. Poprawka polega na tym, żeby przestać udawać, że liczbą jest Double. Kiedy tokenizer w TPDFStructure.Decode rozpozna standardową liczbę rzeczywistą, czyli token zawierający kropkę dziesiętną i żadnego znacznika wykładniczego, zapisuje tekst źródłowy w nowym polu FOriginalText obok przekonwertowanej wartości. Output preferuje wtedy ten tekst i sięga po formatowanie tylko wtedy, gdy nie ma czego preferować

Jak PDFlibPas zachowuje sparsowany tekst dziesiętny w Delphi: tokenizer w TPDFStructure.Decode trzyma literał źródłowy w FOriginalText dla każdego tokenu z kropką dziesiętną i bez wykładnika, Output zapisuje ten tekst dosłownie zamiast wołać PLDoubleToStr, a SetTo go czyści, bo edytowana liczba jest nową liczbą
Wartość, którą dekodujesz, i literał, który wypisujesz, to dwie różne rzeczy: preferowanie sparsowanego tekstu zachowuje 2.22221 dokładnie, a liczby utworzone i edytowane przez bibliotekę nadal słuchają PDFPrecNum, więc ustawienie nigdy nie sięga nietkniętego wejścia
// Lib/PDFlibStruct.pas — cała poprawka po stronie wyjścia
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:= '';   // edytowana liczba jest nową liczbą
  FValue:= Value;
  FChanged:= True;
End;

Dwie granice są celowe. Liczby całkowite nie są zachowywane, bo formatowanie liczb całkowitych jest już bezstratne. Postacie wykładnicze, takie jak 6.02E23, są tolerowane na wejściu ze względu na zepsutych producentów, ale nie są zachowywane na wyjściu, bo zapisanie ich z powrotem utrwalałoby składnię, której §7.3.3 zabrania; przechodzą przez formatter jak każda liczba wygenerowana przez bibliotekę. Tokenizer stosuje też przed zapisaniem tekstu swoją zwykłą minimalną naprawę, więc literał z wiodącą kropką, jak .5, jest trzymany jako 0.5, a literał z końcową kropką, jak 5., jako 5.0. Dla każdego czytnika to ta sama liczba, a przyjmowana jest znacznie szerzej

Co gwarantuje SetPrecision po v3.539.19?

TPDFlib.SetPrecision steruje teraz liczbą miejsc po przecinku w liczbach, które tworzy sama biblioteka: wartościami rysowanymi przez painter, liczbami tworzonymi z Double, na przykład przez NewNumeric, oraz każdą sparsowaną wartością, która została później edytowana przez SetTo. Zwróć uwagę, że tekst dekodowany przez API obiektowe, na przykład literał przekazany do SetObjectFromString, przechodzi przez ten sam tokenizer i jest zachowywany tak samo. Sparsowana liczba dziesiętna, której nigdy nie zmodyfikowano, zachowuje precyzję wejściową niezależnie od ustawienia, a zmiana ustawienia po wczytaniu nie sięga jej wstecz. Wpis referencyjny SetPrecision został zaktualizowany w tym samym wydaniu, żeby mówić dokładnie to, bo stare brzmienie sugerowało, że ustawienie dotyczy każdej liczby w pliku

Czyszczenie odbywa się w SetTo, a nie jest wyprowadzane z flagi Changed, i to rozróżnienie ma znaczenie. Potok zapisu resetuje Changed na obiektach po ich zapisaniu, więc sprawdzenie w stylu wypisuj tekst oryginalny, o ile nie zmieniono, zaczęłoby wypisywać nieaktualny tekst dla wartości, którą w tej samej sesji edytowano, zapisano i znowu edytowano. Powiązanie tekstu oryginalnego z samym przypisaniem czyni niemożliwym, żeby te dwie rzeczy się rozjechały. Test regresyjny przypina każde z tych zachowań wartościami z oryginalnego pliku

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]'));
    // Nieedytowane wejście przeżywa dosłownie, łącznie z wartością, którą
    // formatowanie do czterech miejsc zredukowałoby do 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Edycja odrzuca tekst oryginalny i słucha PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Obniżenie precyzji później nie sięga nieedytowanego wejścia
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Dlaczego model treści nadal normalizuje liczby?

Bo TPDFContentProgram obiecuje kanoniczne operandy numeryczne i ta obietnica jest warta więcej niż dosłowny tekst wewnątrz strumienia treści. Edytowalny model treści, ten sam, na którym zbudowany jest tracker stanu grafiki, istnieje po to, żeby NormalizeContentStreams, optymalizator i Emit produkowały stabilne, porównywalne wyjście z dowolnego wejścia. Gdyby sparsowany operand przenosił swój tekst oryginalny do modelu, sekwencja operatorów w rodzaju 0.50000 0 0 RG wypisywałaby się inaczej niż 0.5 0 0 RG, a każde porównanie w dół strumienia dryfowałoby razem z nawykami formatowania producenta

Model usuwa więc tekst oryginalny w dwóch swoich punktach wejścia. NormalizeContentNumbers biegnie na każdym operandzie, gdy parser go dokłada, i ponownie w SetOperand, gdy dekodowane jest źródło podane przez wywołującego, i rekurencyjnie przechodzi tablice oraz słowniki, dzięki czemu obejmuje wzory kreskowania, tablice TJ i słowniki właściwości treści oznaczonych. Wywołanie SetTo(AsDouble) na każdej liczbie wystarcza, bo to dokładnie ta operacja, która czyści tekst. Surowe dane obrazów inline zostają nietknięte, tak jak zawsze

Dlaczego model treści PDFlibPas nadal normalizuje liczby: NormalizeContentNumbers biegnie tam, gdzie parser dokłada każdy operand, i ponownie w SetOperand, rekurencyjnie przechodzi tablice i słowniki, obejmując wzory kreskowania, tablice TJ i słowniki właściwości treści oznaczonych, a SetTo AsDouble czyści tekst oryginalny, więc 0.50000 i 0.5 wypisują się identycznie
Kanoniczne operandy numeryczne to obietnica modelu treści: surowe dane obrazów inline zostają nietknięte, a nieedytowane liczby w słownikach poza strumieniami treści zachowują gwarancję dosłowności, więc zwykła para LoadFromFile i SaveToFile nadal je zachowuje
// Lib/PDFlibContentModel.pas — model treści trzyma swój kontrakt
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;

Praktyczna zasada dla wywołujących jest więc prosta. Zwykłe LoadFromFile, po którym następuje SaveToFile, zostawia nietknięte strumienie treści i nietknięte liczby w słownikach tak, jak były. Strona, która przejdzie przez NormalizeContentStreams, albo jakakolwiek edycja wykonana przez model treści, wychodzi kanoniczna z założenia, a reszta dokumentu jest nadal zachowana. To dwa różne żądania i teraz robią one dwie różne rzeczy

Ile to kosztuje i gdzie kończy się gwarancja

Każdy TPDFNumeric niesie teraz jedno dodatkowe odwołanie do AnsiString, a każda sparsowana liczba dziesiętna trzyma swój tekst źródłowy przy życiu przez cały czas życia obiektu. W dokumencie z milionami liczb rzeczywistych to realna pamięć i należy to uwzględnić w każdym pomiarze dużych dokumentów, a nie machnąć na to ręką. Gwarancja jest też ograniczona do dokumentu, do którego liczba należy: kopiowanie obiektów między dokumentami albo odtwarzanie wartości przez API obiektowe tworzy nowe liczby, które słuchają precyzji wyjściowej jak każda inna nowa liczba. Warto być precyzyjnym co do tego, co wydanie obiecuje, a czego nie. Wczytanie i zapis nietkniętego dokumentu zachowuje teraz liczby kalibracji, które renderer naprawdę konsumuje, i tę właśnie własność sprawdza baseline korpusu. Nie obiecuje wyjścia identycznego co do bajtu, bo to zależy też od numeracji obiektów, kompresji strumieni i identyfikatora w trailerze omówionego w artykule o deterministycznym ID pliku PDF. Nie sprawia też, że diff odcisków zobaczy różnice zaokrągleń w plikach wyprodukowanych przez inne oprogramowanie, bo te nadal hashują znormalizowane ciało. Wniosek uogólnia się dalece poza CalRGB: kiedy parser zachowuje wyłącznie przekonwertowaną wartość, każdy zapis jest edycją, a jedyny sposób, żeby to zauważyć, to spojrzeć na wynik renderowania. Obsługa liczb i semantyka SetPrecision są opisane na stronie produktu losLab PDF Developer Library