Artículo técnico

Precisión decimal parseada al guardar un PDF en Delphi

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

Dónde perdía PDFlibPas la precisión CalRGB en un save sin cambios en Delphi: el /Gamma 2.22221 ya parseado y una entrada de matriz 0.71519 viven en TPDFNumeric como Double, Output los formatea con PLDoubleToStr y PDFPrecNum en cuatro decimales, todos los chequeos estructurales reportan el documento sin cambios, y solo la comparación de renderizado muestra las 35 imágenes de encabezado corridas
Los bytes de la imagen eran inocentes: TPDFNumeric reformateaba los números de calibración que los rodeaban a través de PDFPrecNum, así que los hashes de streams y el diff de huellas reportaban revisiones idénticas mientras el renderer producía colores un poco distintos en cada página

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

Cómo conserva PDFlibPas el texto decimal parseado en Delphi: el tokenizer de TPDFStructure.Decode guarda el literal fuente en FOriginalText para todo token con punto decimal y sin exponente, Output escribe ese texto tal cual en lugar de llamar a PLDoubleToStr, y SetTo lo limpia porque un número editado es un número nuevo
El valor que usted decodifica y el literal que emite son dos cosas distintas: preferir el texto parseado mantiene 2.22221 exacto, mientras que los números creados por la librería y los editados siguen a PDFPrecNum y la opción nunca llega a la entrada que no se tocó
// 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

Por qué el modelo de contenido de PDFlibPas igual normaliza los números: NormalizeContentNumbers corre donde el parser apila cada operando y otra vez dentro de SetOperand, recursa por arrays y diccionarios para cubrir patrones de guiones, arrays TJ y diccionarios de propiedades del contenido marcado, y SetTo AsDouble limpia el texto original para que 0.50000 y 0.5 emitan igual
Los operandos numéricos canónicos son la promesa del modelo de contenido: los datos crudos de imágenes inline quedan intactos, y los números de diccionario que no se tocaron fuera de los content streams conservan la garantía de texto tal cual, así que un par simple de LoadFromFile y SaveToFile igual los preserva
// 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