Technický článek

Invariantní formátování čísel PDF v Delphi s HotPDF

Každé reálné číslo, které se dostane do PDF souboru, musí být zapsané s tečkou, ať si operační systém myslí o oddělovači desetinných míst cokoli. HotPDF Delphi Component je všechny posílá přes jednu invariantní konverzní vrstvu v HPDFTypes, takže německá, francouzská nebo brazilská pracovní stanice emituje 0.5 0 0 0.5 5.5 7.25 cm a ne variantu s čárkou, kterou žádný shodný čtenář neparsuje

Zní to jako oprava na dva řádky. Nebyla. Vadu roky maskoval vedlejší efekt, znovu se objevila ve třech subsystémech, které nikdo nepovažuje za kód pro formátování čísel, zmizela pod jedním kompilátorem a přežila pod jiným, a její poslední vrstva neměla s oddělovačem desetinných míst vůbec nic společného

Proč locale s desetinnou čárkou poškozuje PDF content stream?

Protože ISO 32000-1 §7.3.3 definuje číselný objekt jako volitelné znaménko, desetinné číslice a nejvýš jednu tečku. Čárka není součástí té produkce a podle §7.2.2 není ani oddělovač, takže čtečka nevidí dva operandy tam, kde jste mysleli jeden. Vidí token, který se nelexuje, a operátor, který ho obsahoval, je zahozen. Jediné 0,5 v matici grafického stavu vezme celý blok q ... Q kolem sebe. Selhání má v terénu i charakteristický tvar: nikdy se nereprodukuje na vývojářském stroji, dorazí jako support ticket z jedné země a vypadá jako vykreslovací bug, ne jako syntaktická chyba, protože většina prohlížečů vadný operátor potichu zahodí a zbytek stránky vykreslí

Diagram poškození locale v PDF content streamu vyrobeném HotPDF: hostitelský proces s desetinnou čárkou emituje 0,5 uvnitř operátorů rg a cm, gramatika čísel ISO 32000-1 přijímá jen tečku, takže se token nelexuje, a prohlížeč potichu zahodí operátory spolu s blokem q Q kolem nich
Čárka není součástí číselné produkce a není oddělovač, takže jediný vadný operand odstraní redakční obdélník i sešitou stránku bez jakékoli chyby
// Hostitelský proces běžící s 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 připojený /Contents dřív nesl:
//   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 nemá produkci pro "0,5", takže redakční
// obdélník i sešitá stránka obě zmizí bez chyby

Vedlejší efekt, který vadu léta skrýval

HotPDF píše většinu čísel na cestě vytváření přes _CutFloat a rané revize té funkce dělaly něco potichu nepřátelského: při každém volání přiřadily '.' do globálního FormatSettings.DecimalSeparator hostitelského procesu. To poškozuje stav locale aplikace hostující komponentu a není to thread safe, ale zajímavý důsledek je ten třetí. Jakmile se vygeneroval nějaký obsah, byl oddělovač procesu tečka, takže každé holé FloatToStr a StrToFloat jinde v knihovně se náhodou stalo správným. Pořadí nejdřív vytvoř a pak uprav, které používá skoro každý příklad, proto prošlo, a to, co maskovalo, byla každá cesta běžící před prvním voláním _CutFloat: editory načteného dokumentu StitchLoadedPage a RedactLoadedRect, textová a obrazová razítka, kompozitní vodoznaky, kreslení čárových kódů přes Format('%.2f'), parser výchozího vzhledu /DA a celý subsystém Type 1 fontů

Časová osa vedlejšího efektu _CutFloat v HotPDF: první volání přiřadí tečku do globálního FormatSettings.DecimalSeparator, což každé pozdější holé FloatToStr náhodou udělá správným, zatímco načtené editory StitchLoadedPage a RedactLoadedRect, cesta čárových kódů, parser /DA a subsystém Type 1 fontů zůstávají rozbité
Pořadí nejdřív vytvoř a pak uprav, které používá každý příklad, drželo vadu neviditelnou, protože všechno po první konverzi zdědilo přepnutý globální oddělovač

Parsování Type 1 selhávalo tím nejošklivějším způsobem ze všech. FontMatrix, BlueValues, stem hinty a reálná čísla uvnitř Type 2 charstringů jsou PostScript reálná čísla zapsaná s tečkami podle specifikace Adobe Type 1 Font Format. Parsování 0.001 pod locale s čárkou vyhazuje EConvertError, okolní try/except ho spolklo a fontová matice zůstala na nule. Pak se zhroutil každý glyf z toho fontu, což vypadá jako problém fontu, ne locale, a pošle vás na odpoledne debugovat vykreslování vložených glyfů

Jedna konverzní vrstva místo roztroušených záplat

Pravidlo, které HotPDF teď dodržuje, je strohé: každé číslo zapsané do nebo přečtené z PDF, PostScriptu, JSON nebo JavaScript syntaxe prochází explicitní invariantní konverzí a žádný kód nikde nespoléhá na globální vedlejší efekt FormatSettings. HPDFTypes vlastní čtyři konverze, plus záznam nastavení, který sdílejí, a jeden pomocník s pevnou přesností pro cestu čárových kódů

// Lib/HPDFTypes.pas - jediná invariantní konverzní plocha
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;

Dva unity už měly vlastní soukromou funkci InvariantFormatSettings, HPDFContentStream a HPDFTextExtraction, a obě byly zredukovány na jednořádkový forward, místo aby zůstaly jako paralelní implementace. Subsystém Type 1 je záměrná výjimka: nemá žádnou závislost na VCL a neodkazuje na HPDFTypes, takže si drží lokální zrcadlo jménem HPDFType1Core.Type1StrToFloat se stejnou sémantikou, na teorii, že zduplikovaná desetiřádková funkce je menší závazek než vléci grafickou vrstvu do fontového parseru. Dvě další místa byla záměrně nechána na pokoji. HPDFHTMLImport parsuje CSS délky záměrnou výměnou tečky za locale oddělovač a pak parsuje s locale, což je pro ten vstup správné, a HPDFType1CB.GlyphCommands spolu s řetězci FontBBox je veřejné API, které si volající čtou zpět vlastním StrToFloat, kde produkce a konzumace ve stejném locale je sebepřítomně konzistentní a invariantní výstup by rozbil každého volajícího v čárkovém regionu

Co HPDF_HAS_FORMAT_SETTINGS zaručuje napříč kompilátory?

Zaručuje, že se invariantní cesta skutečně zkompiluje v každém podporovaném toolchainu, což ten očividný verzní přepínač nedělal. Delphi XE2 zavedlo TFormatSettings.Create a přetížení FloatToStr / TryStrToFloat, která berou TFormatSettings, takže první instinkt je napsat {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} na každém místě volání. Ten instinkt má slepé místo: HotPDF.inc definuje D2009+ pod Free Pascalem, ale nikdy nedefinuje XE2+, protože XE2+ odpovídá na otázku o verzích Delphi, zatímco Free Pascal 3.2.2 tyto přetížení dodává tak jako tak. Každá z těch bran se proto v FPC builtech potichu zvrhla do větve závislé na locale, přesně na platformách, ze kterých původní hlášení pocházela. HPDF_HAS_FORMAT_SETTINGS je definovaný v FPC bloku i v bloku XE2+ souboru HotPDF.inc a je to jediný symbol, který číselné pomocníky testují

{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
  Result := DefaultFormatSettings;   // v FPC žádné TFormatSettings.Create není
{$ELSE}
  Result := TFormatSettings.Create;
{$ENDIF}
  Result.DecimalSeparator := '.';
  Result.ThousandSeparator := ',';
end;
{$ENDIF}

Uvnitř funkce přežije jedna větev kompilátoru, protože Free Pascal žádné TFormatSettings.Create nemá a musíte začít od DefaultFormatSettings. Do konverzí vstupují jen dva číselné oddělovače, takže pole data a měny si drží, co dodává aktuální locale. Delphi před XE2 nemá žádná přetížení a tam pomocníci spadnou zpět na konverzi s locale procesu a pak přepíšou znak oddělovače ve výsledném řetězci, což je ošklivé, ale nikdy se to nedotkne globálního stavu

Třetí vrstva: exponenty, které SVG dovoluje a PDF ne

Opravit oddělovač desetinných míst je nutné a nestačí. W3C SVG 1.1 §4.2 připouští exponent ve své produkci čísel a markup XPS dělá totéž, takže 1E-6 je na cestě dovnitř naprosto platná souřadnice, zatímco ISO 32000-1 §7.3.3 nepřipouští žádný exponent. Hodnota, která přežije invariantní formátování, může přesto přistát v content streamu jako 1E-6 a být odmítnuta, a FloatToStrF v pevném formátu může sám spadnout zpět na exponentovou podobu, jakmile hodnota opustí digitální rozsah RTL. HPDFSafeSVG.SVGFloat proto dělá svou vlastní práci po invariantní konverzi: cokoli pod 1.0E-7 srovná na nulu, pak, pokud E přežije, odstraní exponent a posune desetinnou tečku ručně, doplňuje nulami na tu stranu, kterou posun vyžaduje. SVGFinite hlídá vchod odmítnutím NaN a čehokoli mimo ±1.0E30, než se to dostane k formátovači, a SVGReadNumber s SVGSkipSeparators zpracovává směr dovnitř znak po znaku, místo aby to přenechával RTL

Pipeline ukazující, jak HotPDF odstraňuje exponenty před zápisem PDF operandů: SVGFinite odmítá NaN a hodnoty přes 1.0E30, invariantní konverze nechá tečku, malé velikosti se srovnají na nulu a výstup HPDFFloatToInvariantStr s přeživším E má odstraněný exponent a ručně posunutou desetinnou tečku
SVG 1.1 exponent připouští a ISO 32000-1 ne, takže konverzní hranice přepíše 1E-6 na obyčejný desetinný zápis, který shodný čtenář dokáže parsovat
// Lib/HPDFSafeSVG.pas - desetinný zápis pro PDF operandy
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);
  // ... posuňte desetinnou tečku o ExponentValue, doplňte '0'
  // zleva nebo zprava a pak vraťte znaménko
end;

To je stejný TSVGBuffer, který krmí export stránky do SVG a jeho práci s grafickým stavem, a stejná číselná disciplína platí na straně importu při převodu XPS a OXPS dokumentů na PDF přes HPDFCreateXPSDocumentHandler a člen hdfXPS z THPDFHandledDocumentFormat. Markup formáty jsou v syntaxi čísel štědré. PDF content stream není a konverzní hranice je místo, kde se ten rozdíl musí vyřešit

Regresní testy, které locale reprodukují, místo aby ho předpokládaly

Locale bug, který se objeví jen na cizím stroji, stojí za přibíjení testy, které locale nastaví samy, a HotPDF jich nese dvě. LoadedEditsEmitInvariantNumbersUnderCommaLocale přiřadí čárku do FormatSettings.DecimalSeparator před první úpravou, spustí RedactLoadedRect, StampLoadedPageText, StitchLoadedPage a kompozitní razítko, pak přečte zpět bajty obsahu stránky a tvrdí jak to, že přesné řetězce operátorů jsou přítomné, tak to, že buffer neobsahuje nikde žádnou čárku. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale pokrývá druhou polovinu a původní hřích: řídí cestu vytváření pod stejným čárkovým locale, včetně DirectDrawBarcode s jeho pevným formátováním na dvě desetinná místa, pak tvrdí, že content stream nenese žádnou čárku a že FormatSettings.DecimalSeparator je po návratu z EndDoc stále čárka

Ta druhá aserce je ta, která by chytila původní vadu v den, kdy byla napsána, protože testuje, co knihovna dělá se svým hostitelem, ne jen co zapisuje na disk. Ponaučení se zobecňuje za PDF: každý formát s pevnou lexikální gramatikou, PostScript, JSON i JavaScript včetně, potřebuje na hranici explicitní invariantní konverzi. Sáhnout po globálním nastavení, aby vyšlo správně, funguje, dokud někdo nezavolá váš kód v jiném pořadí, na jiném vlákně nebo z jiného kompilátoru, a tou dobou selhání vypadá jako cokoli jiného než problém formátování

Popisovaná vrstva formátování čísel se dodává jako součást HotPDF Delphi Component pro Delphi a C++Builder spolu s cestami úprav načteného dokumentu, čárových kódů, SVG exportu a XPS konverze, které na ní závisí