PDFlibPas, losLab PDF Developer Library for Delphi, zapisuje każdą liczbę, jaką wkłada do strumienia treści, z separatorem dziesiętnym kropką i bez wykładnika, cokolwiek mówią ustawienia regionalne Windows. Od v3.539.26 AddPageMatrix, ScalePage, DeskewPage, RedactRegion, wyjście text-to-path i rekolorowanie formatują argumenty przez PLDoubleToStrConst, a od v3.539.33 parsery czytające te liczby z powrotem używają PLTryStrToFloatInvariant zamiast systemowego locale. Na niemieckiej, francuskiej czy brazylijskiej maszynie ten sam kod produkuje teraz te same bajty co na amerykańskiej, a to jedyne zachowanie, jakie format plików może tolerować
Dlaczego locale z przecinkiem dziesiętnym psuje PDF bez błędu?
Locale z przecinkiem dziesiętnym psuje PDF po cichu, bo przecinek nie jest znakiem liczbowym w składni PDF, więc szkoda czyta się jako poprawne tokeny o złym znaczeniu. Przed poprawką PLFloatToStr było niczym innym jak gołym wywołaniem FloatToStr, a FloatToStr stosuje FormatSettings.DecimalSeparator. Przy separatorze przecinku AddPageMatrix(0.5, 0.5, 0, 0) pisało 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 dopuszcza w liczbie cyfry, jedną kropkę i wiodący znak i nic poza tym, więc parser treści czyta tę linię jako liczbę 0, po której następuje nieznany token ,5, a operator cm kończy ze złymi argumentami. Nic nie rzuca, nic nie loguje. Strona po prostu renderuje się z macierzą transformacji, która odjechała, a cofanie się od przesuniętego rysunku do ustawienia locale to nędzne popołudnie
Drugi defekt kryje się za pierwszym. FloatToStr używa formatu ffGeneral, który przełącza się na notację wykładniczą, gdy tylko wielkość spadnie poniżej 1E-4, więc malutki offset wychodził jako 1E-5. Ten sam §7.3.3 stwierdza, że PDF nie obsługuje postaci wykładniczej, czyli nawet maszyna z locale amerykańskim mogła napisać niepoprawny argument przy dostatecznie małej wartości. Testy regresji tego wydania przypinają oba kształty awarii: przestawiają separator na przecinek, wołają API i przeszukują powstałą treść za jakimkolwiek tokenem zawierającym przecinek albo wykładnik
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
OldSeparator: Char;
begin
Lib := TPDFlib.Create;
try
Lib.SetPageDimensions(300, 200);
Lib.DrawBox(10, 10, 20, 20, 1);
OldSeparator := FormatSettings.DecimalSeparator;
try
FormatSettings.DecimalSeparator := ','; // symulacja pulpitu de-DE
Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
// v3.539.26 i późniejsze piszą: 0.5 0 0 0.25 0.00001 12.75 cm
// starsze buildy pisały: 0,5 0 0 0,25 1E-5 12,75 cm
Writeln(Lib.GetPageContentToString);
finally
FormatSettings.DecimalSeparator := OldSeparator;
end;
finally
Lib.Free;
end;
end;
Dwa rodzaje liczb, dwie rodziny helperów
Poprawka w PDFlibPas to ostry podział: liczby pokazywane ludziom mogą iść za locale, a liczby pisane dla maszyny nigdy. PLFloatToStr i PLStrToFloat zostają w PDFlibExtra.pas dla tekstów kierowanych do użytkownika, a ich deklaracja nosi teraz komentarz mówiący dokładnie to. Wszystko, co kończy jako składnia PDF, przechodzi przez PLDoubleToStrConst ze stałą liczbą miejsc dziesiętnych dobraną do roboty: sześć dla macierzy, cztery dla współrzędnych i korekt TJ, trzy dla kolorów i prostokątów FDF. Audyt dla v3.539.26 dotknął więcej miejsc wywołań, niż sugerował oryginalny raport błędu:
AddPageMatrix,ScalePageiDeskewPage, które wszystkie dokleającmprzed istniejącą treść strony- Budowniczowie elementów strony emitujący resety
Tm, posuwTJi transformacjecm - Macierze pozycjonowania glifów i punkty konturu w konwerterze text-to-path
- Czarne wypełniane pudełko, które dokleja
RedactRegion, wartości/Rectw eksporcie FDF i argumenty zapisywane przy rekolorowaniu
PLDoubleToStrConst to formatter pisany ręcznie, a nie wrapper wokół FloatToStrF, i trzy jego własności mają tu znaczenie. Zawsze pisze kropkę i obcina końcowe zera, więc 0.5 zostaje 0.5, a nie 0.500000. Nigdy nie pisze wykładnika dla skończonego wejścia. I wartość niezerowa mniejsza od żądanej precyzji zachowuje swoje cyfry znaczące zamiast zapadać się w zero, więc PLDoubleToStrConst(1E-9, 6) zwraca 0.000000001; tylko wartości poniżej około 5E-16 stają się 0. Ta ostatnia reguła istnieje, bo zaokrąglenie malutkiego współczynnika skali do zera zamienia poprawną macierz w osobliwą, a to gorszy błąd niż naprawiany
uses
PDFlibExtra;
procedure CheckNumberHelpers;
var
V: Double;
begin
// Wyjście dla maszyny: kropka dziesiętna, bez wykładnika, końcowe zera obcięte
Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235'); // zachowuje 4 cyfry znaczące
Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');
// Wejście od maszyny: miękka porażka zamiast EConvertError
Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
Assert(not PLTryStrToFloatInvariant('0,5', V)); // liczby w treści nigdy nie używają przecinka
end;
Dlaczego strona parsowania jest groźniejsza niż strona zapisu?
Strona parsowania jest groźniejsza, bo parser przywiązany do locale nie produkuje złej liczby, tylko rzuca. PLStrToFloat woła StrToFloat, które podnosi EConvertError, gdy tekst nie pasuje do systemowego separatora. Na systemie z przecinkiem dziesiętnym oznaczało to, że RecolorPage przerywał w chwili, gdy spotykał zwykły operator 0.5 g, więc zawodziła każda prawdziwa strona, nie tylko egzotyczne. RenderPageRegionToFile odrzucał własny udokumentowany format clip "10.5,20.5,50.5,40.5", a atrybuty długości SVG, kolory eksportu SVG, listy wierzchołków adnotacji i wartości solidności output intent były albo odmawiane, albo po cichu zastępowane domyślnymi. Biblioteka działająca idealnie na maszynie dewelopera i padająca na pierwszym kliencie w Monachium to dokładnie ten rodzaj kodu, który, jak przypadki z artykułu o kodzie Delphi działającym przypadkiem, wygląda poprawnie tylko dzięki temu, gdzie był testowany
v3.539.33 sklasyfikowało każde wywołanie StrToFloat i TryStrToFloat według tego, skąd pochodzi jego wejście. Argumenty strumieni treści, atrybuty SVG, łańcuchy kolorów malarza i listy clip oraz wierzchołków rozdzielane przecinkami mają wszędzie stałą składnię z kropką, więc teraz przechodzą przez PLTryStrToFloatInvariant, które przycina tekst, parsuje go z PLInvariantFormatSettings i zwraca False dla pustego, zdeformowanego albo nieskończonego wejścia zamiast rzucać. Lista rozdzielana przecinkami nie zostawia miejsca na kompromis, bo przecinek nie może być jednocześnie delimiterem listy i znakiem dziesiętnym. Ten sam przebieg naprawił też zapis poza granice bufora: RenderPageRegionToFile trzymał kiedyś piątą wartość clip za czteroelementowym buforem. Dla potoku rekolorowania opisanego w przewodniku po konwersji PDF-a do jednej przestrzeni barw praktycznym skutkiem jest to, że RecolorPage i RecolorDocument nie przerywają już na systemie z przecinkiem dziesiętnym. Wartości reguł, które wywołujący wpisuje do CheckDocumentPolicy, to jedyny przypadek parsowania używający pobłażliwego helpera, z powodu, który wyjaśnia następna sekcja
Co się dzieje, gdy naprawisz tylko jeden koniec cyklu zapis-odczyt?
Naprawa tylko jednego końca cyklu zapis-odczyt łamie kod, który wcześniej działał, dlatego zmiana atrybutów struktury w v3.539.32 przesunęła writer i reader razem. Wrappery SetStructElem* przekazują liczby jako łańcuchy: SetStructElemBBox formatuje cztery wartości w jeden łańcuch, przechowuje go przez AddTagAttribute, a writer /A parsuje ten łańcuch później, żeby rozstrzygnąć, czy stanie się liczbą, tablicą czy nazwą. Oba końce używały systemowego locale, więc na systemie z przecinkiem cykl był samospójny. Błąd wychodził dopiero, gdy wywołujący posłuchał dokumentacji i podał "0.5" do AddTagAttribute: reader nie umiał tego sparsować i emitował nazwę PDF /0.5. Placeholder PDF/VCR miał problem lustrzany, bo biblioteka generowała GTS_BBox z kropką, a potem walidowała go z locale przed zapisem
Zmiana samego writera na kropkę byłaby gorsza niż nierobienie niczego, bo każda wartość SetStructElem* wywaliłaby wtedy na readerze przywiązanym do locale i zdegradowałaby do nazwy. Pisarze używają teraz PLDoubleToStrConst(v, 6), a reader nowego PLTryStrToFloatLenient, które próbuje najpierw postaci z kropką, a w razie czego wraca do systemowego locale. Wywołujący z locale przecinkowym, który podał kiedyś "1,25", nadal dostaje liczbę 1.25. Kompromis jest zamierzony i udokumentowany: na niemieckim systemie "1.500" stawało się kiedyś nazwą, bo StrToFloat odrzuca separatory tysięcy, a teraz czyta się jako 1.5, za to literalne łańcuchy NAN i INF nie są już przyjmowane jako liczby
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
begin
FormatSettings.DecimalSeparator := ','; // wywołujący z przecinkiem dziesiętnym
Lib := TPDFlib.Create;
try
Lib.BeginTag('Figure', 'Sales chart', '');
Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5'); // /SpaceAfter 0.5, wcześniej /0.5
Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // nadal /StartIndent 1.25
Lib.DrawText(20, 20, 'figure');
Lib.EndTag;
Lib.SaveToFile('tagged.pdf');
finally
Lib.Free;
end;
end;
Gdzie NaN i nieskończoność zostają zatrzymane
AddPageMatrix, ScalePage i RedactRegion odrzucają teraz NaN i nieskończone argumenty z góry i zwracają 0, bo żadna liczba PDF nie umie ich przedstawić. ScalePage odmawiało już współczynników zero lub mniejszych, ale NaN przechodzi przez test <= 0, więc skala NaN podążała kiedyś całą drogę do formattera. W v3.539.26 formatter wciąż wołał Round na NaN, co podnosi EInvalidOp na Win32, gdzie jednostka x87 nie maskuje operacji niepoprawnych; v3.539.31 kazało PLDoubleToStrConst pisać 0 dla NaN jako ostatnią linię obrony, ale zero w macierzy to transformacja osobliwa, więc prawdziwą poprawką pozostaje sprawdzenie na poziomie API. Dwie granice zostają z premedytacją. Łańcuchy stanu metafile są pisane i czytane z locale wewnątrz jednego procesu i nigdy go nie opuszczają, więc zostawiono je w spokoju. A test formatujący 1E-5 ścieżką elementów strony musi przeczytać treść, zanim warstwa zostanie przepisana, bo ponowna emisja argumentów w precyzji dokumentu legalnie zamienia tę wartość w 0
Jeśli twoja aplikacja trafia do klientów poza światem kropek dziesiętnych, najbezpieczniejszym nawykiem jest ten, którego używa teraz zestaw testów PDFlibPas: przepuść ścieżki produkujące PDF raz z FormatSettings.DecimalSeparator ustawionym na przecinek i przeskanuj wyjście za przecinkami i wykładnikami. Artykuł o zachowywaniu sparsowanej precyzji dziesiętnej pokrywa drugą połowę tej samej historii, czyli jak liczby przeczytane z istniejącego pliku zachowują swój dokładny tekst przy zapisie. Pliki do pobrania, pełna referencja API i build próbny są na stronie produktu PDFlibPas Delphi PDF library