Artykuł techniczny

Liczby PDF niezależne od locale w Delphi z przecinkiem

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;
AddPageMatrix w PDFlibPas na pulpicie z przecinkiem dziesiętnym pisało przed poprawką 0,5 0 0 0,25 1E-5 12,75 cm, co parser PDF czyta jako liczbę 0 plus nieznane tokeny, zostawiając cm ze złymi argumentami i stroną po cichu przetransformowaną, podczas gdy PLDoubleToStrConst pisze poprawne dziesiętne z kropką
Przecinek nie jest znakiem liczbowym w składni PDF, więc szkoda czyta się jako poprawne tokeny o złym znaczeniu — a wykładniki ffGeneral jak 1E-5 były niepoprawne w każdym locale, nie tylko przecinkowych

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, ScalePage i DeskewPage, które wszystkie dokleają cm przed istniejącą treść strony
  • Budowniczowie elementów strony emitujący resety Tm, posuw TJ i transformacje cm
  • Macierze pozycjonowania glifów i punkty konturu w konwerterze text-to-path
  • Czarne wypełniane pudełko, które dokleja RedactRegion, wartości /Rect w 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

PDFlibPas dzieli formatowanie liczb na dwa: PLFloatToStr i PLStrToFloat zostają przy locale dla tekstów dla użytkownika, podczas gdy PLDoubleToStrConst i PLTryStrToFloatInvariant formatują wszystko, co staje się składnią PDF, z kropką, bez wykładnika i ze stałą precyzją sześciu, czterech albo trzech miejsc dziesiętnych dobraną do roboty
Niezmienny formatter jest pisany ręcznie z premedytacją: obcina końcowe zera, nigdy nie pisze wykładnika i zachowuje cyfry znaczące malutkich wartości, bo zaokrąglenie współczynnika skali do zera uczyniłoby poprawną macierz osobliwą
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

SetStructElemBBox w PDFlibPas i jego rodzeństwo przekazują liczby jako łańcuchy przez AddTagAttribute, a writer /A parsuje te łańcuchy z powrotem, więc v3.539.32 przesunęło oba końce razem: PLDoubleToStrConst pisze z kropką, a PLTryStrToFloatLenient czyta najpierw kropkę z fallbackiem na locale, więc przecinkowe 1,25 nadal czyta się jako 1.25
Naprawa samego writera zdegradowałaby każdy atrybut elementu struktury do nazwy PDF, dlatego cykl zapis-odczyt przesuwa oba końce razem albo wcale
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