PDFlibPas, la losLab PDF Developer Library, conserva el texto decimal exacto que parseó para cada número real de un documento y reescribe ese texto tal cual siempre que el valor no se haya modificado nunca. Desde la v3.539.19 el ajuste SetPrecision gobierna solo los números que la biblioteca crea o edita, así que un load-and-save corriente ya no redondea un /Gamma de CalRGB de 2.22221 a 2.2222 ni desplaza los colores de una página que nadie tocó. El cambio es pequeño en código y grande en lo que dice sobre los parsers: el valor que decodificas y el literal que emites son dos cosas distintas, y un ida y vuelta por Double no es una transformación identidad
¿Por qué un guardado que no cambiaba nada desplazaba los colores de la página?
Porque lo que se reformateaba eran los parámetros del espacio de color, no la imagen. El archivo que lo destapó es un documento de oficina de 35 páginas del corpus de regresión local, con una imagen de cabecera reutilizada en todas las páginas. Cargarlo y guardarlo tal cual producía streams de imagen idénticos byte a byte a la entrada, y una comparación de hashes de streams decía que el documento no había cambiado. Una comparación de renderizados decía lo contrario: las 35 páginas mostraban diferencias de píxeles en la cabecera, y en ningún otro sitio
La imagen de cabecera se dibuja a través de un espacio de color CalRGB, que la ISO 32000-1 §8.6.5.3 define con un /WhitePoint, un array /Gamma opcional de tres elementos y una /Matrix opcional de nueve elementos. Esos arrays son objetos numéricos corrientes en el diccionario del espacio de color. TPDFNumeric guardaba cada uno como un Double y nada más, y TPDFNumeric.Output formateaba ese Double a través de PDFPrecNum, que por defecto son cuatro decimales. Así que /Gamma pasaba de 2.22221 a 2.2222, una entrada de la matriz pasaba de 0.71519 a 0.7152, y el renderizador producía fielmente colores un poco distintos a partir de una calibración un poco distinta. Los bytes de la imagen eran inocentes; los números que la rodeaban no. Lo incómodo es lo invisible que era esto. Comparar los bytes decodificados del stream no lo ve, porque los números viven en un diccionario, no en un stream. Comparar los payloads de los adjuntos tampoco lo ve. Incluso el diff de revisiones descrito en el artículo sobre modification levels toma la huella de un cuerpo de objeto normalizado, así que las dos revisiones hashean al mismo valor y el diff las reporta idénticas. Solo el renderizado lo cazó, y por eso el baseline del corpus renderiza cada página en lugar de fiarse únicamente de las comprobaciones estructurales
El valor que parseaste no es el literal que deberías escribir
Un número real de PDF es una cadena decimal, y la ISO 32000-1 §7.3.3 es explícita en que solo es una cadena decimal: nada de notación de base, nada de forma exponencial. El Anexo C lista después la precisión que se espera que respete una implementación, aproximadamente cinco dígitos decimales significativos en la parte fraccionaria. Una precisión de salida por defecto de cuatro ya está por debajo de eso, y cerca de cero va peor: PLDoubleToStr escala el valor, redondea a entero y emite 0 cuando el resultado es cero, así que una entrada de matriz de -0.000012345 no pierde un dígito, desaparece del todo
Subir el valor por defecto solo movería el precipicio. El fix es dejar de fingir que un Double es el número. Cuando el tokenizer de TPDFStructure.Decode reconoce un real estándar, es decir, cuando el token contiene un punto decimal y ningún marcador de exponente, guarda el texto de origen en el nuevo campo FOriginalText junto al valor convertido. Output prefiere entonces ese texto y recurre al formateo solo cuando no hay nada que preferir
// Lib/PDFlibStruct.pas — el fix entero del lado de la salida
Function TPDFNumeric.Output: AnsiString;
Begin
If FOriginalText<> '' Then
Result:= FOriginalText
Else
Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;
Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
FOriginalText:= ''; // un número editado es un número nuevo
FValue:= Value;
FChanged:= True;
End;
Hay dos límites deliberados. Los enteros no se conservan, porque el formateo de enteros ya es sin pérdida. Las formas exponenciales como 6.02E23 se toleran a la entrada por el bien de los producers rotos, pero no se conservan a la salida, ya que reescribirlas perpetuaría una sintaxis que la §7.3.3 prohíbe; pasan por el formateador como cualquier número generado por la biblioteca. El tokenizer aplica además su reparación mínima habitual antes de guardar el texto, así que un literal con punto inicial como .5 se queda como 0.5 y uno con punto final como 5. como 5.0. Ambos son el mismo número para cualquier lector y están mucho más ampliamente aceptados
¿Qué garantiza SetPrecision a partir de la v3.539.19?
TPDFlib.SetPrecision controla ahora los decimales de los números que produce la propia biblioteca: los valores dibujados a través del painter, los números creados a partir de un Double como los de NewNumeric, y cualquier valor parseado que se haya editado después con SetTo. Ojo: el texto decodificado a través de la API de objetos, por ejemplo un literal pasado a SetObjectFromString, pasa por el mismo tokenizer y se conserva igual. Un decimal parseado que nunca se modificó mantiene su precisión de entrada sea cual sea el ajuste, y cambiar el ajuste después de cargar no lo toca de forma retroactiva. La entrada de referencia de SetPrecision se actualizó en la misma release para decir exactamente esto, porque la redacción antigua daba a entender que el ajuste aplicaba a todos los números del archivo
El borrado ocurre en SetTo en lugar de derivarse del flag Changed, y esa distinción importa. El pipeline de guardado resetea Changed en los objetos una vez escritos, así que una comprobación del tipo «emitir el texto original salvo si cambió» empezaría a emitir texto obsoleto para un valor que se editó, se guardó y se volvió a editar en la misma sesión. Atar el texto original a la propia asignación hace imposible que los dos discrepen. El test de regresión fija cada uno de estos comportamientos con los valores del archivo original
uses
PDFlibStruct;
var
Structure: TPDFStructure;
Values: TPDFArray;
Number: TPDFNumeric;
begin
Structure := TPDFStructure.Create;
try
Structure.PDFPrecNum := 4;
Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
// La entrada sin editar sobrevive tal cual, incluido el valor que
// el formateo a cuatro decimales habría colapsado a 0
Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');
// Una edición descarta el texto original y sigue PDFPrecNum
Number := TPDFNumeric(Values.Item[0]);
Number.SetTo(0.123456);
Assert(Number.Output = '0.1235');
Assert(Structure.NewNumeric(0.123456).Output = '0.1235');
// Bajar la precisión después no llega a la entrada sin editar
Structure.PDFPrecNum := 2;
Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
finally
Structure.Free;
end;
end;
¿Por qué el modelo de contenido sigue normalizando números?
Porque TPDFContentProgram promete operandos numéricos canónicos, y esa promesa vale más que el texto tal cual dentro de un content stream. El modelo de contenido editable, el mismo sobre el que se construye el tracker de estado gráfico, existe para que NormalizeContentStreams, el optimizador y Emit produzcan una salida estable y comparable a partir de una entrada arbitraria. Si un operando parseado se llevara su texto original al modelo, una secuencia de operadores como 0.50000 0 0 RG emitiría distinto de 0.5 0 0 RG, y toda comparación posterior se desviaría según las manías de formato del producer
Así que el modelo despoja el texto original en sus dos puntos de entrada. NormalizeContentNumbers corre sobre cada operando según lo empuja el parser y otra vez dentro de SetOperand cuando se decodifica código fuente aportado por quien llama, y recursa por arrays y diccionarios de modo que quedan cubiertos los dash patterns, los arrays TJ y los diccionarios de propiedades del contenido marcado. Llamar a SetTo(AsDouble) sobre cada numérico basta, ya que esa es precisamente la operación que borra el texto. Los datos crudos de las imágenes inline se dejan en paz, como siempre
// Lib/PDFlibContentModel.pas — el modelo de contenido mantiene su contrato
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
K: Integer;
Begin
If Obj is TPDFNumeric Then
TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
Else If Obj is TPDFArray Then
For K:= 0 To TPDFArray(Obj).Count- 1 Do
NormalizeContentNumbers(TPDFArray(Obj).Item[K])
Else If Obj is TPDFDictionary Then
For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;
La regla práctica para quien llama es por tanto sencilla. Un LoadFromFile corriente seguido de SaveToFile deja los content streams intactos y los números de diccionario intactos como estaban. Una página que pasa por NormalizeContentStreams, o cualquier edición hecha a través del modelo de contenido, sale canónica por diseño, y el resto del documento sigue conservándose. Son dos peticiones distintas, y ahora hacen dos cosas distintas
Qué cuesta y dónde se acaba la garantía
Cada TPDFNumeric lleva ahora una referencia extra a un AnsiString, y cada decimal parseado mantiene vivo su texto de origen durante toda la vida del objeto. En un documento con millones de números reales eso es memoria de verdad, y tiene que entrar en cualquier medición de documentos grandes en lugar de quitarle importancia. La garantía también está acotada al propio documento del número: copiar objetos entre documentos o reconstruir valores a través de la API de objetos produce números nuevos, que siguen la precisión de salida como cualquier otro número nuevo. Merece la pena ser preciso sobre qué reclama la release y qué no. Un load-and-save de un documento intacto conserva ahora los números de calibración que el renderizador consume de verdad, que es la propiedad que comprueba el baseline del corpus. No reclama una salida idéntica byte a byte, que además depende de la numeración de objetos, de la compresión de streams y del identificador del trailer tratado en el artículo sobre el PDF ID determinista. Y no hace que el diff de huellas vea diferencias de redondeo en archivos producidos por otro software, ya que esos siguen hasheando el cuerpo normalizado. La lección generaliza mucho más allá de CalRGB: cuando un parser se queda solo con el valor convertido, cada guardado es una edición, y la única forma de darse cuenta es mirar el resultado renderizado. El tratamiento numérico y la semántica de SetPrecision están documentados en la página de producto de losLab PDF Developer Library