Todo número real que llegue a un archivo PDF tiene que escribirse con punto, piense el sistema operativo lo que piense sobre el separador decimal. El HotPDF Delphi Component enruta todos ellos por una única capa de conversión invariante en HPDFTypes, así que una estación de trabajo alemana, francesa o brasileña emite 0.5 0 0 0.5 5.5 7.25 cm y no la variante con comas que ningún lector conforme va a parsear
Suena a fix de dos líneas. No lo era. El defecto llevaba años camuflado por un efecto secundario, reapareció en tres subsistemas que nadie considera código de formato de números, desapareció bajo un compilador y sobrevivió bajo otro, y la última capa no tenía nada que ver con el separador decimal
¿Por qué una locale con coma decimal corrompe un content stream de PDF?
Porque la 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 forma parte de esa producción, y según la §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 lexar, y el operador que lo contenía se descarta. Un único 0,5 en una matriz de estado gráfico se lleva por delante todo el bloque q ... Q que lo rodea. El fallo además tiene una forma característica en el campo: nunca se reproduce en la máquina del desarrollador, llega como ticket de soporte desde un país, y se lee como un bug de render en lugar de un error de sintaxis, porque la mayoría de visores sueltan en silencio el operador malformado y renderizan el resto de la página
// Proceso host ejecutándose 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 solía llevar el /Contents añadido:
// 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
//
// La 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 ambos sin ningún error
El efecto secundario que escondió el defecto durante años
HotPDF escribe la mayoría de los números del camino de creación a través de _CutFloat, y las primeras revisiones de esa función hacían algo silenciosamente hostil: en cada llamada asignaba '.' al FormatSettings.DecimalSeparator global del proceso host. Eso corrompe el estado de locale de la aplicación que aloja 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 crear-y-luego-editar que usa casi todos los ejemplos por tanto pasaba, y lo que enmascaraba era todo camino que corre antes de la primera llamada a _CutFloat: los editores de documentos cargados StitchLoadedPage y RedactLoadedRect, los stamps de texto e imagen, las marcas de agua compuestas, el dibujo de códigos de barras con Format('%.2f'), el parser de apariencia por defecto /DA y todo el subsistema de fuentes Type 1
El parseo de 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 reales PostScript escritos con punto, según la especificación Adobe Type 1 Font Format. Parsear 0.001 bajo una locale con coma lanza EConvertError, un try/except circundante lo tragaba, y la matriz de fuente se quedaba en cero. Cada glifo de esa fuente colapsaba entonces, lo que parece un problema de fuentes más que de locale y te manda a depurar el render de glifos embebidos durante una tarde
Una capa de conversión en lugar de parches dispersos
La regla que sigue ahora HotPDF es contundente: cualquier número escrito en o leído de sintaxis PDF, PostScript, JSON o JavaScript pasa por una conversión invariante explícita, y ningún código depende de un efecto secundario de FormatSettings global. HPDFTypes es dueño de las cuatro conversiones, más el registro de ajustes que comparten y un helper de precisión fija para el camino de códigos de barras
// 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 unidades ya llevaban una función privada InvariantFormatSettings propia, HPDFContentStream y HPDFTextExtraction, y ambas quedaron reducidas a un forward de una línea en lugar de seguir como implementaciones paralelas. El subsistema Type 1 es la excepción deliberada: no lleva dependencia VCL y no referencia HPDFTypes, así que conserva un espejo local llamado HPDFType1Core.Type1StrToFloat con semántica idéntica, con 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 sitios se dejaron solos a propósito. HPDFHTMLImport parsea longitudes CSS intercambiando deliberadamente el punto por el separador de locale y luego parseando con la locale, que es lo correcto para esa entrada, y HPDFType1CB.GlyphCommands junto con las cadenas FontBBox son API pública que los callers leen con su propio StrToFloat, donde producir y consumir en la misma locale es autoconsistente y la salida invariante rompería a cada caller en una región de comas
¿Qué garantiza HPDF_HAS_FORMAT_SETTINGS entre compiladores?
Garantiza que el camino invariante compila de verdad en toda toolchain soportada, que es lo que la puerta de versión obvia no hacía. Delphi XE2 introdujo TFormatSettings.Create y las sobrecargas 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 a una pregunta sobre números de versión de Delphi, mientras que Free Pascal 3.2.2 trae esas sobrecargas igualmente. Cada una de esas puertas degradaba por tanto a la rama dependiente de locale en builds FPC, en silencio, exactamente en las plataformas de donde vinieron los reportes originales. HPDF_HAS_FORMAT_SETTINGS está definido 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 tienes 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 aporte la locale actual. El Delphi anterior a XE2 no tiene sobrecargas en absoluto, y ahí los helpers caen en convertir con la locale del proceso y luego reescribir el carácter separador en la cadena 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. La W3C SVG 1.1 §4.2 admite un exponente en su producción de números y el markup XPS hace lo mismo, así que 1E-6 es una coordenada perfectamente válida a la entrada, mientras que la ISO 32000-1 §7.3.3 no admite exponente alguno. Un valor que sobrevive al formato invariante puede aterrizar igual en un content stream como 1E-6 y ser rechazado, y FloatToStrF en formato fijo puede caer él mismo a forma exponencial una vez que el valor sale del rango de dígitos de la RTL. HPDFSafeSVG.SVGFloat por tanto hace su propio trabajo después de la conversión invariante: vacía a cero cualquier cosa por debajo de 1.0E-7, y luego, si sobrevive una E, quita el exponente y mueve el punto decimal a mano, rellenando con ceros en el lado que el desplazamiento requiera. SVGFinite guarda la entrada rechazando NaN y cualquier cosa fuera de ±1.0E30 antes de que llegue al formateador, y SVGReadNumber con SVGSkipSeparators maneja la dirección de entrada carácter a carácter en lugar de delegar en la RTL
// 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);
// ... desplaza el punto decimal ExponentValue posiciones, rellenando con '0'
// a la izquierda o a la derecha, y luego recoloca el signo
end;
Es el mismo TSVGBuffer que alimenta la exportación SVG de páginas y su manejo del estado gráfico, y la misma disciplina numérica se aplica en el lado de importación cuando se convierten documentos XPS y OXPS a PDF a través de HPDFCreateXPSDocumentHandler y el miembro hdfXPS de THPDFHandledDocumentFormat. Los formatos de markup son generosos con la sintaxis de números. El content stream de PDF no lo es, y la frontera de conversión es donde hay que resolver esa diferencia
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 merece la pena clavarse con tests que fijan ellos mismos la locale, y HotPDF lleva dos. LoadedEditsEmitInvariantNumbersUnderCommaLocale asigna una coma al FormatSettings.DecimalSeparator antes de la primera edición, ejecuta RedactLoadedRect, StampLoadedPageText, StitchLoadedPage y un stamp compuesto, luego lee de vuelta los bytes del contenido de la página y afirma tanto que las cadenas de operador exactas están presentes como que el buffer no contiene ninguna coma. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale cubre la otra mitad y el pecado original: conduce el camino de creación bajo la misma locale con coma, incluido DirectDrawBarcode con su formato fijo de dos decimales, y luego afirma que el content stream no lleva coma y que FormatSettings.DecimalSeparator sigue siendo una coma cuando vuelve EndDoc
Esa segunda aserción es la que habría atrapado el defecto original el día en que se escribió, porque prueba lo que la librería le hace a su host en lugar de solo lo que escribe en disco. La lección generaliza más allá de PDF: cualquier formato con una gramática léxica fija, PostScript y JSON y JavaScript incluidos, necesita una conversión invariante explícita en la frontera. Recurrir a un ajuste global para obtener la respuesta correcta funciona hasta que alguien llama a tu código en un orden distinto, en otro hilo, o desde otro compilador, y para entonces el fallo parece cualquier cosa menos un problema de formato
La capa de formato de números descrita aquí viaja como parte del HotPDF Delphi Component para Delphi y C++Builder, junto con los caminos de edición de documentos cargados, códigos de barras, exportación SVG y conversión XPS que dependen de ella