Odborný článok

PDF čísla nezávislé od locale pre čiarkové Delphi aplikácie

PDFlibPas, losLab PDF Developer Library for Delphi, zapisuje každé číslo, ktoré dostane do content streamu, s bodkou ako desatinným oddeľovačom a bez exponentu, nech hovoria regionálne nastavenia Windows čokoľvek. Od v3.539.26 formátujú AddPageMatrix, ScalePage, DeskewPage, RedactRegion, výstup text-to-path aj prefarbovanie operandy cez PLDoubleToStrConst a od v3.539.33 parsery, ktoré tie čísla čítajú späť, používajú PLTryStrToFloatInvariant namiesto systémového locale. Na nemeckom, francúzskom či brazílskom stroji vyprodukuje tentýž kód teraz tie isté bajty ako na americkom, a to je jediné správanie, ktoré súborový formát znáša

Prečo pokazí čiarkové locale PDF bez chyby?

Čiarkové locale pokazí PDF potichu, lebo čiarka nie je v PDF syntaxi znak čísla, takže škoda sa číta ako platné tokeny so zlým významom. Pred opravou bolo PLFloatToStr nič iné než holé volanie FloatToStr a FloatToStr sa riadi FormatSettings.DecimalSeparator. S čiarkovým oddeľovačom zapísalo AddPageMatrix(0.5, 0.5, 0, 0) 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 dovoluje v čísle číslice, jednu bodku a prípadné znamienko na začiatku a nič viac, takže content parser prečíta ten riadok ako číslo 0 nasledované neznámym tokenom ,5 a operátor cm skončí so zlými operandmi. Nič nevyhodí výnimku, nič nezaloguje. Strana sa jednoducho vykreslí s transformačnou maticou, ktorá sa posunula, a dohnať z premiestnenej kresby späť k nastaveniu locale je trápenie na celé popoludnie

Druhý defekt sa skrýva za prvým. FloatToStr používa formát ffGeneral, ktorý prejde na exponentový zápis, akonáhle magnitúda klesne pod 1E-4, takže malý offset vyšiel ako 1E-5. Týž §7.3.3 konštatuje, že PDF exponentový tvar nepodporuje, čo znamená, že aj stroj s americkým locale dokázal zapísať neplatný operand pri dostatočne malej hodnote. Regresné testy tohto vydania priklincujú oba tvary zlyhania: prepnú oddeľovač na čiarku, zavolajú API a preskenujú výsledný obsah po akomkoľvek tokene obsahujúcom čiarku či exponent

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 := ',';   // simulácia nemeckej plochy de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 a novšie zapíše: 0.5 0 0 0.25 0.00001 12.75 cm
      // staršie zostavenia písali: 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 čiarkovej ploche zapísala pred opravou 0,5 0 0 0,25 1E-5 12,75 cm, čo PDF parser prečíta ako číslo 0 plus neznáme tokeny, takže cm zostane so zlými operandmi a strana sa potichu transformuje, zatiaľ čo PLDoubleToStrConst zapisuje platné bodkové decimály
Čiarka nie je v PDF syntaxi znak čísla, takže škoda sa číta ako platné tokeny so zlým významom — a exponenty ffGeneral ako 1E-5 boli neplatné na každom locale, nielen na čiarkových

Dva druhy čísel, dve rodiny helperov

Oprava v PDFlibPas je prísne rozdelenie: čísla určené ľuďom môžu nasledovať locale a čísla písané pre stroj nikdy. PLFloatToStr a PLStrToFloat ostanú v PDFlibExtra.pas pre text orientovaný na používateľa a ich deklarácia teraz nesie komentár, ktorý to hovorí presne takto. Všetko, čo končí ako PDF syntax, prechádza cez PLDoubleToStrConst s pevným počtom desatinných miest zvoleným podľa úlohy: šesť pre matice, štyri pre súradnice a úpravy TJ, tri pre farby a FDF obdĺžniky. Audit pre v3.539.26 zasiahol viac miest volaní, než naznačovalo pôvodné hlásenie chyby:

  • AddPageMatrix, ScalePage a DeskewPage, ktoré všetky predradia cm pred existujúci obsah strany
  • Stavitelia elementov strany, ktoré emitujú resety Tm, posuny TJ a transformácie cm
  • Umiestňovacie matice glyfov a outline body v prevodníku text-to-path
  • Čierne výplňové pole, ktoré predradí RedactRegion, hodnoty /Rect pri FDF exporte a operandy, ktoré zapisuje prefarbovanie

PLDoubleToStrConst je formatter písaný na mieru, nie obal okolo FloatToStrF, a tri jeho vlastnosti sú tu podstatné. Vždy zapisuje bodku a škrtnie nuly na konci, takže 0.5 ostanú 0.5 a nie 0.500000. Pre konečný vstup nikdy nezapíše exponent. A nenulová hodnota menšia než požadovaná presnosť si ponechá svoje významné číslice namiesto kolapsu na nulu, takže PLDoubleToStrConst(1E-9, 6) vráti 0.000000001; pod približne 5E-16 sa hodnoty stanú 0. Posledné pravidlo existuje preto, lebo zaokrúhlenie malého škálovacieho faktora na nulu zmení platnú maticu na singulárnu, čo je horšia chyba než tá opravovaná

PDFlibPas delí formátovanie čísel na dve: PLFloatToStr a PLStrToFloat ostanú viazané na locale pre text určený ľuďom, zatiaľ čo PLDoubleToStrConst a PLTryStrToFloatInvariant formátujú všetko, čo sa stane PDF syntaxou, s bodkou, bez exponentu a s pevnou presnosťou šiestich, štyroch či troch desatinných miest podľa úlohy
Invariantný formatter je zámerne písaný na mieru: škrtnie nuly na konci, nikdy nezapíše exponent a ponechá významné číslice malých hodnôt, lebo zaokrúhlenie škálovacieho faktora na nulu by z platnej matice urobilo singulárnu
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Strojový výstup: bodková decimála, žiadny exponent, koncové nuly odškrtnuté
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // ponechá 4 významné číslice
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // Strojový vstup: mäkké zlyhanie namiesto EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // content čísla čiarku nikdy nepoužívajú
end;

Prečo je parsujúca strana nebezpečnejšia než zapisujúca?

Parsujúca strana je nebezpečnejšia, lebo parser viazaný na locale nevyrobí zlé číslo, ale vyhodí výnimku. PLStrToFloat volá StrToFloat, ktoré pri texte nesúhlacom so systémovým oddeľovačom hodí EConvertError. Na čiarkovom systéme to znamenalo, že RecolorPage padla v momente, keď stretla obyčajný operátor 0.5 g, takže zlyhala každá reálna strana, nie len exotické. RenderPageRegionToFile odmietal vlastný zdokumentovaný clip formát "10.5,20.5,50.5,40.5" a atribúty dĺžok SVG, farby SVG exportu, zoznamy vrcholov anotácií a hodnoty solidity output intentu sa buď odmietali, alebo potichu nahradili predvolenými. Knižnica, ktorá na vývojárskej mašine beží dokonale a padne pri prvom zákazníkovi v Mníchove, je presne ten druh kódu, ktorý, ako prípady v článku o Delphi kóde fungujúcom náhodou, vyzerá korektne len vďaka miestu, kde sa testoval

v3.539.33 roztriedilo každé volanie StrToFloat a TryStrToFloat podľa toho, odkiaľ vstup prichádza. Operandy content streamov, atribúty SVG, farebné reťazce painterov a čiarkou oddeľované clip aj vertex zoznamy majú všetky pevnú bodkovú syntax, takže teraz prechádzajú cez PLTryStrToFloatInvariant, ktoré ostrihá text, parsuje ho s PLInvariantFormatSettings a pre prázdny, deformovaný či nekonečný vstup vráti False namiesto vyhodenia výnimky. Čiarkou oddeľovaný zoznam nedáva priestor na kompromis, lebo čiarka nemôže byť súčasne delimiterom zoznamu aj desatinným oddeľovačom. Ten istý priebeh opravil aj zápis mimo rozsahu: RenderPageRegionToFile ukladal piatu clip hodnotu za štvorprvkový buffer. Pre pipeline prefarbovania popísanú v sprievodcovi konverziou PDF do jedného farebného priestoru je praktický výsledok taký, že RecolorPage a RecolorDocument už na čiarkovom systéme nepadia. Hodnoty pravidiel, ktoré volajúci vpíše do CheckDocumentPolicy, sú jediný parsing prípad, ktorý namiesto toho používa zhovievavý helper, a to z dôvodu, ktorý vysvetľuje ďalšia sekcia

Čo sa stane, keď opravíte len jeden koniec round tripu?

Oprava iba jedného konca locale round tripu rozbije kód, ktorý fungoval, a presne preto zmena štruktúrových atribútov vo v3.539.32 posunula zapisovača a čítača spolu. Wrappery SetStructElem* prenášajú čísla ako reťazce: SetStructElemBBox naformátuje štyri hodnoty do jedného reťazca, uloží ho cez AddTagAttribute a zapisovač /A ten reťazec neskôr parsuje, aby rozhodol, či sa stane číslom, poľom alebo názvom. Oba konce používali systémové locale, takže na čiarkovom systéme bol round trip sebakonzistentný. Chyba sa ukázala až vtedy, keď sa volajúci riadil dokumentáciou a podal "0.5" do AddTagAttribute: čítač ho nedokázala rozparsovať a emitovala PDF názov /0.5. Zástupný symbol PDF/VCR mal zrkadlový problém, lebo knižnica generovala GTS_BBox s bodkou a pred uložením ho validovala cez locale

Zmena len zapisovača na bodku by bola horšia ako nerobiť nič, pretože každá hodnota SetStructElem* by potom neprešla čítačom viazaným na locale a degradovala na názov. Preto zapisovače teraz používajú PLDoubleToStrConst(v, 6) a čítač nové PLTryStrToFloatLenient, ktoré najprv skúsi bodkový tvar a až potom spadne k systémovému locale. Volajúci s čiarkovým locale, ktorý kedysi podal "1,25", stále dostane číslo 1.25. Kompromis je zámerný a zdokumentovaný: na nemeckom systéme sa "1.500" kedysi stávalo názvom, lebo StrToFloat odmieta oddeľovače tisícov, a teraz sa prečíta ako 1.5, zatiaľ čo literálne reťazce NAN a INF sa už ako čísla neprijímajú

SetStructElemBBox v PDFlibPas aj jej sestry prenášajú čísla ako reťazce cez AddTagAttribute a zapisovač /A tie reťazce parsuje späť, takže v3.539.32 posunula oba konce spolu: PLDoubleToStrConst zapisuje s bodkou a PLTryStrToFloatLenient číta najprv bodku s prípadným návratom k locale, takže čiarkové 1,25 sa stále prečíta ako 1.25
Oprava len zapisovača by degradovala každý atribút štruktúrovaného elementu na PDF názov, a preto round trip posúva oba konce spolu, alebo vôbec
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // volajúci s čiarkovou decimálou
  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, predtým /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // aj naďalej /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

Kde sa NaN a nekonečno zastavia

AddPageMatrix, ScalePage a RedactRegion teraz odmietajú NaN aj nekonečné argumenty rovno na vstupe a vracajú 0, lebo žiadne PDF číslo ich nedokáže niesť. ScalePage už odmietala faktory nula a menej, ale NaN prejde testom <= 0, takže NaN mierka sa kedysi dostala až k formatteru. Vo v3.539.26 volalo toto formátovanie ešte Round na NaN, čo na Win32 hodí EInvalidOp, keďže x87 jednotka nepotlačuje neplatné operácie; v3.539.31 prinútilo PLDoubleToStrConst zapisovať pre NaN 0 ako poslednú obrannú líniu, ale nula v matici je singulárna transformácia, takže skutočnou opravou ostáva kontrola na úrovni API. Dve hranice ostanú zámerne pri mieste. Stavové reťazce metafilu sa zapisujú a čítajú s locale vnútri jedného procesu a nikdy ho neopúšťajú, takže ich nechali na pokoji. A test, ktorý formátuje 1E-5 cez cestu elementov strany, musí obsah prečítať skôr, než sa vrstva prepíše, lebo opätovná emisia operandov v presnosti dokumentu zmení túto hodnotu legitímne na 0

Ak vaša aplikácia putuje k zákazníkom mimo sveta bodkových decimál, najbezpečnejším zvykom je ten, ktorý teraz používa test suite PDFlibPas: prebehnite cesty vyrábajúce PDF raz s FormatSettings.DecimalSeparator nastaveným na čiarku a preskenujte výstup po čiarkach a exponentoch. O druhej polovici toho istého príbehu, ako čísla načítané z existujúceho súboru zachovajú pri ukladaní svoj presný text, pojednáva článok o zachovaní presnosti naparsovaných decimál. Súbory na stiahnutie, úplná API referencia a skúšobné zostavenie sú na produktovej stránke PDFlibPas Delphi PDF library