Articolo tecnico

Formattazione numerica PDF invariante in Delphi con HotPDF

Ogni numero reale che arriva in un file PDF va scritto con il punto, qualunque cosa il sistema operativo pensi che sia il separatore decimale. La HotPDF Delphi Component instrada tutti attraverso un unico layer di conversione invariante in HPDFTypes, così una workstation tedesca, francese o brasiliana emette 0.5 0 0 0.5 5.5 7.25 cm e non la variante con la virgola che nessun lettore conforme analizzerà

Sembra una correzione di due righe. Non lo era. Il difetto era stato mimetizzato per anni da un effetto collaterale, è riapparso in tre sottosistemi che nessuno considera codice di formattazione numerica, è sparito con un compilatore e sopravvissuto con un altro, e il suo ultimo strato non c'entrava nulla con il separatore decimale

Perché una locale con la virgola decimale corrompe un content stream PDF?

Perché ISO 32000-1 §7.3.3 definisce un oggetto numerico come segno opzionale, cifre decimali e al massimo un punto. La virgola non fa parte di quella produzione, e secondo la §7.2.2 non è nemmeno un delimitatore, quindi il lettore non vede due operandi dove ne intendevi uno. Vede un token che non riesce a fare lex, e l'operatore che lo conteneva viene scartato. Un singolo 0,5 in una matrice dello stato grafico si porta via l'intero blocco q ... Q che lo circonda. Il fallimento ha anche una forma caratteristica sul campo: non si riproduce mai sulla macchina dello sviluppatore, arriva come ticket di supporto da un singolo paese, e si legge come un bug di rendering invece che come un errore di sintassi, perché la maggior parte dei viewer scarta in silenzio l'operatore malformato e renderizza il resto della pagina

Diagramma della corruzione da locale in un content stream PDF prodotto da HotPDF: un processo host con la virgola decimale emette 0,5 dentro un operatore rg e un cm, la grammatica dei numeri di ISO 32000-1 accetta solo il punto quindi il token non fa lex, e il viewer scarta in silenzio gli operatori insieme al blocco q Q che li circonda
Una virgola non fa parte della produzione numerica e non è un delimitatore, quindi un singolo operando malformato rimuove il rettangolo di redaction e la pagina cucita senza alcun errore
// Processo host in esecuzione con DecimalSeparator = ','
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);

// Cosa conteneva in origine il /Contents aggiunto:
//   0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
//   q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3 non ha una produzione per "0,5", quindi il rettangolo
// di redaction e la pagina cucita spariscono entrambi senza un errore

L'effetto collaterale che ha nascosto il difetto per anni

HotPDF scrive la maggior parte dei numeri del percorso di creazione attraverso _CutFloat, e le prime revisioni di quella funzione facevano qualcosa di ostile in silenzio: a ogni singola chiamata assegnava '.' alla FormatSettings.DecimalSeparator globale del processo host. Questo corrompe lo stato di locale dell'applicazione che ospita il componente e non è thread safe, ma la conseguenza interessante è la terza. Una volta generato qualsiasi contenuto, il separatore del processo era un punto, quindi ogni FloatToStr e StrToFloat nudo altrove nella libreria diventava accidentalmente corretto. L'ordine crea-poi-modifica che quasi ogni esempio usa quindi passava, e ciò che mascherava era ogni percorso che gira prima della prima chiamata a _CutFloat: gli editor di documenti caricati StitchLoadedPage e RedactLoadedRect, i timbri di testo e immagine, le watermark composite, il disegno dei barcode attraverso Format('%.2f'), il parser di default appearance /DA e l'intero sottosistema dei font Type 1

Linea temporale dell'effetto collaterale di _CutFloat in HotPDF: la prima chiamata assegna un punto alla FormatSettings.DecimalSeparator globale, il che rende accidentalmente corretto ogni FloatToStr nudo successivo mentre gli editor di documenti caricati StitchLoadedPage e RedactLoadedRect, il percorso barcode, il parser /DA e il sottosistema dei font Type 1 restano rotti
L'ordine crea-poi-modifica che ogni esempio usa ha tenuto il difetto invisibile, perché tutto ciò che veniva dopo la prima conversione ereditava il separatore globale scambiato

Il parsing Type 1 falliva nel modo più brutto di tutti. FontMatrix, BlueValues, gli stem hint e i numeri reali dentro le charstring Type 2 sono real PostScript scritti con il punto, secondo la specifica Adobe Type 1 Font Format. Analizzare 0.001 sotto una locale con la virgola solleva EConvertError, un try/except che lo racchiudeva lo inghiottiva, e la matrice del font restava a zero. Ogni glifo di quel font poi collassava, il che sembra un problema di font invece che di locale e ti manda a debbuggare il rendering dei glifi incorporati per un pomeriggio

Un unico layer di conversione invece di patch sparse

La regola che HotPDF segue ora è brutale: qualsiasi numero scritto dentro o letto fuori dalla sintassi PDF, PostScript, JSON o JavaScript passa attraverso una conversione invariante esplicita, e nessun codice da nessuna parte dipende da un effetto collaterale della FormatSettings globale. HPDFTypes possiede le quattro conversioni, più il record di impostazioni che condividono e un helper a precisione fissa per il percorso dei barcode

// Lib/HPDFTypes.pas - l'unica superficie di conversione invariante
function HPDFInvariantFormatSettings: TFormatSettings;

function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
  out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
  const Default: Extended): Extended;

function HPDFFormatInvariant2(Value: Extended): string;

Due unit avevano già una loro funzione privata InvariantFormatSettings, HPDFContentStream e HPDFTextExtraction, ed entrambe sono state ridotte a un forward di una riga invece di restare implementazioni parallele. Il sottosistema Type 1 è l'eccezione deliberata: non porta dipendenze VCL e non referenzia HPDFTypes, quindi tiene un mirror locale chiamato HPDFType1Core.Type1StrToFloat con semantica identica, sulla teoria che una funzione duplicata di dieci righe sia un rischio minore che trascinare il layer grafico dentro un parser di font. Altri due punti sono stati lasciati da soli di proposito. HPDFHTMLImport analizza le lunghezze CSS scambiando deliberatamente il punto con il separatore di locale e poi analizzando con la locale, che è giusto per quell'input, e HPDFType1CB.GlyphCommands insieme alle stringhe FontBBox è API pubblica che i chiamanti rileggono con il loro StrToFloat, dove produrre e consumare nella stessa locale è auto-coerente e un output invariante romperebbe ogni chiamante in una regione con la virgola

Cosa garantisce HPDF_HAS_FORMAT_SETTINGS tra i compilatori?

Garantisce che il percorso invariante compili davvero in ogni toolchain supportata, cosa che l'ovvio gate di versione non faceva. Delphi XE2 ha introdotto TFormatSettings.Create e gli overload di FloatToStr / TryStrToFloat che prendono una TFormatSettings, quindi il primo istinto è scrivere {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} a ogni call site. Quell'istinto ha un punto cieco: HotPDF.inc definisce D2009+ sotto Free Pascal ma non definisce mai XE2+, perché XE2+ risponde a una domanda sui numeri di versione di Delphi, mentre Free Pascal 3.2.2 fornisce quegli overload comunque. Quindi ognuno di quei gate degradava al ramo dipendente dalla locale nelle build FPC, in silenzio, esattamente sulle piattaforme da cui venivano i report originali. HPDF_HAS_FORMAT_SETTINGS è definito sia nel blocco FPC sia nel blocco XE2+ di HotPDF.inc, ed è l'unico simbolo che gli helper numerici testano

{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
  Result := DefaultFormatSettings;   // nessun TFormatSettings.Create in FPC
{$ELSE}
  Result := TFormatSettings.Create;
{$ENDIF}
  Result.DecimalSeparator := '.';
  Result.ThousandSeparator := ',';
end;
{$ENDIF}

Un ramo di compilatore sopravvive dentro la funzione, perché Free Pascal non ha TFormatSettings.Create e devi partire da DefaultFormatSettings. Solo i due separatori numerici alimentano le conversioni, quindi i campi data e valuta tengono qualunque cosa fornisca la locale corrente. Il Delphi pre-XE2 non ha proprio overload, e lì gli helper ripiegano convertendo con la locale del processo e poi riscrivendo il carattere separatore nella stringa risultante, che è brutto ma non tocca mai lo stato globale

Il terzo strato: gli esponenti che SVG permette e il PDF no

Sistemare il separatore decimale è necessario e non sufficiente. W3C SVG 1.1 §4.2 ammette un esponente nella sua produzione di numeri e il markup XPS fa lo stesso, quindi 1E-6 è una coordinata perfettamente valida in ingresso, mentre ISO 32000-1 §7.3.3 non ammette alcun esponente. Un valore che sopravvive alla formattazione invariante può comunque finire in un content stream come 1E-6 ed essere rifiutato, e FloatToStrF in formato fisso può a sua volta ripiegare sulla forma esponenziale appena il valore esce dal range di cifre della RTL. HPDFSafeSVG.SVGFloat quindi fa il suo lavoro dopo la conversione invariante: porta a zero qualsiasi cosa sotto 1.0E-7, poi, se sopravvive una E, rimuove l'esponente e sposta il punto decimale a mano, riempiendo di zeri dal lato che lo shift richiede. SVGFinite protegge l'ingresso rifiutando NaN e qualsiasi cosa fuori da ±1.0E30 prima che raggiunga il formattatore, e SVGReadNumber con SVGSkipSeparators gestisce la direzione in ingresso carattere per carattere invece di demandare alla RTL

Pipeline che mostra come HotPDF rimuove gli esponenti prima di scrivere gli operandi PDF: SVGFinite rifiuta NaN e valori oltre 1.0E30, la conversione invariante lascia un punto, le magnitudini minuscole vanno a zero, e l'output di HPDFFloatToInvariantStr con una E sopravvissuta ha l'esponente rimosso e il punto decimale spostato a mano
SVG 1.1 ammette un esponente e ISO 32000-1 no, quindi il confine di conversione riscrive 1E-6 in notazione decimale semplice che un lettore conforme può analizzare
// Lib/HPDFSafeSVG.pas - notazione decimale per gli operandi PDF
if Abs(Value) < 0.0000001 then
  Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
  ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
  SetLength(S, ExponentAt - 1);
  // ... sposta il punto decimale di ExponentValue, riempiendo con '0'
  // a sinistra o a destra, poi riattacca il segno
end;

È lo stesso TSVGBuffer che alimenta l'export SVG di pagina e la sua gestione dello stato grafico, e la stessa disciplina numerica vale sul lato import quando si convertono documenti XPS e OXPS in PDF attraverso HPDFCreateXPSDocumentHandler e il membro hdfXPS di THPDFHandledDocumentFormat. I formati di markup sono generosi sulla sintassi dei numeri. Il content stream PDF non lo è, ed è al confine di conversione che quella differenza va risolta

Test di regressione che riproducono la locale invece di assumerla

Un bug di locale che appare solo sulla macchina di qualcun altro vale la pena inchiodarlo con test che impostano loro stessi la locale, e HotPDF ne porta due. LoadedEditsEmitInvariantNumbersUnderCommaLocale assegna una virgola a FormatSettings.DecimalSeparator prima della prima modifica, esegue RedactLoadedRect, StampLoadedPageText, StitchLoadedPage e un timbro composito, poi rilegge i byte di contenuto della pagina e asserisce sia che le stringhe esatte degli operatori siano presenti sia che il buffer non contenga virgole da nessuna parte. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale copre l'altra metà e il peccato originale: guida il percorso di creazione sotto la stessa locale con la virgola, incluso DirectDrawBarcode con la sua formattazione fissa a due decimali, poi asserisce che il content stream non porti virgole e che FormatSettings.DecimalSeparator sia ancora una virgola quando EndDoc ritorna

Quella seconda asserzione è quella che avrebbe beccato il difetto originale il giorno stesso in cui fu scritto, perché testa ciò che la libreria fa al suo host e non solo ciò che scrive su disco. La lezione generalizza oltre il PDF: qualsiasi formato con una grammatica lessicale fissa, PostScript, JSON e JavaScript inclusi, ha bisogno di una conversione invariante esplicita al confine. Appoggiarsi a un'impostazione globale per ottenere la risposta giusta funziona finché qualcuno non chiama il tuo codice in un ordine diverso, su un thread diverso o da un compilatore diverso, e a quel punto il fallimento sembra qualsiasi cosa tranne un problema di formattazione

Il layer di formattazione numerica descritto qui fa parte della HotPDF Delphi Component per Delphi e C++Builder, insieme ai percorsi di modifica dei documenti caricati, barcode, export SVG e conversione XPS che dipendono da esso