Artículo técnico

Formato de números PDF invariante en Delphi con HotPDF

Todo número real que llega a un archivo PDF tiene que escribirse con punto, piense lo que piense el sistema operativo que el separador decimal es. El HotPDF Delphi Component enruta todos por una sola capa de conversión invariante en HPDFTypes, así que una workstation alemana, francesa o brasileña emite 0.5 0 0 0.5 5.5 7.25 cm y no la variante con coma que ningún lector conforme va a parsear

Eso suena a un fix de dos líneas. No lo fue. El defecto había estado camuflado por años con un side effect, reapareció en tres subsistemas que nadie piensa como código de formato de números, desaparecía con un compilador y sobrevivía con otro, y la última capa no tenía nada que ver con el separador decimal

¿Por qué una locale de coma decimal corrompe un content stream de PDF?

Porque ISO 32000-1 §7.3.3 define un objeto numérico como un signo opcional, dígitos decimales y a lo sumo un punto. Una coma no es parte de esa producción, y según §7.2.2 tampoco es un delimitador, así que el lector no ve dos operandos donde querías uno. Ve un token que falla al lexear, y el operador que lo contenía se descarta. Un solo 0,5 en una matriz de graphics state se lleva todo el bloque q ... Q que lo rodea. El fallo también tiene una forma característica en el campo: nunca se reproduce en la máquina del desarrollador, llega como ticket de soporte de un país, y se lee como un bug de renderizado más que de sintaxis, porque la mayoría de los visores descartan el operador malformado en silencio y renderizan el resto de la página

Diagrama de corrupción por locale en un content stream de PDF producido por HotPDF: un proceso host con coma decimal emite 0,5 dentro de un operador rg y un cm, la gramática numérica de ISO 32000-1 solo acepta punto así que el token falla al lexear, y el visor descarta en silencio los operadores junto con el bloque q Q que los rodea
Una coma no es parte de la producción numérica ni un delimitador, así que un operando malformado elimina el rectángulo de redacción y la página cosida sin ningún error
// Proceso host corriendo 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);

// Lo que el /Contents anexado solía llevar:
//   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 no tiene producción para "0,5", así que el rectángulo
// de redacción y la página cosida desaparecen sin ningún error

El side effect que ocultó el defecto por años

HotPDF escribe la mayoría de los números del camino de creación por _CutFloat, y las revisiones tempranas de esa función hacían algo silenciosamente hostil: en cada llamada asignaba '.' al global FormatSettings.DecimalSeparator del proceso host. Eso corrompe el estado de locale de la aplicación que hospeda el componente y no es thread safe, pero la consecuencia interesante es la tercera. Una vez generado cualquier contenido, el separador del proceso era un punto, así que todo FloatToStr y StrToFloat pelado en el resto de la librería quedaba accidentalmente correcto. El orden de crear-y-luego-editar que usa casi todo sample por lo tanto pasaba, y lo que enmascaraba era todo camino que corre antes de la primera llamada a _CutFloat: los editores de documento cargado StitchLoadedPage y RedactLoadedRect, los stamps de texto e imagen, las watermarks compuestas, el dibujo de barcodes con Format('%.2f'), el parser de default appearance /DA y todo el subsistema de fuentes Type 1

Línea de tiempo del side effect de _CutFloat en HotPDF: la primera llamada asigna un punto al FormatSettings.DecimalSeparator global, lo que vuelve accidentalmente correcto todo FloatToStr posterior mientras los editores de documento cargado StitchLoadedPage y RedactLoadedRect, el camino de barcodes, el parser /DA y el subsistema de fuentes Type 1 siguen rotos
El orden de crear-y-luego-editar que usa todo sample mantuvo invisible el defecto, porque todo lo posterior a la primera conversión heredaba el separador global volteado

El parseo Type 1 fallaba de la forma más fea de todas. FontMatrix, BlueValues, los stem hints y los números reales dentro de los charstrings Type 2 son reals PostScript escritos con punto, según la especificación Adobe Type 1 Font Format. Parsear 0.001 bajo una locale de coma levanta EConvertError, un try/except circundante lo tragaba, y la matriz de fuente quedaba en cero. Todos los glyphs de esa fuente después colapsaban, lo que parece un problema de fuentes más que de locale y te manda a debuggear el renderizado de glyphs embebidos por una tarde

Una capa de conversión en lugar de parches dispersos

La regla que HotPDF sigue ahora es tajante: todo número que se escriba o se lea de sintaxis PDF, PostScript, JSON o JavaScript pasa por una conversión invariante explícita, y ningún código depende de un side effect de FormatSettings global. HPDFTypes es dueño de las cuatro conversiones, más el record de settings que comparten y un helper de precisión fija para el camino de barcodes

// Lib/HPDFTypes.pas - la única superficie de conversión 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;

Dos units ya llevaban una función privada InvariantFormatSettings propia, HPDFContentStream y HPDFTextExtraction, y ambas se redujeron a un forward de una línea en lugar de quedar como implementaciones paralelas. El subsistema Type 1 es la excepción deliberada: no lleva dependencia VCL y no referencia HPDFTypes, así que mantiene un espejo local llamado HPDFType1Core.Type1StrToFloat con semántica idéntica, sobre la teoría de que una función duplicada de diez líneas es una responsabilidad menor que arrastrar la capa de gráficos a un parser de fuentes. Otros dos lugares se dejaron solos a propósito. HPDFHTMLImport parsea longitudes CSS intercambiando a propósito el punto por el separador de la locale y parseando después con la locale, que es lo correcto para ese input, y HPDFType1CB.GlyphCommands junto con las strings FontBBox son API pública que los callers leen de vuelta con su propio StrToFloat, donde producir y consumir en la misma locale es autoconsistente y una salida invariante rompería a todo caller en una región de coma

¿Qué garantiza HPDF_HAS_FORMAT_SETTINGS entre compiladores?

Garantiza que el camino invariante realmente compila en todo toolchain soportado, que es lo que el gate de versión obvio no hacía. Delphi XE2 introdujo TFormatSettings.Create y los overloads de FloatToStr / TryStrToFloat que toman un TFormatSettings, así que el primer instinto es escribir {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} en cada call site. Ese instinto tiene un punto ciego: HotPDF.inc define D2009+ bajo Free Pascal pero nunca define XE2+, porque XE2+ responde una pregunta sobre números de versión de Delphi, mientras que Free Pascal 3.2.2 trae esos overloads igual. Cada uno de esos gates por lo tanto degeneraba a la rama dependiente de locale en builds FPC, en silencio, exactamente en las plataformas de donde venían los reportes originales. HPDF_HAS_FORMAT_SETTINGS se define tanto en el bloque FPC como en el bloque XE2+ de HotPDF.inc, y es el único símbolo que prueban los helpers de números

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

Una rama de compilador sobrevive dentro de la función, porque Free Pascal no tiene TFormatSettings.Create y hay que partir de DefaultFormatSettings. Solo los dos separadores de números alimentan las conversiones, así que los campos de fecha y moneda conservan lo que dé la locale actual. Delphi pre-XE2 no tiene overloads en absoluto, y ahí los helpers caen a convertir con la locale del proceso y reescribir después el carácter separador en la string resultante, que es feo pero nunca toca estado global

La tercera capa: exponentes que SVG permite y PDF no

Arreglar el separador decimal es necesario y no suficiente. W3C SVG 1.1 §4.2 admite un exponente en su producción numérica y el markup XPS hace lo mismo, así que 1E-6 es una coordenada perfectamente válida a la entrada, mientras que ISO 32000-1 §7.3.3 no admite exponente alguno. Un valor que sobrevive al formato invariante puede todavía aterrizar en un content stream como 1E-6 y ser rechazado, y FloatToStrF en formato fijo puede él mismo caer a forma de exponente una vez que el valor sale del rango de dígitos de la RTL. HPDFSafeSVG.SVGFloat por lo tanto hace su propio trabajo después de la conversión invariante: manda a cero todo lo que esté por debajo de 1.0E-7, después, si sobrevive una E, quita el exponente y mueve el punto decimal a mano, rellenando con ceros del lado que el corrimiento requiera. SVGFinite guarda la entrada rechazando NaN y cualquier cosa fuera de ±1.0E30 antes de que llegue al formatter, y SVGReadNumber con SVGSkipSeparators maneja la dirección de entrada carácter por carácter en lugar de delegar en la RTL

Pipeline que muestra cómo HotPDF quita exponentes antes de escribir operandos PDF: SVGFinite rechaza NaN y valores más allá de 1.0E30, la conversión invariante deja un punto, las magnitudes diminutas se mandan a cero, y la salida de HPDFFloatToInvariantStr con una E sobreviviente se le quita el exponente y se corre el punto decimal a mano
SVG 1.1 admite exponente e ISO 32000-1 no, así que el límite de conversión reescribe 1E-6 en notación decimal simple que un lector conforme puede parsear
// Lib/HPDFSafeSVG.pas - notación decimal para operandos 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);
  // ... corre el punto decimal por ExponentValue, rellenando con '0'
  // a la izquierda o la derecha, y después reengancha el signo
end;

Ese es el mismo TSVGBuffer que alimenta la exportación de páginas a SVG y su manejo de graphics state, y la misma disciplina numérica aplica del lado de importación cuando se convierten documentos XPS y OXPS a PDF a través de HPDFCreateXPSDocumentHandler y del miembro hdfXPS de THPDFHandledDocumentFormat. Los formatos de markup son generosos con la sintaxis de números. El content stream de PDF no, y el límite de conversión es donde esa diferencia tiene que resolverse

Tests de regresión que reproducen la locale en lugar de asumirla

Un bug de locale que solo aparece en la máquina de otro vale la pena fijarlo con tests que ponen ellos mismos la locale, y HotPDF lleva dos. LoadedEditsEmitInvariantNumbersUnderCommaLocale asigna una coma a FormatSettings.DecimalSeparator antes del primer edit, corre RedactLoadedRect, StampLoadedPageText, StitchLoadedPage y un stamp compuesto, después lee de vuelta los bytes del contenido de la página y afirma tanto que las strings de operador exactas están presentes como que el buffer no contiene ninguna coma en ningún lado. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale cubre la otra mitad y el pecado original: maneja el camino de creación bajo la misma locale de coma, incluido DirectDrawBarcode con su formato fijo de dos decimales, y después afirma que el content stream no lleva coma y que FormatSettings.DecimalSeparator sigue siendo una coma cuando EndDoc vuelve

Esa segunda aserción es la que habría atrapado el defecto original el mismo día en que se escribió, porque prueba lo que la librería le hace a su host y no solo lo que escribe en disco. La lección se generaliza más allá de PDF: cualquier formato con una gramática léxica fija, incluidos PostScript, JSON y JavaScript, necesita una conversión invariante explícita en el límite. Estirar la mano a un setting global para obtener la respuesta correcta funciona hasta que alguien llama tu código en otro orden, en otro thread o desde otro compilador, y para entonces el fallo parece cualquier cosa menos un problema de formato

La capa de formato de números que se describe acá viaja como parte del HotPDF Delphi Component para Delphi y C++Builder, junto con los caminos de edición de documento cargado, barcodes, exportación SVG y conversión XPS que dependen de ella