Artículo técnico

Conservar la precisión decimal del PDF al guardar en Delphi

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

Dónde perdía PDFlibPas precisión de CalRGB en un guardado no-op 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 a cuatro decimales, todas las comprobaciones estructurales dicen que el documento no cambió, y solo la comparación de renderizados muestra las 35 imágenes de cabecera alteradas
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 renderizador producía colores un poco distintos en cada página

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

Cómo conserva PDFlibPas el texto decimal parseado en Delphi: el tokenizer de TPDFStructure.Decode guarda el literal de origen en FOriginalText para cualquier token con punto decimal y sin exponente, Output escribe ese texto tal cual en lugar de llamar a PLDoubleToStr, y SetTo lo borra porque un número editado es un número nuevo
El valor que decodificas y el literal que emites son dos cosas distintas: preferir el texto parseado mantiene 2.22221 exacto, mientras que los números creados o editados por la biblioteca siguen PDFPrecNum y el ajuste nunca llega a la entrada intacta
// 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

Por qué el modelo de contenido de PDFlibPas sigue normalizando números: NormalizeContentNumbers corre donde el parser empuja cada operando y otra vez dentro de SetOperand, recursa por arrays y diccionarios para cubrir dash patterns, arrays TJ y diccionarios de propiedades del contenido marcado, y SetTo AsDouble borra 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 se dejan en paz, y los números de diccionario intactos fuera de los content streams conservan la garantía de texto literal, así que un simple par LoadFromFile y SaveToFile todavía 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 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