Technický článek

Čísla PDF nezávislá na locale pro Delphi aplikace s čárkou

PDFlibPas, losLab PDF Developer Library for Delphi, zapisuje každé číslo, které dává do content streamu, s tečkou jako desetinným oddělovačem a bez exponentu, ať říkají Windows regional settings cokoliv. Od v3.539.26 formátují operandy přes PLDoubleToStrConst AddPageMatrix, ScalePage, DeskewPage, RedactRegion, výstup text-to-path i přebarvování, a od v3.539.33 parsery, která ta čísla čtou zpět, používají PLTryStrToFloatInvariant místo systémového locale. Na německém, francouzském či brazilském stroji vyleze ze stejného kódu tentýž bajt za bajtem jako na americkém, a jinak to formát souborů nesnese

Proč kazí čárkové locale PDF bez jediné chyby?

Čárkové locale kazí PDF potichu, protože čárka není v syntaxi PDF číslicový znak, takže škoda vypadá jako validní tokeny se špatným významem. Před opravou nebylo PLFloatToStr nic víc než nahé volání FloatToStr a FloatToStr se řídí FormatSettings.DecimalSeparator. S čárkou jako oddělovačem zapsalo AddPageMatrix(0.5, 0.5, 0, 0) 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 v čísle dovoluje číslice, jednu tečku a úvodní znaménko a nic víc, takže content parser přečte tenhle řádek jako číslo 0 následované neznámým tokenem ,5 a operátor cm skončí se špatnými operandy. Nic nezvedne výjimku, nic nezaloguje. Stránka se prostě vykreslí s transformační maticí, která někde ujela, a dohledávat z přeházené kresby locale nastavení je nechtěné odpoledne

Druhá vada se schovává za tou první. FloatToStr používá formát ffGeneral, který přepne na exponentový zápis, jakmile velikost spadne pod 1E-4, takže drobný offset vyšel jako 1E-5. Týž §7.3.3 říká, že PDF exponentový tvar nepodporuje, což znamená, že i stroj s US locale mohl zapsat nevalidní operand při dost malé hodnotě. Regresní testy tohohle vydání přibíjí oba tvary selhání: přepnou oddělovač na čárku, zavolají API a proskenují výsledný obsah na jakýkoli token s čárkou nebo exponentem

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 := ',';   // simulace plochy de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 a novější zapisují: 0.5 0 0 0.25 0.00001 12.75 cm
      // starší buildy psaly:          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 v PDFlibPas na čárkové ploše zapsal před opravou 0,5 0 0 0,25 1E-5 12,75 cm, což PDF parser přečte jako číslo 0 plus neznámé tokeny, takže cm dostane špatné operandy a stránka se potichu přetransformuje, zatímco PLDoubleToStrConst zapisuje validní desetinné hodnoty s tečkou
Čárka není v syntaxi PDF číslicový znak, takže škoda vypadá jako validní tokeny se špatným významem — a exponenty jako 1E-5 z ffGeneral byly nevalidní na každém locale, ne jen na čárkových

Dva druhy čísel, dvě rodiny helperů

Oprava v PDFlibPas je přísný rozkol: čísla určená lidem můžou za locale jít, čísla zapsaná stroji nikdy. PLFloatToStr a PLStrToFloat zůstávají v PDFlibExtra.pas pro texty mířené na uživatele a jejich deklarace teď nese komentář, který to říká napřímo. Všechno, co skončí jako PDF syntaxe, jde přes PLDoubleToStrConst s pevným počtem desetinných míst zvoleným pro daný úkol: šest pro matice, čtyři pro souřadnice a korekce TJ, tři pro barvy a FDF obdélníky. Audit pro v3.539.26 sáhl do víc míst, než naznačoval původní bug report:

  • AddPageMatrix, ScalePage a DeskewPage, které všechny předřadí cm před stávající obsah stránky
  • Stavitelé prvků stránky, kteří vysílají resety Tm, posuny TJ a transformace cm
  • Matice umístění glyfů a obrysové body v konvertoru text-to-path
  • Černá výplňová krabice, kterou přidává RedactRegion, hodnoty /Rect v FDF exportu a operandy, které zapisuje přebarvování

PLDoubleToStrConst je ručně psaný formátovač, ne obal kolem FloatToStrF, a tři jeho vlastnosti jsou tu podstatné. Pořád zapisuje tečku a odstraňuje koncové nuly, takže 0.5 zůstane 0.5 a ne 0.500000. Pro konečný vstup nikdy nezapisuje exponent. A nenulová hodnota menší než požadovaná přesnost si podrží své významné číslice místo zhroucení na nulu, takže PLDoubleToStrConst(1E-9, 6) vrátí 0.000000001; na 0 se smrsknou jen hodnoty pod zhruba 5E-16. Tohle poslední pravidlo existuje proto, že zaokrouhlení drobného škálovacího faktoru na nulu promění validní matici v singulární, což je horší bug než ten, který se právě opravuje

PDFlibPas rozděluje formátování čísel na dvě poloviny: PLFloatToStr a PLStrToFloat zůstávají vázané na locale pro texty uživateli, zatímco PLDoubleToStrConst a PLTryStrToFloatInvariant formátují všechno, co se stává PDF syntaxí, s tečkou, bez exponentu a s pevnou přesností šesti, čtyř nebo tří desetinných míst podle úkolu
Invariantní formátovač je ručně psaný záměrně: odstraňuje koncové nuly, nikdy nezapisuje exponent a drží významné číslice drobných hodnot, protože zaokrouhlení škálovacího faktoru na nulu by z validní matice udělalo singulární
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Strojový výstup: desetinná tečka, žádný exponent, koncové nuly odstraněny
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // drží 4 významné číslice
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // Strojový vstup: měkké selhání místo EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // čísla v obsahu čárku nikdy nepoužívají
end;

Proč je parse strana nebezpečnější než zapisovací?

Parse strana je nebezpečnější, protože parser vázaný na locale nevyprodukuje špatné číslo — rovnou vyhodí výjimku. PLStrToFloat volá StrToFloat, které na neshodě se systémovým oddělovačem zvedne EConvertError. Na systému s čárkou to znamenalo, že RecolorPage skončil v okamžiku, kdy potkal obyčejný operátor 0.5 g, takže selhávala každá reálná stránka, ne jen exotické. RenderPageRegionToFile odmítal vlastní zdokumentovaný clip formát "10.5,20.5,50.5,40.5" a délkové atributy SVG, barvy SVG exportu, seznamy vrcholů anotací a hodnoty solidity output intentu byly buď odmítnuté, nebo potichu nahrazené defaulty. Knihovna, která funguje dokonalé na vývojářském stroji a padá na prvním zákazníkovi v Mnichově, je přesně ten druh kódu, který — jako případy z článku o Delphi kódu fungujícím náhodou — vypadá správně jen proto, kde se testoval

v3.539.33 roztřídila každé volání StrToFloat a TryStrToFloat podle toho, odkud vstup pochází. Operandy content streamů, atributy SVG, barvové řetězce painteru a čárkou oddělené clip a vertex seznamy mají všichni pevnou tečkovou syntaxi, takže nyní jdou přes PLTryStrToFloatInvariant, které text ořezá, parsuje ho přes PLInvariantFormatSettings a u prázdného, deformovaného nebo nekonečného vstupu vrátí False místo výjimky. Čárkou oddělený seznam nedává kompromisu žádný prostor, protože čárka nemůže být zároveň delimiterem seznamu i desetinným oddělovačem. Týž průchod opravil i zápis mimo hranice bufferu: RenderPageRegionToFile ukládal pátou clip hodnotu za svůj čtyřprvkový buffer. Pro přebarvovací pipeline popsanou v průvodci konverzí PDF do jedného barevného prostoru je praktickým výsledkem, že RecolorPage a RecolorDocument už na čárkovém systému nepřerušují. Hodnoty pravidel, které volající vypíše do CheckDocumentPolicy, jsou jediným parse případem, který místo toho používá shovívavý helper, a důvod vysvětluje další sekce

Co se stane, když opravíte jen jeden konec round tripu?

Oprava jen jednoho konce locale round tripu rozbije kód, který fungoval, a proto změna strukturálních atributů ve v3.539.32 posunula writer a reader dohromady. Wrappery SetStructElem* přenášejí čísla jako řetězce: SetStructElemBBox naformátuje čtyři hodnoty do jednoho řetězce, uloží ho přes AddTagAttribute a writer /A tenhle řetězec později parsuje, aby rozhodl, zda se stane číslem, polem nebo názvem. Oba konce používaly systémové locale, takže round trip byl na čárkovém systému vnitřně konzistentní. Bug se ukázal, jen když volající podle dokumentace předal do AddTagAttribute "0.5": reader ho neparsnul a vydal PDF název /0.5. Placeholder PDF/VCR měl zrcadlově obrácený problém, protože knihovna vygenerovala GTS_BBox s tečkou a pak ho před uložením validovala přes locale

Přepnout jen writer na tečku by bylo horší než nedělat nic, protože každá hodnota SetStructElem* by pak propadla readerem vázaným na locale a degradovala na název. Takže writery teď používají PLDoubleToStrConst(v, 6) a reader nové PLTryStrToFloatLenient, které zkusí nejdřív tečkový tvar a sáhne po systémovém locale jako záloze. Volající s čárkovým locale, který dřív poslal "1,25", dostane i nadále číslo 1.25. Kompromis je záměrný a zdokumentovaný: na německém systému se "1.500" dřív stávalo názvem, protože StrToFloat odmítá oddělovače tisíců, a teď se přečte jako 1.5, zatímco doslovné řetězce NAN a INF už se jako čísla nepřijímají

SetStructElemBBox a jeho sourozenci v PDFlibPas přenášejí čísla jako řetězce přes AddTagAttribute a writer /A ty řetězce parsuje zpět, takže v3.539.32 posunula oba konce dohromady: PLDoubleToStrConst zapisuje s tečkou a PLTryStrToFloatLenient čte nejdřív tečku se zálohou na locale, takže čárkové 1,25 se pořád přečte jako 1.25
Opravit jen writer by degradovalo každý atribut strukturálního prvku na PDF název, a proto round trip posouvá oba konce dohromady, nebo vůbec
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // volající s čárkou jako desetinným oddělovačem
  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, dřív /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // pořád /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

Kde se NaN a nekonečno zastaví

AddPageMatrix, ScalePage a RedactRegion teď NaN a nekonečné argumenty odmítnou hned na vstupu a vrátí 0, protože žádné PDF číslo je neumí reprezentovat. ScalePage už dřív odmítala faktory nula a méně, ale NaN testem <= 0 projde, takže NaN měřítko kdysi procestovalo až do formátovače. Ve v3.539.26 volal tenhle formátovač na NaN stále Round, které zvedá EInvalidOp na Win32, kde x87 jednotka neinvalidní operace nemaskuje; v3.539.31 učinil zápis 0 pro NaN v PLDoubleToStrConst poslední záložní linií, ale nula v matici je singulární transformace, takže kontrola na úrovni API je pořád ta skutečná oprava. Dvě hranice zůstávají záměrně na místě. Stavové řetězce metafile se zapisují i čtou s locale uvnitř jednoho procesu a nikdy z něj nevyjdou, takže byly ponechány. A test, který formátuje 1E-5 přes cestu prvků stránky, musí obsah přečíst dřív, než se vrstva přepíše, protože znovuvysílání operandů v přesnosti dokumentu legitimně promění tuhle hodnotu v 0

Pokud vaše aplikace putuje k zákazníkům mimo tečkový svět, nejbezpečnější návyk je ten, který teď používá test suite PDFlibPas: projděte cesty tvořící PDF jednou s FormatSettings.DecimalSeparator nastaveným na čárku a proskenujte výstup na čárky a exponenty. Článek o zachování parsované desetinné přesnosti pokrývá druhou polovinu téhož příběhu — jak čísla přečtená z existujícího souboru drží svůj přesný text při uložení. Ke stažení, kompletní API reference i trial build najdete na stránce produktu PDFlibPas Delphi PDF library