PDFlibPas, la librería de desarrollo PDF de losLab para Delphi, escribe cada número que mete en un content stream con separador decimal punto y sin exponente, digan lo que digan las configuraciones regionales de Windows. Desde la v3.539.26 AddPageMatrix, ScalePage, DeskewPage, RedactRegion, la salida de texto a path y el recoloreo formatean operandos vía PLDoubleToStrConst, y desde la v3.539.33 los parsers que leen esos números de vuelta usan PLTryStrToFloatInvariant en lugar del locale del sistema. En una máquina alemana, francesa o brasileña el mismo código ahora produce los mismos bytes que en una de US, que es el único comportamiento que un formato de archivo puede tolerar
¿Por qué un locale con decimal coma corrompe un PDF sin dar error?
Un locale con decimal coma corrompe un PDF en silencio porque la coma no es un carácter de número en la sintaxis de PDF, así que el daño se lee como tokens válidos con significado equivocado. Antes del fix, PLFloatToStr no era más que una llamada pelada a FloatToStr, y FloatToStr sigue FormatSettings.DecimalSeparator. Con separador coma, AddPageMatrix(0.5, 0.5, 0, 0) escribía 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 permite en un número dígitos, un punto y un signo inicial, y nada más, así que un parser de contenido lee esa línea como el número 0 seguido de un token desconocido ,5, y el operador cm termina con operandos equivocados. Nada lanza excepción, nada registra en el log. La página simplemente se renderiza con una matriz de transformación que se desvió, y trabajar hacia atrás desde un dibujo mal ubicado hasta una configuración regional es una tarde miserable
El segundo defecto se esconde detrás del primero. FloatToStr usa el formato ffGeneral, que salta a notación de exponente en cuanto la magnitud baja de 1E-4, así que un offset diminuto salía como 1E-5. El mismo §7.3.3 declara que PDF no soporta la forma exponencial, o sea que incluso una máquina con locale US podía escribir un operando inválido con un valor lo bastante chico. Los tests de regresión de este release clavan ambas formas de falla: ponen el separador en coma, llaman a la API y escanean el contenido resultante buscando cualquier token que contenga una coma o un exponente
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 := ','; // simule un escritorio de-DE
Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
// v3.539.26 y posteriores escriben: 0.5 0 0 0.25 0.00001 12.75 cm
// builds anteriores escribían: 0,5 0 0 0,25 1E-5 12,75 cm
Writeln(Lib.GetPageContentToString);
finally
FormatSettings.DecimalSeparator := OldSeparator;
end;
finally
Lib.Free;
end;
end;
Dos clases de números, dos familias de helpers
El fix en PDFlibPas es una separación estricta: los números que la gente ve pueden seguir el locale, y los números que se escriben para una máquina nunca lo hacen. PLFloatToStr y PLStrToFloat se quedan en PDFlibExtra.pas para texto orientado al usuario, y su declaración ahora lleva un comentario que dice exactamente eso. Todo lo que termina siendo sintaxis de PDF pasa por PLDoubleToStrConst con una cantidad fija de decimales elegida según el trabajo: seis para matrices, cuatro para coordenadas y ajustes de TJ, tres para colores y rectángulos de FDF. La auditoría de la v3.539.26 tocó más call sites de lo que sugería el reporte original del bug:
AddPageMatrix,ScalePageyDeskewPage, que todos anteponen uncmal contenido existente de la página- Los builders de elementos de página que emiten resets de
Tm, avances deTJy transforms decm - Las matrices de colocación de glyphs y los puntos de outline en el conversor de texto a path
- La caja de relleno negro que
RedactRegionantepone, los valores/Rectdel export de FDF y los operandos que escribe el recoloreo
PLDoubleToStrConst es un formateador escrito a mano en lugar de un wrapper alrededor de FloatToStrF, y tres de sus propiedades importan aquí. Siempre escribe un punto y recorta los ceros finales, así que 0.5 queda 0.5 y no 0.500000. Nunca escribe un exponente para entrada finita. Y un valor distinto de cero más chico que la precisión pedida conserva sus dígitos significativos en lugar de colapsar a cero, así que PLDoubleToStrConst(1E-9, 6) devuelve 0.000000001; solo los valores por debajo de aproximadamente 5E-16 se convierten en 0. Esa última regla existe porque redondear un factor de escala diminuto a cero convierte una matriz válida en una singular, que es un bug peor que el que se está arreglando
uses
PDFlibExtra;
procedure CheckNumberHelpers;
var
V: Double;
begin
// Salida para máquina: decimal punto, sin exponente, ceros finales recortados
Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235'); // conserva 4 dígitos significativos
Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');
// Entrada de máquina: falla suave en lugar de EConvertError
Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
Assert(not PLTryStrToFloatInvariant('0,5', V)); // los números de contenido nunca usan coma
end;
¿Por qué el lado de parsing es más peligroso que el lado de escritura?
El lado de parsing es más peligroso porque un parser atado al locale no produce un número equivocado: lanza excepción. PLStrToFloat llama a StrToFloat, que eleva EConvertError cuando el texto no matchea el separador del sistema. En un sistema con decimal coma eso significaba que RecolorPage abortaba apenas encontraba un operador 0.5 g de los de siempre, así que fallaba toda página del mundo real, no solo las exóticas. RenderPageRegionToFile rechazaba su propio formato de clip documentado "10.5,20.5,50.5,40.5", y los atributos de longitud SVG, los colores del export SVG, las listas de vértices de anotaciones y los valores de solidez de output intent se rechazaban o se reemplazaban en silencio por defaults. Una librería que funciona perfecto en la máquina del desarrollador y falla con el primer cliente en Múnich es exactamente la clase de código que, como los casos de el artículo sobre código Delphi que funciona de casualidad, solo parece correcto por donde fue probado
La v3.539.33 clasificó cada llamada a StrToFloat y TryStrToFloat según de dónde viene su entrada. Los operandos de content streams, los atributos SVG, los strings de color del painter y las listas de clip y de vértices separadas por coma tienen todas una sintaxis de punto fija, así que ahora pasan por PLTryStrToFloatInvariant, que recorta el texto, lo parsea con PLInvariantFormatSettings y devuelve False ante entrada vacía, malformada o no finita en lugar de lanzar excepción. Una lista separada por coma no admite términos medios, porque una coma no puede ser a la vez delimitador de lista y marca decimal. El mismo pase arregló además una escritura fuera de límites: RenderPageRegionToFile guardaba un quinto valor de clip más allá de su buffer de cuatro elementos. Para el pipeline de recoloreo descrito en la guía para convertir un PDF a un espacio de color, el resultado práctico es que RecolorPage y RecolorDocument ya no abortan en un sistema con decimal coma. Los valores de reglas que un llamador teclea en CheckDocumentPolicy son el único caso de parsing que usa el helper permisivo, por la razón que explica la siguiente sección
¿Qué pasa si arregla solo un extremo de un round-trip?
Arreglar solo un extremo de un round-trip de locale rompe código que antes funcionaba, y por eso el cambio de atributos de estructura de la v3.539.32 movió juntos al escritor y al lector. Los wrappers SetStructElem* transmiten los números como strings: SetStructElemBBox formatea cuatro valores en un string, lo guarda vía AddTagAttribute, y el escritor de /A después parsea ese string para decidir si se vuelve un número, un array o un name. Ambos extremos usaban el locale del sistema, así que en un sistema con decimal coma el round-trip era autoconsistente. El bug aparecía solo cuando un llamador seguía la documentación y pasaba "0.5" a AddTagAttribute: el lector no podía parsearlo y emitía el name de PDF /0.5. El placeholder de PDF/VCR tenía el problema en espejo, porque la librería generaba GTS_BBox con punto y después lo validaba con el locale al guardar
Cambiar solo el escritor a punto habría sido peor que no hacer nada, porque cada valor de SetStructElem* habría fallado entonces en el lector atado al locale y se habría degradado a un name. Así que los escritores ahora usan PLDoubleToStrConst(v, 6), y el lector usa el nuevo PLTryStrToFloatLenient, que prueba primero la forma con punto y cae al locale del sistema. Un llamador con locale coma que pasaba "1,25" en el pasado sigue obteniendo el número 1.25. El trade-off es deliberado y está documentado: en un sistema alemán "1.500" se volvía un name porque StrToFloat rechaza separadores de miles, y ahora se lee como 1.5, mientras que los strings literales NAN e INF ya no se aceptan como números
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
begin
FormatSettings.DecimalSeparator := ','; // llamador con decimal coma
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, antes /0.5
Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // sigue siendo /StartIndent 1.25
Lib.DrawText(20, 20, 'figure');
Lib.EndTag;
Lib.SaveToFile('tagged.pdf');
finally
Lib.Free;
end;
end;
Dónde se frenan NaN y el infinito
AddPageMatrix, ScalePage y RedactRegion ahora rechazan de entrada los argumentos NaN e infinitos y devuelven 0, porque ningún número de PDF puede representarlos. ScalePage ya rechazaba factores de cero o menos, pero NaN pasa un test de <= 0, así que un factor de escala NaN llegaba viaje completo hasta el formateador. En la v3.539.26 ese formateador todavía llamaba a Round sobre NaN, que eleva EInvalidOp en Win32 donde la unidad x87 no enmascara las operaciones inválidas; la v3.539.31 hizo que PLDoubleToStrConst escribiera 0 ante NaN como última línea de defensa, pero un cero en una matriz es una transformación singular, así que el chequeo a nivel de API sigue siendo el fix de verdad. Dos fronteras se quedan como están a propósito. Los strings de estado de metafile se escriben y se leen con el locale dentro de un mismo proceso y jamás salen de él, así que se los dejó tranquilos. Y un test que formatea 1E-5 por el camino de elementos de página debe leer el contenido antes de que la capa se reescriba, porque re-emitir operandos a precisión de documento convierte legítimamente ese valor en 0
Si su aplicación llega a clientes fuera del mundo del decimal punto, el hábito más seguro es el que la suite de tests de PDFlibPas usa ahora: corra los caminos que producen PDF una vez con FormatSettings.DecimalSeparator puesto en coma y escanee la salida buscando comas y exponentes. El artículo sobre preservar la precisión decimal parseada cubre la otra mitad de esta misma historia, cómo los números leídos de un archivo existente conservan su texto exacto al guardar. Descargas, la referencia completa de la API y el build de prueba están en la página de producto de PDFlibPas Delphi PDF library