Articol tehnic

Numere PDF neutre de locale pentru app-uri Delphi cu virgulă

PDFlibPas, losLab PDF Developer Library for Delphi, scrie fiecare număr pe care îl pune într-un content stream cu separator zecimal punct și fără exponent, indiferent ce spun setările regionale Windows. Din v3.539.26, AddPageMatrix, ScalePage, DeskewPage, RedactRegion, output-ul text-to-path și recolorarea formatează operanzii prin PLDoubleToStrConst, iar din v3.539.33 parserele care citesc acele numere înapoi folosesc PLTryStrToFloatInvariant în locul locale-ului de sistem. Pe o mașină germană, franceză sau braziliană, același cod produce acum aceiași octeți ca pe unul american, singurul comportament pe care un format de fișier îl poate tolera

De ce corupe un locale cu zecimale cu virgulă un PDF fără nicio eroare?

Un locale cu zecimale cu virgulă corupe un PDF în tăcere pentru că virgula nu e caracter de număr în sintaxa PDF, deci dauna se citește ca token-uri valide cu sens greșit. Înainte de corecție, PLFloatToStr nu era nimic mai mult decât un apel gol de FloatToStr, iar FloatToStr urmează FormatSettings.DecimalSeparator. Cu separator virgulă, AddPageMatrix(0.5, 0.5, 0, 0) scria 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 permite într-un număr doar cifre, un punct și un semn în față, așa că un parser de conținut citește linia aceea ca numărul 0 urmat de un token necunoscut ,5, iar operatorul cm ajunge cu operanzi greșiți. Nimic nu ridică, nimic nu loghează. Pagina pur și simplu se randează cu o matrice de transformare care a derivat, iar de-a o urmări înapoi de la un desen deplasat până la o setare de locale e un după-amiază mizer

Al doilea defect se ascunde în spatele primului. FloatToStr folosește formatul ffGeneral, care trece la notație cu exponent o dată ce magnitudinea scade sub 1E-4, deci un offset minuscul ieșea ca 1E-5. Același §7.3.3 stabilește că PDF nu suportă forma cu exponent, ceea ce înseamnă că chiar și o mașină cu locale american putea scrie un operand invalid la o valoare suficient de mică. Testele de regresie ale acestui release fixează ambele forme de eșec: răsturnă separatorul în virgulă, apelează API-ul și scanează conținutul rezultat după orice token care conține o virgulă sau un 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 := ',';   // simulează un desktop de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 și mai nou scriu: 0.5 0 0 0.25 0.00001 12.75 cm
      // build-urile vechi scriau:   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 pe un desktop cu zecimale cu virgulă scria 0,5 0 0 0,25 1E-5 12,75 cm înainte de corecție, ceea ce un parser PDF citește ca numărul 0 plus token-uri necunoscute, lăsând cm cu operanzi greșiți și pagina transformată în tăcere, în timp ce PLDoubleToStrConst scrie zecimale valide cu punct
Virgula nu e caracter de număr în sintaxa PDF, deci dauna se citește ca token-uri valide cu sens greșit — iar exponenții ffGeneral precum 1E-5 erau invalide pe orice locale, nu doar pe cele cu virgulă

Două feluri de numere, două familii de helper-e

Corecția din PDFlibPas e o despărțire strictă: numerele arătate oamenilor pot urma locale-ul, iar numerele scrise pentru o mașină nu-l urmează niciodată. PLFloatToStr și PLStrToFloat rămân în PDFlibExtra.pas pentru textul îndreptat spre utilizator, iar declarația lor poartă acum un comentariu care spune exact asta. Tot ce ajunge sintaxă PDF trece prin PLDoubleToStrConst cu un număr fix de zecimale ales pentru treabă: șase pentru matrice, patru pentru coordonate și ajustări TJ, trei pentru culori și dreptunghiuri FDF. Auditul pentru v3.539.26 a atins mai multe locuri de apel decât sugera raportul inițial de bug:

  • AddPageMatrix, ScalePage și DeskewPage, care pun cu toții un cm în fața conținutului existent al paginii
  • Constructorii de elemente de pagină care emit reset-uri Tm, avansuri TJ și transformări cm
  • Matricele de plasare a glyph-urilor și punctele de contur în convertorul text-to-path
  • Cutia de umplere neagră pe care o pune în față RedactRegion, valorile /Rect din exportul FDF și operanzii pe care îi scrie recolorarea

PLDoubleToStrConst e un formatter scris de mână, nu un wrapper în jurul lui FloatToStrF, și trei din proprietățile lui contează aici. Scrie întotdeauna un punct și taie zerourile de la coadă, deci 0.5 rămâne 0.5 și nu 0.500000. Nu scrie niciodată un exponent pentru input finit. Iar o valoare non-zero mai mică decât precizia cerută își păstrează cifrele semnificative în loc să se prăbușească în zero, deci PLDoubleToStrConst(1E-9, 6) întoarce 0.000000001; doar valorile sub circa 5E-16 devin 0. Regula din urmă există pentru că rotunjirea unui factor de scală minuscul la zero transformă o matrice validă într-una singulară, un bug mai rău decât cel reparat

PDFlibPas împarte formatarea numerelor în două: PLFloatToStr și PLStrToFloat rămân legate de locale pentru textul către utilizator, în timp ce PLDoubleToStrConst și PLTryStrToFloatInvariant formatează tot ce devine sintaxă PDF cu punct, fără exponent și cu o precizie fixă de șase, patru sau trei zecimale per sarcină
Formatter-ul invariant e scris de mână din intenție: taie zerourile de la coadă, nu scrie niciodată un exponent și păstrează cifrele semnificative ale valorilor minuscule, fiindcă rotunjirea unui factor de scală la zero ar face o matrice validă singulară
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Output de mașină: zecimală cu punct, fără exponent, zerouri tăiate
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // păstrează 4 cifre semnificative
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // Input de mașină: eșec moale în loc de EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // numerele de conținut nu folosesc virgulă
end;

De ce e partea de parsare mai periculoasă decât partea de scriere?

Partea de parsare e mai periculoasă pentru că un parser legat de locale nu produce un număr greșit, ci aruncă. PLStrToFloat apelează StrToFloat, care ridică EConvertError când textul nu se potrivește cu separatorul de sistem. Pe un sistem cu zecimale cu virgulă, asta însemna că RecolorPage se întrerupea în clipa în care întâlnea un banal operator 0.5 g, deci eșuau toate paginile reale, nu doar cele exotice. RenderPageRegionToFile respingea propriul format de clip documentat "10.5,20.5,50.5,40.5", iar atributele de lungime SVG, culorile din exportul SVG, listele de vârfuri ale adnotărilor și valorile de soliditate ale output intent erau respinse sau înlocuite în tăcere cu implicite. O bibliotecă care merge perfect pe mașina dezvoltatorului și crapă la primul client din München e exact felul de cod care, ca și cazurile din articolul despre cod Delphi care merge din întâmplare, pare corect doar din cauza locului unde a fost testat

v3.539.33 a clasificat fiecare apel StrToFloat și TryStrToFloat după locul de unde vine input-ul lui. Operanzii de content stream, atributele SVG, șirurile de culoare ale painter-ului și listele separate prin virgulă de clip și vârfuri au toate o sintaxă fixă cu punct, deci trec acum prin PLTryStrToFloatInvariant, care taie spațiile, parsează cu PLInvariantFormatSettings și întoarce False pentru input gol, malformat sau non-finit în loc să ridice. O listă separată prin virgulă nu lasă loc de compromis, pentru că o virgulă nu poate fi în același timp delimiter de listă și marcă zecimală. Același pas a reparat și o scriere în afara limitelor: RenderPageRegionToFile stoca pe atunci o a cincea valoare de clip peste bufferul lui de patru elemente. Pentru pipeline-ul de recolorare descris în ghidul conversiei unui PDF într-un singur color space, rezultatul practic e că RecolorPage și RecolorDocument nu se mai întrerup pe un sistem cu zecimale cu virgulă. Valorile de regulă pe care un apelant le tastează în CheckDocumentPolicy sunt singurul caz de parsare care folosește helper-ul îngăduitor, din motivul explicat în secțiunea următoare

Ce se întâmplă dacă repari doar un capăt al unui dus-întors?

Repararea doar a unui capăt al unui dus-întors de locale rupe cod care mergea, de-asta schimbarea atributelor de structură din v3.539.32 a mutat împreună scriitorul și cititorul. Wrapper-ele SetStructElem* transmit numerele ca șiruri: SetStructElemBBox formatează patru valori într-un singur șir, îl stochează prin AddTagAttribute, iar scriitorul /A parsează mai târziu acel șir ca să decidă dacă devine număr, tablou sau name. Ambele capete foloseau locale-ul de sistem, deci pe un sistem cu zecimale cu virgulă dus-întorsul era autoconsistent. Bug-ul a apărut doar când un apelant a urmat documentația și a pasat "0.5" către AddTagAttribute: cititorul nu-l putea parsea și emitea name-ul PDF /0.5. Placeholder-ul PDF/VCR avea problema în oglindă, pentru că biblioteca genera GTS_BBox cu punct și apoi îl valida cu locale-ul înainte de salvare

Să schimbi doar scriitorul la punct ar fi fost mai rău decât să nu faci nimic, fiindcă fiecare valoare SetStructElem* ar fi eșuat apoi la cititorul legat de locale și s-ar fi degradat într-un name. Deci scriitorii folosesc acum PLDoubleToStrConst(v, 6), iar cititorul folosește noul PLTryStrToFloatLenient, care încearcă întâi forma cu punct și cade pe locale-ul de sistem. Un apelant cu locale cu virgulă care pasa "1,25" pe vremuri primește în continuare numărul 1.25. Compromisul e deliberat și documentat: pe un sistem german, "1.500" devenea un name pentru că StrToFloat respinge separatorii de mii, iar acum se citește ca 1.5, în timp ce șirurile literale NAN și INF nu mai sunt acceptate ca numere

PDFlibPas SetStructElemBBox și frații lui transmit numerele ca șiruri prin AddTagAttribute, iar scriitorul /A parsează acele șiruri înapoi, deci v3.539.32 a mutat ambele capete împreună: PLDoubleToStrConst scrie cu punct, iar PLTryStrToFloatLenient citește întâi cu punct, cu fallback pe locale, astfel încât un 1,25 pe locale cu virgulă se citește în continuare ca 1.25
Repararea doar a scriitorului ar fi degradat fiecare atribut de element structurat într-un name PDF, de-asta un dus-întors mută ambele capete împreună sau deloc
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // apelant cu zecimale cu virgulă
  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, înainte era /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // tot /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

Unde sunt opriți NaN și infinitul

AddPageMatrix, ScalePage și RedactRegion resping acum NaN și argumente infinite din față și întorc 0, pentru că niciun număr PDF nu le poate reprezenta. ScalePage refuza deja factorii de zero sau sub zero, dar NaN trece un test <= 0, deci o scală NaN ajungea până la formatter. În v3.539.26, formatter-ul acela apela în continuare Round pe NaN, ceea ce ridică EInvalidOp pe Win32, unde unitatea x87 nu maschează operațiile invalide; v3.539.31 a făcut PLDoubleToStrConst să scrie 0 pentru NaN ca ultimă linie de apărare, dar un zero într-o matrice e o transformare singulară, deci verificarea la nivel de API rămâne corecția reală. Două limite rămân în picioare din intenție. Șirurile de stare ale metafile-urilor se scriu și se citesc cu locale-ul în interiorul unui singur proces și nu-l părăsesc niciodată, deci au fost lăsate în pace. Iar un test care formatează 1E-5 prin calea elementelor de pagină trebuie să citească conținutul înainte ca stratul să fie rescris, pentru că re-emisia operanzilor la precizia documentului transformă pe drept valoarea aceea în 0

Dacă aplicația voastră ajunge la clienți în afara lumii cu zecimale cu punct, cel mai sigur obicei e cel pe care suita de test PDFlibPas îl folosește acum: rulați căile care produc PDF o dată cu FormatSettings.DecimalSeparator setat pe virgulă și scanați output-ul după virgule și exponenți. Articolul despre păstrarea preciziei zecimale parsate acoperă cealaltă jumătate a aceleiași povești, cum numerele citite dintr-un fișier existent își păstrează textul exact la salvare. Descărcările, referința completă API și build-ul de încercare sunt pe pagina de produs PDFlibPas Delphi PDF library