PDFlibPas, la losLab PDF Developer Library, conserva el texto decimal exacto que parseó de cada número real de un documento y lo escribe tal cual siempre que el valor nunca se haya modificado. Desde la v3.539.19, la opción SetPrecision solo gobierna los números que la librería crea o edita, así que un load-and-save común ya no redondea un /Gamma de CalRGB de 2.22221 a 2.2222 ni corre los colores de una página que nadie tocó. El cambio es chico en código y grande en lo que dice sobre los parsers: el valor que usted decodifica y el literal que emite son dos cosas distintas, y un ida y vuelta por Double no es una transformación identidad
¿Por qué un save que no cambió nada movía 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 dejó al descubierto es un documento de oficina de 35 páginas del corpus de regresión local, con una imagen de encabezado reutilizada en todas las páginas. Cargarlo y volver a guardarlo tal cual producía streams de imagen idénticos byte a byte a los de entrada, y una comparación de hashes de streams declaraba el documento sin cambios. Una comparación de renderizado decía otra cosa: las 35 páginas mostraban diferencias de píxeles en el encabezado, y en ningún otro lado
La imagen del encabezado se dibuja a través de un espacio de color CalRGB, que 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 comunes dentro del diccionario del espacio de color. TPDFNumeric guardaba cada uno como un Double y nada más, y TPDFNumeric.Output formateaba ese Double pasándolo por PDFPrecNum, que por defecto usa cuatro decimales. Así, /Gamma pasaba de 2.22221 a 2.2222, una entrada de la matriz pasaba de 0.71519 a 0.7152, y el renderer 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 todo esto. Comparar los bytes de los streams ya decodificados no lo ve, porque los números viven en un diccionario, no en un stream. Comparar los payloads de los adjuntos tampoco. Hasta el diff de revisiones que describe el artículo sobre niveles de modificación 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 detectó, y por eso el baseline del corpus renderiza cada página en lugar de confiar únicamente en los chequeos estructurales
El valor que usted parseó no es el literal que debe escribir
Un número real de PDF es una cadena decimal, y ISO 32000-1 §7.3.3 es explícito en que es solo una cadena decimal: sin notación de radix, sin 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 la cosa empeora: PLDoubleToStr escala el valor, lo 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 por completo
Subir el valor por defecto solo correría el precipicio. El fix es dejar de hacer de cuenta que un Double es el número. Cuando el tokenizer de TPDFStructure.Decode reconoce un real estándar, es decir, cuando el token tiene punto decimal y ningún marcador de exponente, guarda el texto fuente en el nuevo campo FOriginalText junto con el valor convertido. A partir de ahí Output prefiere ese texto y solo cae al formateo cuando no hay nada que preferir
// Lib/PDFlibStruct.pas — todo el fix 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 culpa de productores rotos, pero no se conservan a la salida, ya que escribirlas de vuelta perpetuaría una sintaxis que §7.3.3 prohíbe; pasan por el formateador como cualquier número generado por la librería. El tokenizer además aplica su reparación mínima de siempre antes de guardar el texto, así que un literal con punto inicial como .5 queda como 0.5 y un literal con punto final como 5. queda como 5.0. Los dos son el mismo número para cualquier lector y se aceptan mucho más ampliamente
¿Qué garantiza SetPrecision después de la v3.539.19?
TPDFlib.SetPrecision ahora controla los decimales de los números que produce la propia librería: los valores dibujados por el painter, los números creados a partir de un Double, por ejemplo con NewNumeric, y cualquier valor parseado que después se haya editado con SetTo. Ojo con esto: el texto decodificado a través de la API de objetos, por ejemplo un literal que se pasa a SetObjectFromString, pasa por el mismo tokenizer y se conserva igual. Un decimal parseado que nunca se modificó mantiene la precisión de entrada sin importar la opción, y cambiar la opción después de la carga no lo toca de forma retroactiva. La entrada de referencia de SetPrecision se actualizó en la misma release para decir exactamente esto, porque el texto viejo daba a entender que la opción aplicaba a todos los números del archivo
La limpieza 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 que se escribieron, así que un chequeo del tipo "emitir el texto original salvo que haya cambiado" empezaría a emitir texto viejo para un valor que se editó, se guardó y se volvió a editar en la misma sesión. Atar el texto original a la asignación misma hace imposible que los dos se contradigan. La prueba 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 a 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 igual normaliza los 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 está construido el tracker de estado gráfico, existe para que NormalizeContentStreams, el optimizador y Emit produzcan una salida estable y comparable a partir de cualquier entrada. Si un operando parseado arrastrara su texto original al modelo, una secuencia de operadores como 0.50000 0 0 RG emitiría distinto que 0.5 0 0 RG, y toda comparación aguas abajo se iría a la deriva según las costumbres de formateo del productor
Así que el modelo borra el texto original en sus dos puntos de entrada. NormalizeContentNumbers corre sobre cada operando cuando el parser lo apila y otra vez dentro de SetOperand cuando se decodifica código fuente provisto por quien llama, y recursa por arrays y diccionarios para cubrir los patrones de guiones, los arrays TJ y los diccionarios de propiedades del contenido marcado. Llamar a SetTo(AsDouble) en cada numérico alcanza, porque esa es justamente la operación que limpia el texto. Los datos crudos de imágenes inline se dejan intactos, 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 entonces simple. Un LoadFromFile común seguido de un SaveToFile deja los content streams y los números de diccionario que no se tocaron tal 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 igual se conserva. Son dos pedidos distintos, y ahora hacen dos cosas distintas
Qué cuesta y dónde termina la garantía
Cada TPDFNumeric ahora carga una referencia AnsiString extra, y cada decimal parseado mantiene vivo su texto fuente durante toda la vida del objeto. En un documento con millones de números reales eso es memoria real, y corresponde medirlo en cualquier prueba con documentos grandes en lugar de restarle importancia. La garantía además está acotada al documento del propio 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. Vale la pena ser precisos sobre qué reclama esta release y qué no. Un load-and-save de un documento que no se tocó ahora conserva los números de calibración que el renderer realmente consume, que es la propiedad que chequea el baseline del corpus. No reclama una salida idéntica byte a byte, que además depende de la numeración de objetos, la compresión de streams y el identificador del trailer que se comenta 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, porque esos igual hashean el cuerpo normalizado. La lección se generaliza bastante más allá de CalRGB: cuando un parser guarda solo el valor convertido, cada save es una edición, y la única forma de darse cuenta es mirar el resultado renderizado. El manejo numérico y la semántica de SetPrecision están documentados en la página del producto losLab PDF Developer Library