Każda liczba rzeczywista, która trafia do pliku PDF, musi być zapisana z kropką, niezależnie od tego, co system operacyjny uważa za separator dziesiętny. HotPDF Delphi Component kieruje wszystkie przez jedną warstwę niezmiennej konwersji w HPDFTypes, więc niemieckie, francuskie albo brazylijskie stanowisko emituje 0.5 0 0 0.5 5.5 7.25 cm, a nie wariant z przecinkiem, którego żaden zgodny czytelnik nie sparsuje
To brzmi jak poprawka w dwóch linijkach. Nie była. Defekt przez lata maskował efekt uboczny, pojawiał się ponownie w trzech podsystemach, których nikt nie kojarzy z kodem formatowania liczb, znikał pod jednym kompilatorem, a przeżywał pod innym, a jego ostatnia warstwa nie miała z separatorem dziesiętnym nic wspólnego
Dlaczego locale z przecinkiem dziesiętnym psuje strumień treści PDF?
Bo ISO 32000-1 §7.3.3 definiuje obiekt numeryczny jako opcjonalny znak, cyfry dziesiętne i co najwyżej jedną kropkę. Przecinek nie jest częścią tej produkcji, a w myśl §7.2.2 nie jest też separatorem, więc czytelnik nie widzi dwóch operandów tam, gdzie chodziło ci o jeden. Widzi token, który nie przechodzi leksowania, a operator, który go zawierał, jest odrzucany. Pojedyncze 0,5 w macierzy stanu grafiki wywala cały otaczający blok q ... Q. Porażka ma też charakterystyczny kształt w terenie: nigdy nie odtwarza się na maszynie dewelopera, przychodzi jako ticket wsparcia z jednego kraju i wygląda jak bug renderowania, a nie błąd składni, bo większość przeglądarek po cichu upuszcza zniekształcony operator i renderuje resztę strony
// Proces hosta działający z DecimalSeparator = ','
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);
// Co dołączone /Contents kiedyś niosło:
// 0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
// q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3 nie ma produkcji dla "0,5", więc prostokąt
// redakcji i zszywana strona oba znikają bez błędu
Efekt uboczny, który maskował defekt przez lata
HotPDF zapisuje większość liczb na ścieżce tworzenia przez _CutFloat, a wczesne rewizje tej funkcji robiły coś po cichu wrogiego: przy każdym pojedynczym wywołaniu przypisywały '.' do globalnego FormatSettings.DecimalSeparator procesu hosta. To psuje stan locale aplikacji hostującej komponent i nie jest thread safe, ale ciekawą konsekwencją jest trzecia. Gdy raz wygenerowano jakąkolwiek treść, separator procesu był kropką, więc każde gołe FloatToStr i StrToFloat gdzie indziej w bibliotece stawało się przypadkowo poprawne. Kolejność create-then-edit, której używa prawie każdy przykład, przechodziła więc, a to, co maskowała, to każda ścieżka działająca przed pierwszym wywołaniem _CutFloat: edytory załadowanego dokumentu StitchLoadedPage i RedactLoadedRect, stemple tekstowe i obrazowe, złożone znaki wodne, rysowanie kodów kreskowych przez Format('%.2f'), parser domyślnego wyglądu /DA i cały podsystem fontów Type 1
Parsowanie Type 1 padało najbrzydziej ze wszystkich. FontMatrix, BlueValues, stem hints i liczby rzeczywiste wewnątrz charstringów Type 2 to reale PostScript zapisywane z kropkami, zgodnie ze specyfikacją Adobe Type 1 Font Format. Parsowanie 0.001 w locale z przecinkiem rzuca EConvertError, otaczające try/except go połykało, a macierz fontu zostawała na zerze. Każdy glif z tego fontu potem się zapadał, co wygląda jak problem z fontem, a nie z locale, i wysyła cię na popołudnie debugowania renderowania osadzonych glifów
Jedna warstwa konwersji zamiast rozrzuconych poprawek
Reguła, którą HotPDF teraz stosuje, jest prosta: każda liczba zapisywana do albo czytana ze składni PDF, PostScript, JSON lub JavaScript przechodzi przez jawną niezmienną konwersję, a żaden kod nigdzie nie polega na globalnym efekcie ubocznym FormatSettings. HPDFTypes jest właścicielem czterech konwersji, plus wspólnego rekordu ustawień i jednego helpera o stałej precyzji dla ścieżki kodów kreskowych
// Lib/HPDFTypes.pas - pojedyncza powierzchnia niezmiennej konwersji
function HPDFInvariantFormatSettings: TFormatSettings;
function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
const Default: Extended): Extended;
function HPDFFormatInvariant2(Value: Extended): string;
Dwie jednostki już niosły własną prywatną funkcję InvariantFormatSettings, HPDFContentStream i HPDFTextExtraction, i obie zostały zredukowane do jednolinijkowego forwarda zamiast zostać jako równoległe implementacje. Podsystem Type 1 to świadomy wyjątek: nie niesie zależności VCL i nie odwołuje się do HPDFTypes, więc trzyma lokalne lustro nazwane HPDFType1Core.Type1StrToFloat z identyczną semantyką, w teorii, że zduplikowana dziesięciolinijkowa funkcja to mniejszy ciężar niż wciąganie warstwy graficznej do parsera fontów. Dwa inne miejsca zostawiono celowo. HPDFHTMLImport parsuje długości CSS, celowo zamieniając kropkę na separator locale, a potem parsując z locale, co jest poprawne dla tego wejścia, a HPDFType1CB.GlyphCommands razem ze stringami FontBBox to publiczne API, które wywołujący czytają z powrotem własnym StrToFloat, gdzie produkcja i konsumpcja w tym samym locale jest samospójna, a niezmienne wyjście złamałoby każdego wywołującego w regionie z przecinkiem
Co HPDF_HAS_FORMAT_SETTINGS gwarantuje między kompilatorami?
Gwarantuje, że niezmienna ścieżka faktycznie kompiluje się w każdym wspieranym toolchainie, czego oczywista bramka wersji nie robiła. Delphi XE2 wprowadziło TFormatSettings.Create oraz przeciążenia FloatToStr / TryStrToFloat przyjmujące TFormatSettings, więc pierwszym odruchem jest napisać {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} przy każdym miejscu wywołania. Ten odruch ma ślepą plamkę: HotPDF.inc definiuje D2009+ pod Free Pascalem, ale nigdy nie definiuje XE2+, bo XE2+ odpowiada na pytanie o numery wersji Delphi, podczas gdy Free Pascal 3.2.2 dostarcza te przeciążenia tak czy inaczej. Każda z tych bramek degradowała się więc do gałęzi zależnej od locale w buildach FPC, po cichu, dokładnie na platformach, z których pochodziły oryginalne zgłoszenia. HPDF_HAS_FORMAT_SETTINGS jest zdefiniowany zarówno w bloku FPC, jak i w bloku XE2+ pliku HotPDF.inc, i to jedyny symbol testowany przez helpery liczb
{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
Result := DefaultFormatSettings; // brak TFormatSettings.Create w FPC
{$ELSE}
Result := TFormatSettings.Create;
{$ENDIF}
Result.DecimalSeparator := '.';
Result.ThousandSeparator := ',';
end;
{$ENDIF}
Jedna gałąź kompilatora przetrwała wewnątrz funkcji, bo Free Pascal nie ma TFormatSettings.Create i trzeba zacząć od DefaultFormatSettings. Tylko dwa separatory liczb zasilają konwersje, więc pola daty i waluty dostają cokolwiek dostarcza aktualny locale. Pre-XE2 Delphi nie ma żadnych przeciążeń, i tam helpery wracają do konwersji z locale procesu, a potem przepisują znak separatora w wynikowym stringu, co jest brzydkie, ale nigdy nie dotyka stanu globalnego
Trzecia warstwa: wykładniki, które SVG dopuszcza, a PDF nie
Naprawienie separatora dziesiętnego jest konieczne i niewystarczające. W3C SVG 1.1 §4.2 dopuszcza wykładnik w swojej produkcji liczb i markup XPS robi to samo, więc 1E-6 jest w pełni poprawną współrzędną w drodze do środka, podczas gdy ISO 32000-1 §7.3.3 nie dopuszcza żadnego wykładnika. Wartość, która przeżyje niezmienne formatowanie, może nadal wylądować w strumieniu treści jako 1E-6 i zostać odrzucona, a FloatToStrF w formacie stałym może sam wrócić do formy wykładniczej, gdy wartość wyjdzie poza zakres cyfr RTL. HPDFSafeSVG.SVGFloat robi więc własną robotę po niezmiennej konwersji: zeruje wszystko poniżej 1.0E-7, a potem, jeśli E przeżyje, zdejmuje wykładnik i ręcznie przesuwa kropkę dziesiętną, dopełniając zerami z tej strony, której wymaga przesunięcie. SVGFinite pilnuje wejścia, odrzucając NaN i wszystko poza ±1.0E30, zanim dotrze do formatera, a SVGReadNumber z SVGSkipSeparators obsługuje kierunek wejściowy znak po znaku zamiast zdać się na RTL
// Lib/HPDFSafeSVG.pas - notacja dziesiętna dla operandów PDF
if Abs(Value) < 0.0000001 then
Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
SetLength(S, ExponentAt - 1);
// ... przesuń kropkę dziesiętną o ExponentValue, dopełniając '0'
// z lewej albo prawej, a potem dołóż z powrotem znak
end;
To ten sam TSVGBuffer, który zasila eksport strony do SVG i obsługę stanu grafiki, a ta sama dyscyplina liczb obowiązuje po stronie importu, gdy konwertujesz dokumenty XPS i OXPS do PDF przez HPDFCreateXPSDocumentHandler i człon hdfXPS rekordu THPDFHandledDocumentFormat. Formaty markup są hojne co do składni liczb. Strumień treści PDF nie jest, i to na granicy konwersji trzeba rozwiązać tę różnicę
Testy regresji, które odtwarzają locale zamiast go zakładać
Bug locale, który pojawia się tylko na czyimś cudzym komputerze, warto przypiąć testami, które same ustawiają locale, i HotPDF ma dwa takie. LoadedEditsEmitInvariantNumbersUnderCommaLocale przypisuje przecinek do FormatSettings.DecimalSeparator przed pierwszą edycją, odpala RedactLoadedRect, StampLoadedPageText, StitchLoadedPage i złożony stempel, potem czyta z powrotem bajty treści strony i asertuje zarówno obecność dokładnych stringów operatorów, jak i to, że bufor nie zawiera nigdzie przecinka. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale pokrywa drugą połowę i pierwotny grzech: prowadzi ścieżkę tworzenia w tym samym locale z przecinkiem, włącznie z DirectDrawBarcode z jego stałym formatowaniem dwóch miejsc po przecinku, a potem asertuje, że strumień treści nie niesie przecinka i że FormatSettings.DecimalSeparator jest nadal przecinkiem, gdy wraca EndDoc
Ta druga asercja to ta, która złapałaby oryginalny defekt w dniu, w którym powstał, bo testuje to, co biblioteka robi ze swoim hostem, a nie tylko to, co zapisuje na dysk. Lekcja generalizuje się poza PDF: każdy format ze stałą gramatyką leksykalną, PostScript, JSON i JavaScript włącznie, potrzebuje jawnej niezmiennej konwersji na granicy. Sięganie po globalne ustawienie, żeby dostać poprawną odpowiedź, działa, dopóki ktoś nie zawoła twojego kodu w innej kolejności, na innym wątku albo z innego kompilatora, a wtedy porażka wygląda jak wszystko, tylko nie problem z formatowaniem
Opisana tu warstwa formatowania liczb jest częścią HotPDF Delphi Component dla Delphi i C++Builder, razem ze ścieżkami edycji załadowanego dokumentu, kodów kreskowych, eksportu SVG i konwersji XPS, które na niej polegają