Articolo tecnico

Numeri PDF locale-neutral per Delphi con decimali a virgola

PDFlibPas, la losLab PDF Developer Library per Delphi, scrive ogni numero che mette in un content stream con separatore decimale a punto e senza esponente, qualunque cosa dicano le impostazioni regionali di Windows. Dalla v3.539.26 AddPageMatrix, ScalePage, DeskewPage, RedactRegion, l'output text-to-path e il ricoloraggio formattano gli operandi attraverso PLDoubleToStrConst, e dalla v3.539.33 i parser che rileggono quei numeri usano PLTryStrToFloatInvariant invece del locale di sistema. Su una macchina tedesca, francese o brasiliana lo stesso codice ora produce gli stessi byte di una statunitense, che è l'unico comportamento che un formato di file possa tollerare

Perché un locale con decimali a virgola corrompe un PDF senza errori?

Un locale con decimali a virgola corrompe un PDF in silenzio perché la virgola non è un carattere numerico nella sintassi PDF, quindi il danno si legge come token validi con significato sbagliato. Prima della correzione, PLFloatToStr era niente più che una chiamata nuda di FloatToStr, e FloatToStr segue FormatSettings.DecimalSeparator. Con separatore a virgola, AddPageMatrix(0.5, 0.5, 0, 0) scriveva 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 ammette cifre, un punto e un segno iniziale in un numero e nient'altro, quindi un parser del contenuto legge quella riga come il numero 0 seguito da un token sconosciuto ,5, e l'operatore cm finisce con operandi sbagliati. Niente eccezioni, niente log. La pagina semplicemente si renderizza con una matrice di trasformazione che è andata alla deriva, e risalire a ritroso da un disegno spostato a un'impostazione di locale è un pomeriggio miserabile

Il secondo difetto si nasconde dietro al primo. FloatToStr usa il formato ffGeneral, che passa alla notazione a esponente appena la grandezza scende sotto 1E-4, quindi un offset minuscolo usciva come 1E-5. Lo stesso §7.3.3 dichiara che il PDF non supporta la forma a esponente, il che significa che anche una macchina con locale USA poteva scrivere un operando non valido con un valore abbastanza piccolo. I test di regression di questa release bloccano entrambe le forme di fallimento: girano il separatore a virgola, chiamano l'API e scansionano il contenuto risultante alla ricerca di qualunque token che contenga una virgola o un esponente

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 := ',';   // simula un desktop de-DE
      Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
      // v3.539.26 e successive scrivono: 0.5 0 0 0.25 0.00001 12.75 cm
      // build precedenti scrivevano:     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 su un desktop con decimali a virgola scriveva 0,5 0 0 0,25 1E-5 12,75 cm prima della correzione, che un parser PDF legge come il numero 0 più token sconosciuti, lasciando cm con operandi sbagliati e la pagina trasformata in silenzio, mentre PLDoubleToStrConst scrive decimali a punto validi
La virgola non è un carattere numerico nella sintassi PDF, quindi il danno si legge come token validi con significato sbagliato — e gli esponenti ffGeneral come 1E-5 erano non validi su ogni locale, non solo su quelli a virgola

Due tipi di numeri, due famiglie di helper

La correzione in PDFlibPas è una separazione rigorosa: i numeri mostrati alle persone possono seguire il locale, i numeri scritti per una macchina mai. PLFloatToStr e PLStrToFloat restano in PDFlibExtra.pas per il testo rivolto all'utente, e la loro dichiarazione ora porta un commento che dice esattamente questo. Tutto ciò che finisce come sintassi PDF passa per PLDoubleToStrConst con un numero fisso di decimali scelto per il compito: sei per le matrici, quattro per le coordinate e le correzioni TJ, tre per i colori e i rettangoli FDF. L'audit della v3.539.26 ha toccato più call site di quanto suggerisse la segnalazione originale:

  • AddPageMatrix, ScalePage e DeskewPage, che tutti prepongono un cm al contenuto esistente della pagina
  • I costruttori di elementi di pagina che emettono reset Tm, avanzamenti TJ e trasformazioni cm
  • Matrici di posizionamento dei glyph e punti di outline nel convertitore text-to-path
  • Il box di fill nero che RedactRegion prepone, i valori /Rect nell'export FDF e gli operandi che il ricoloraggio scrive

PLDoubleToStrConst è un formattatore scritto a mano piuttosto che un wrapper attorno a FloatToStrF, e tre delle sue proprietà contano qui. Scrive sempre un punto e toglie gli zeri finali, così 0.5 resta 0.5 invece di 0.500000. Non scrive mai un esponente per input finito. E un valore diverso da zero più piccolo della precisione richiesta conserva le sue cifre significative invece di collassare a zero, così PLDoubleToStrConst(1E-9, 6) restituisce 0.000000001; solo valori sotto circa 5E-16 diventano 0. Questa ultima regola esiste perché arrotondare a zero un fattore di scala minuscolo trasforma una matrice valida in una singolare, che è un bug peggiore di quello in corso di correzione

PDFlibPas divide la formattazione dei numeri in due: PLFloatToStr e PLStrToFloat restano legati al locale per il testo rivolto all'utente, mentre PLDoubleToStrConst e PLTryStrToFloatInvariant formattano tutto ciò che diventa sintassi PDF con punto, niente esponente e una precisione fissa per compito di sei, quattro o tre decimali
Il formattatore invariante è scritto a mano di proposito: toglie gli zeri finali, non scrive mai un esponente e conserva le cifre significative dei valori minuscoli, perché arrotondare a zero un fattore di scala renderebbe singolare una matrice valida
uses
  PDFlibExtra;

procedure CheckNumberHelpers;
var
  V: Double;
begin
  // Output per macchina: decimale a punto, niente esponente, zeri finali tolti
  Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
  Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
  Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235');  // conserva 4 cifre significative
  Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');

  // Input da macchina: fallimento morbido invece di EConvertError
  Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
  Assert(not PLTryStrToFloatInvariant('0,5', V));   // i numeri del contenuto non usano mai la virgola
end;

Perché il lato parsing è più pericoloso del lato scrittura?

Il lato parsing è più pericoloso perché un parser legato al locale non produce un numero sbagliato, lancia. PLStrToFloat chiama StrToFloat, che solleva EConvertError quando il testo non corrisponde al separatore di sistema. Su un sistema a decimali con virgola questo significava che RecolorPage abortiva nell'istante in cui incontrava un banale operatore 0.5 g, quindi falliva ogni pagina reale, non solo quelle esotiche. RenderPageRegionToFile rifiutava il proprio formato di clip documentato "10.5,20.5,50.5,40.5", e gli attributi di lunghezza SVG, i colori dell'export SVG, le liste di vertici delle annotazioni e i valori di solidità degli output intent venivano rifiutati o silenziosamente sostituiti dai default. Una libreria che funziona perfettamente sulla macchina dello sviluppatore e fallisce sul primo cliente a Monaco è esattamente il tipo di codice che, come i casi in l'articolo sul codice Delphi che funziona per caso, appare corretto solo per dove è stato testato

La v3.539.33 ha classificato ogni chiamata a StrToFloat e TryStrToFloat in base a da dove arriva l'input. Gli operandi dei content stream, gli attributi SVG, le stringhe di colore dei painter e le liste di clip e vertici separate da virgole hanno tutti una sintassi a punto fissa, quindi ora passano per PLTryStrToFloatInvariant, che ripulisce il testo, lo analizza con PLInvariantFormatSettings e restituisce False per input vuoto, malformato o non finito invece di lanciare. Una lista separata da virgole non lascia spazio a compromessi, perché una virgola non può essere insieme delimitatore di lista e separatore decimale. Lo stesso passaggio ha anche corretto una scrittura fuori dai limiti: RenderPageRegionToFile memorizzava un quinto valore di clip oltre il suo buffer a quattro elementi. Per la pipeline di ricoloraggio descritta in la guida alla conversione di un PDF in un unico color space, il risultato pratico è che RecolorPage e RecolorDocument non abortiscono più su un sistema a virgola decimale. I valori delle regole che un chiamante digita in CheckDocumentPolicy sono l'unico caso di parsing che usa invece l'helper permissivo, per la ragione che spiega la sezione successiva

Che cosa succede se correggi una sola estremità di un round trip?

Correggere una sola estremità di un round trip di locale rompe codice che funzionava, ed è per questo che la modifica degli attributi di struttura della v3.539.32 ha spostato insieme lo scrittore e il lettore. I wrapper SetStructElem* passano i numeri come stringhe: SetStructElemBBox formatta quattro valori in una stringa, la memorizza attraverso AddTagAttribute, e lo scrittore /A in seguito analizza quella stringa per decidere se diventa un numero, un array o un name. Entrambe le estremità usavano il locale di sistema, quindi su un sistema a virgola decimale il round trip era autoconsistente. Il bug si mostrava solo quando un chiamante seguiva la documentazione e passava "0.5" a AddTagAttribute: il lettore non riusciva a analizzarla ed emetteva il name PDF /0.5. Il placeholder PDF/VCR aveva il problema speculare, perché la libreria generava GTS_BBox con il punto e poi lo validava con il locale prima di salvare

Cambiare solo lo scrittore al punto sarebbe stato peggio che non fare nulla, dato che ogni valore SetStructElem* avrebbe poi fallito il lettore legato al locale e sarebbe degradato in name. Quindi gli scrittori ora usano PLDoubleToStrConst(v, 6), e il lettore usa il nuovo PLTryStrToFloatLenient, che prova prima la forma a punto e ripiega sul locale di sistema. Un chiamante con locale a virgola che passava "1,25" in passato ottiene ancora il numero 1.25. Il trade-off è deliberato e documentato: su un sistema tedesco "1.500" diventava un name perché StrToFloat rifiuta i separatori delle migliaia, e ora si legge come 1.5, mentre le stringhe letterali NAN e INF non sono più accettate come numeri

PDFlibPas SetStructElemBBox e i suoi fratelli passano i numeri come stringhe attraverso AddTagAttribute, e lo scrittore /A analizza quelle stringhe, quindi la v3.539.32 ha spostato insieme entrambe le estremità: PLDoubleToStrConst scrive con punto e PLTryStrToFloatLenient legge prima il punto con fallback al locale, così un 1,25 con locale a virgola si legge ancora come 1.25
Correggere solo lo scrittore avrebbe degradato ogni attributo di elemento strutturato in un name PDF, per questo un round trip sposta insieme entrambe le estremità o nessuna
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
begin
  FormatSettings.DecimalSeparator := ',';   // chiamante con decimali a virgola
  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, prima /0.5
    Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // resta /StartIndent 1.25
    Lib.DrawText(20, 20, 'figure');
    Lib.EndTag;
    Lib.SaveToFile('tagged.pdf');
  finally
    Lib.Free;
  end;
end;

Dove NaN e infinito vengono fermati

AddPageMatrix, ScalePage e RedactRegion ora rifiutano NaN e argomenti infiniti all'ingresso e restituiscono 0, perché nessun numero PDF può rappresentarli. ScalePage rifiutava già fattori zero o negativi, ma NaN passa un test <= 0, quindi una scala NaN arrivava fino al formattatore. Nella v3.539.26 quel formattatore chiamava ancora Round su NaN, che solleva EInvalidOp su Win32 dove l'unità x87 non maschera le operazioni non valide; la v3.539.31 ha fatto sì che PLDoubleToStrConst scriva 0 per NaN come ultima linea di difesa, ma uno zero in una matrice è una trasformazione singolare, quindi il controllo a livello API resta la vera correzione. Due confini restano in vigore di proposito. Le stringhe di stato dei metafile vengono scritte e lette con il locale dentro un solo processo e non lo lasciano mai, quindi sono state lasciate in pace. E un test che formatta 1E-5 attraverso il percorso degli elementi di pagina deve leggere il contenuto prima che il layer venga riscritto, perché riemettere gli operandi alla precisione del documento trasforma legittimamente quel valore in 0

Se la tua applicazione arriva a clienti fuori dal mondo dei decimali con punto, l'abitudine più sicura è quella che la suite di test di PDFlibPas ora usa: esegui i percorsi che producono PDF una volta con FormatSettings.DecimalSeparator impostato a virgola e scansiona l'output alla ricerca di virgole ed esponenti. L'articolo sulla conservazione della precisione decimale analizzata copre l'altra metà della stessa storia, come i numeri letti da un file esistente conservano il loro testo esatto al salvataggio. Download, il riferimento API completo e la build di prova sono sulla pagina di prodotto della PDFlibPas Delphi PDF library