Tehnički članak

Locale-neutral PDF brojevi u Delphiju: zarez ili točka

PDFlibPas, losLab PDF Developer Library za Delphi, zapisuje svaki broj koji stavi u content stream s točkom kao decimalnim separatorom i bez eksponenta, ma što govorile regionalne postavke Windowsa. Od v3.539.26 AddPageMatrix, ScalePage, DeskewPage, RedactRegion, text-to-path izlaz i prebojavanje formatiraju operande kroz PLDoubleToStrConst, a od v3.539.33 parseri koji te brojeve čitaju natrag koriste PLTryStrToFloatInvariant umjesto lokala sustava. Na njemačkom, francuskom ili brazilskom stroju isti kod sada proizvodi iste bajtove kao na američkom, a to je jedino ponašanje koje format datoteke može podnijeti

Zašto lokal sa zarezom kao decimalom pokvari PDF bez pogreške?

Lokal sa zarezom kao decimalom tiho pokvari PDF jer zarez nije znak broja u PDF sintaksi, pa se šteta čita kao valjani tokeni s krivim značenjem. Prije ispravka PLFloatToStr nije bio ništa više od golog poziva FloatToStr, a FloatToStr slijedi FormatSettings.DecimalSeparator. Sa zarezom kao separatorom, AddPageMatrix(0.5, 0.5, 0, 0) zapisao je 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 dopušta znamenke, jednu točku i vodeći predznak u broju i ništa više, pa content parser tu liniju čita kao broj 0 iza kojeg slijedi nepoznat token ,5, i operator cm završi s krivim operandima. Ništa ne baci, ništa ne zapiše. Stranica se jednostavno renderira s transformacijskom matricom koja je odlutala, a rad unatrag od pomaknutog crteža do postavke lokala jest jadno poslijepodne

Drugi se defekt krije iza prvoga. FloatToStr koristi format ffGeneral, koji pređe na eksponencijalni zapis čim veličina padne ispod 1E-4, pa je sitni offset izašao kao 1E-5. Isti §7.3.3 kaže da PDF ne podržava eksponencijalni oblik, što znači da bi i stroj s američkim lokalom mogao zapisati nevaljan operand uz dovoljno malu vrijednost. Regresijski testovi ovog izdanja pribijaju oba oblika kvara: prebace separator na zarez, pozovu API i pretraže nastali sadržaj za svaki token koji sadrži zarez ili eksponent

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 := ',';   // simuliraj de-DE radnu površinu
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 i noviji zapisuju: 0.5 0 0 0.25 0.00001 12.75 cm
      // stariji buildovi:            0,5 0 0 0,25 1E-5 12,75 cm
      Writeln(Lib.GetPageContentToString);
    finally
      FormatSettings.DecimalSeparator := OldSeparator;
    end;
  finally
    Lib.Free;
  end;
end;
PDFlibPas AddPageMatrix na radnoj površini sa zarezom kao decimalom zapisao je 0,5 0 0 0,25 1E-5 12,75 cm prije ispravka, što PDF parser čita kao broj 0 plus nepoznate tokene, ostavljajući cm s krivim operandima i tiho transformiranom stranicom, dok PLDoubleToStrConst zapisuje valjane točkaste decimale
Zarez nije znak broja u PDF sintaksi, pa se šteta čita kao valjani tokeni s krivim značenjem — a ffGeneral eksponenti poput 1E-5 bili su nevaljani na svakom lokalu, ne samo na zarezima

Dva tipa brojeva, dvije obitelji helpera

Ispravak u PDFlibPasu stroga je podjela: brojevi pokazani ljudima mogu slijediti lokal, a brojevi zapisani za stroj nikad. PLFloatToStr i PLStrToFloat ostaju u PDFlibExtra.pas za tekst okrenut korisniku, i njihova deklaracija sada nosi komentar koji upravo to kaže. Sve što završi kao PDF sintaksa prolazi kroz PLDoubleToStrConst s fiksnim brojem decimalnih mjesta odabranim za posao: šest za matrice, četiri za koordinate i TJ prilagodbe, tri za boje i FDF pravokutnike. Revizija za v3.539.26 dirala je više call siteova nego što je izvorno izvješće o bugu nagovještavalo:

  • AddPageMatrix, ScalePage i DeskewPage, koji svi dodaju cm ispred postojećeg sadržaja stranice
  • Graditelji elemenata stranice koji ispuštaju Tm resetove, TJ pomake i cm transformacije
  • Matrice pozicioniranja glifova i outline točke u text-to-path konverteru
  • Crna kutija za popunjavanje koju RedactRegion doda ispred, vrijednosti /Rect u FDF izvozu i operandi koje prebojavanje zapiše

PLDoubleToStrConst ručno je rađeni formater, a ne omot oko FloatToStrF, i tri mu svojstva ovdje znače. Uvijek zapisuje točku i reže zaostale nule, pa 0.5 ostane 0.5 umjesto 0.500000. Nikad ne zapiše eksponent za konačan ulaz. I nenulta vrijednost manja od tražene preciznosti zadrži svoje značajne znamenke umjesto da se sruši u nulu, pa PLDoubleToStrConst(1E-9, 6) vrati 0.000000001; samo vrijednosti ispod otprilike 5E-16 postanu 0. To posljednje pravilo postoji jer zaokruživanje sitnog faktora skaliranja u nulu pretvori valjanu matricu u singularnu, što je gori bug od onoga koji se popravlja

PDFlibPas dijeli formatiranje brojeva na dvoje: PLFloatToStr i PLStrToFloat ostaju vezani uz lokal za tekst okrenut korisniku, dok PLDoubleToStrConst i PLTryStrToFloatInvariant formatiraju sve što postane PDF sintaksa s točkom, bez eksponenta i s fiksnom preciznošću po poslu od šest, četiri ili tri decimale
Invarijantni formater namjerno je ručno rađen: reže zaostale nule, nikad ne zapiše eksponent i zadržava značajne znamenke sitnih vrijednosti, jer bi zaokruživanje faktora skaliranja u nulu valjanu matricu učinilo singularnom
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Strojni izlaz: točkasta decimala, bez eksponenta, zaostale nule izrezane
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // zadržava 4 značajne znamenke
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // Strojni ulaz: blag pad umjesto EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // brojevi u sadržaju nikad ne koriste zarez
end;

Zašto je strana parsiranja opasnija od strane pisanja?

Strana parsiranja opasnija je jer parser vezan uz lokal ne proizvede krivi broj, nego baci. PLStrToFloat zove StrToFloat, koji podigne EConvertError kad se tekst ne poklopi sa separatorom sustava. Na sustavu sa zarezom kao decimalom to je značilo da RecolorPage padne u trenutku kad sretne običan operator 0.5 g, pa su propadale sve stvarne stranice, ne samo egzotične. RenderPageRegionToFile odbijao je vlastiti dokumentirani clip format "10.5,20.5,50.5,40.5", a SVG atributi duljina, boje SVG izvoza, liste vrhova anotacija i vrijednosti output intent čvrstoće ili su odbijani ili tiho zamijenjeni zadanim. Biblioteka koja na razvojnom stroju radi savršeno, a padne na prvom kupcu u Münchenu, točno je ona vrsta koda koja, poput slučajeva u članku o Delphi kodu koji radi slučajno, djeluje ispravno samo zbog toga gdje je testirana

v3.539.33 razvrstao je svaki poziv StrToFloat i TryStrToFloat prema tome odakle mu ulaz dolazi. Operandi content streama, SVG atributi, stringovi boja slikara i liste clipova i vrhova odvojene zarezom sve imaju fiksnu točkastu sintaksu, pa sada prolaze kroz PLTryStrToFloatInvariant, koja obreže tekst, parsira ga s PLInvariantFormatSettings i vrati False za prazan, deformiran ili ne-konačan ulaz umjesto da baci. Lista odvojena zarezom ne ostavlja prostora za kompromis, jer zarez ne može biti i ograničitelj liste i decimalni znak. Isti prolaz popravio je i ispis izvan granica: RenderPageRegionToFile čuvao je petu clip vrijednost iza svog buffera od četiri elementa. Za recoloring pipeline opisan u vodiču kroz pretvorbu PDF-a u jedan color space, praktičan je rezultat da se RecolorPage i RecolorDocument više ne prekidaju na sustavu sa zarezom kao decimalom. Vrijednosti pravila koje pozivatelj utipka u CheckDocumentPolicy jedini su parsing slučaj koji umjesto toga koristi blagi helper, iz razloga koji objašnjava idući odjeljak

Što se dogodi ako popravite samo jedan kraj round tripa?

Popravak samo jednog kraja lokalskog round tripa slomi kod koji je radio, zato je izmjena atributa strukture u v3.539.32 pomakla writer i reader zajedno. Omoti SetStructElem* prenašaju brojeve kao stringove: SetStructElemBBox formatira četiri vrijednosti u jedan string, pohrani ga kroz AddTagAttribute, a writer /A kasnije parsira taj string da odluči postaje li broj, polje ili ime. Oba su kraja koristila lokal sustava, pa je na sustavu sa zarezom kao decimalom round trip bio samodosljedan. Bug se pokazao tek kad je pozivatelj slijedio dokumentaciju i predao "0.5" u AddTagAttribute: reader ga nije mogao parsirati i ispuštao je PDF ime /0.5. PDF/VCR placeholder imao je ogledalski problem, jer je biblioteka generirala GTS_BBox s točkom, a onda ga validirala lokalom prije spremanja

Mijenjati samo writera u točku bilo bi gore od nerada, jer bi tada svaka vrijednost SetStructElem* padala na readeru vezanom uz lokal i degradirala u ime. Zato writeri sada koriste PLDoubleToStrConst(v, 6), a reader novi PLTryStrToFloatLenient, koji prvo proba točkasti oblik, pa se vrati na lokal sustava. Pozivatelj sa zarez-lokalom koji je u prošlosti predao "1,25" i dalje dobije broj 1.25. Kompromis je namjeran i dokumentiran: na njemačkom sustavu "1.500" postajalo je ime jer StrToFloat odbija separatore tisuća, a sada se čita kao 1.5, dok se literalni stringovi NAN i INF više ne prihvaćaju kao brojevi

PDFlibPas SetStructElemBBox i njegova braća prenašaju brojeve kao stringove kroz AddTagAttribute, a writer /A te stringove parsira natrag, pa je v3.539.32 pomakla oba kraja zajedno: PLDoubleToStrConst zapisuje s točkom, a PLTryStrToFloatLenient čita prvo točku s lokalnim fallbackom pa zarez-lokal 1,25 i dalje čita kao 1.25
Popravak samo writera degradirao bi svaki atribut strukturnog elementa u PDF ime, zato round trip pomjera oba kraja zajedno ili nijedan
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // pozivatelj sa zarezom kao decimalom
  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, nekad /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // i dalje /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

Gdje se NaN i beskonačnost zaustave

AddPageMatrix, ScalePage i RedactRegion sada unaprijed odbijaju NaN i beskonačne argumente i vraćaju 0, jer nijedan PDF broj ne može predstaviti te vrijednosti. ScalePage već je odbijao faktore nula ili manje, ali NaN prolazi test <= 0, pa je NaN skala nekada putovala sve do formatera. U v3.539.26 taj je formater još zvao Round nad NaN-om, što podigne EInvalidOp na Win32 gdje x87 jedinica ne maskira nevaljane operacije; v3.539.31 natjerao je PLDoubleToStrConst da za NaN zapiše 0 kao posljednju liniju obrane, ali je nula u matrici singularna transformacija, pa je provjera na razini API-ja i dalje pravi ispravak. Dvije granice ostaju na mjestu namjerno. Metafile state stringovi zapisuju se i čitaju s lokalom unutar jednog procesa i nikad ga ne napuštaju, pa su ostavljeni na miru. I test koji formatira 1E-5 kroz put elemenata stranice mora pročitati sadržaj prije nego se sloj prepravi, jer ponovno ispuštanje operanada na preciznosti dokumenta valjano tu vrijednost pretvori u 0

Ako vaša aplikacija ide kupcima izvan svijeta točkastih decimala, najsigurnija je navika ona koju test suite PDFlibPas sada koristi: vrtite PDF-proizvodne puteve jednom s FormatSettings.DecimalSeparator postavljenim na zarez i pretražite izlaz za zareze i eksponente. Članak o očuvanju parsirane decimalne preciznosti pokriva drugu polovicu iste priče, kako brojevi pročitani iz postojeće datoteke čuvaju svoj točan tekst pri spremanju. Preuzimanja, potpuna API referenca i trial build su na stranici proizvoda PDFlibPas Delphi PDF library