Artículo técnico

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

Carga un PDF calibrado, no cambies nada, guárdalo con otro nombre y compara el diccionario del espacio de color: entró /Gamma [2.22221 2.22221 2.22221] y salió /Gamma [2.2222 2.2222 2.2222]. El writer reformateó un número que nunca tuvo que tocar, y la página ahora se renderiza con colores ligeramente distintos. La cura es tratar cada decimal parseado como dos cosas a la vez, un valor para la aritmética y un literal para la salida, y seguir emitiendo el literal hasta que alguien asigne de verdad un valor nuevo

Por qué un guardado no-op puede mover colores CalRGB

Los píxeles se mueven porque al renderer le llegan datos de calibración ligeramente distintos mientras los bytes de la imagen siguen idénticos. ISO 32000-1 §8.6.5.3 define un espacio de color CalRGB mediante un /WhitePoint obligatorio, un array /Gamma opcional de tres elementos y un /Matrix opcional de nueve, y todos son objetos numéricos corrientes en el diccionario del espacio de color. Pásalos por un Double y un formateador clavado en cuatro decimales, y 2.22221 se convierte en 2.2222 mientras una entrada de matriz 0.71519 se convierte en 0.7152. Un valor de gamma es un exponente aplicado a cada componente de cada píxel que pasa por ese espacio, así que el cambio se nota dondequiera que se use

Para un fichero que declara un espacio CalRGB para una imagen de cabecera reutilizada en 35 páginas, las 35 cabeceras se renderizan con colores desplazados y nada más cambia en esas páginas. Las comprobaciones a las que la gente recurre primero salen limpias. Un hash de los bytes decodificados del stream compara datos de imagen, y los datos de imagen de verdad son idénticos; el daño está en un diccionario. La comparación de revisiones descrita en el artículo del nivel de modificación toma la huella de un cuerpo de objeto normalizado, así que las dos revisiones también dan el mismo hash ahí. La comparación de páginas renderizadas es lo que lo caza. Para cualquier pipeline que reescriba PDFs producidos por otro software, pondríamos una comparación de render por página por delante de cualquier comprobación estructural

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

Qué aspecto puede tener un número real de PDF

ISO 32000-1 §7.3.3 define un número real como una cadena decimal: signo opcional, dígitos, un punto, más dígitos. Sin notación de radix. Sin forma exponencial. El Anexo C, que enumera los límites de implementación, espera que se respeten unos cinco dígitos decimales significativos en la parte fraccionaria. Una precisión de salida por defecto de cuatro ya se queda un dígito por debajo de lo que la especificación pide conservar a los lectores, y el fichero no hizo nada malo al usar cinco

Cerca de cero, un formateador ingenuo hace algo peor que perder un dígito. La forma habitual de imprimir un Double a N posiciones es multiplicar por 10^N, redondear a entero y escribir los dígitos, y cuando ese entero es cero la salida es 0. Dale el primer operando de 0.00001 0 0 1 0 0 cm a cuatro posiciones y el resultado es 0 0 0 1 0 0 cm, una matriz singular que hace desaparecer todo lo dibujado bajo ella. Una entrada /Matrix de -0.000012345 corre la misma suerte

Cuando PDF Library for Delphi (PDFlibPas) tiene que formatear él mismo un Double, conserva el número de dígitos significativos pedido para un valor distinto de cero menor que el paso de precisión en lugar de escribir 0. A cuatro posiciones, 0.00001 se escribe como 0.00001 y 0.0000123 como 0.0000123; solo las magnitudes por debajo de 5E-16 aproximadamente salen todavía como 0. Así los números recién escritos se mantienen sanos. Un número parseado que nunca necesitó reformatearse es otra historia, y la biblioteca lo gestiona no reformateándolo en absoluto

Cómo PDFlibPas conserva el literal que parseó

TPDFStructure.Decode guarda el texto fuente de cada real estándar que tokeniza, es decir, un token con punto decimal y sin marcador de exponente, justo al lado del Double convertido. TPDFNumeric.Output reescribe ese texto siempre que está presente y formatea el Double al PDFPrecNum de la estructura solo cuando no lo está. TPDFNumeric.SetTo descarta el texto, porque un número editado es un número nuevo y debe seguir la precisión de salida actual

Cómo PDFlibPas conserva el texto decimal parseado en Delphi: el tokenizador de TPDFStructure.Decode guarda el literal fuente junto al valor convertido para todo token con punto decimal y sin exponente, Output reescribe ese texto tal cual en lugar de reformatear el Double al PDFPrecNum, 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 el 2.22221 exacto, mientras que los números creados por la biblioteca y los editados siguen el PDFPrecNum y el ajuste nunca alcanza a la entrada intacta

Atar el reinicio a SetTo fue deliberado. El atajo tentador es emitir el texto original salvo que el flag Changed del objeto esté activo, ya que ese flag existe de antemano en todos los objetos. Pero el pipeline de guardado limpia Changed en cuanto un objeto se ha escrito, así que un valor editado, guardado y editado otra vez en la misma sesión volvería a emitir su literal obsoleto en el segundo guardado. Limpiar el texto dentro de la propia asignación implica que el literal y el valor nunca pueden discrepar

Algunos límites son intencionados. Los enteros no se guardan como texto porque el formateo de enteros ya es sin pérdida. Las formas exponenciales como 6.02E23 se aceptan en entrada por cortesía a los productores que las escriben de todos modos, pero se formatean en salida como cualquier número generado por la biblioteca, porque copiarlas de vuelta perpetuaría una sintaxis que §7.3.3 descarta. El tokenizador además aplica su pequeña reparación de siempre antes de guardar el texto: un literal con punto inicial como .5 se guarda como 0.5, y uno con punto final como 5. como 5.0. Todos los lectores tratan cada pareja como el mismo número, y la grafía reparada se acepta en muchos más sitios

El fragmento de abajo pasa los valores CalRGB por la capa de objetos directamente, así que cada regla se puede ver sin abrir un fichero PDF. Las líneas Assert son comprobaciones que sugerimos añadir en tus propios tests; no son llamadas a la biblioteca

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]'));

    // Comprobación propia: la entrada sin editar sobrevive literal, incluido el
    // valor que un formateador ingenuo de cuatro posiciones colapsaría a 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Una edición descarta el texto original y sigue el PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Los valores nuevos diminutos conservan dígitos significativos en vez de volverse 0
    Assert(Structure.NewNumeric(0.0000123).Output = '0.0000123');

    // Bajar la precisión después no alcanza a la entrada sin editar
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Una trampa al escribir aserciones como estas: el Round de Delphi usa redondeo bancario, y 0.000012345 * 1e8 evalúa a 1234.4999… en binario en lugar de 1234.5. Nosotros dimos de lleno con ese valor al comprobar el camino de formateo cercano a cero. Elige entradas de test que no sean puntos medios binarios siempre que una aserción dependa de cómo redondea el último dígito

¿Qué controla SetPrecision?

TPDFlib.SetPrecision fija los decimales de los números que escribe la biblioteca: los gráficos dibujados a través del painter, los números creados desde un Double como por ejemplo con NewNumeric, y los valores parseados que hayan cambiado después con SetTo. Acepta de 2 a 8 y devuelve 0 para cualquier cosa fuera de ese rango. Un decimal parseado que nadie editó conserva su texto de entrada sea cual sea el ajuste, y cambiar el ajuste después de LoadFromFile no retroactúa sobre él. La fuente pasada a SetObjectFromString pasa por el mismo tokenizador y se conserva de la misma manera

Nuestra recomendación es dejar SetPrecision tranquilo salvo que la geometría que generes necesite pasos más finos. Subirlo para proteger ficheros de entrada no compra nada, porque la entrada intacta ya conserva sus propios dígitos, y alarga cada número que dibujes. Cinco posiciones es una elección razonable para trabajo de dibujo por debajo del punto; ocho casi siempre solo infla los content streams

uses
  PDFlibrary;

var
  PDF: TPDFlib;
begin
  PDF := TPDFlib.Create;
  try
    if PDF.LoadFromFile('calibrated.pdf', '') <> 1 then
      Exit;

    // Rige los números escritos a partir de aquí; los arrays /Gamma,
    // /Matrix y /WhitePoint parseados conservan su texto original
    PDF.SetPrecision(5);

    PDF.SelectPage(1);
    PDF.SetLineWidth(0.123456);       // los números dibujados usan cinco posiciones
    PDF.DrawLine(72, 72, 300, 72);

    PDF.SaveToFile('calibrated-out.pdf');
  finally
    PDF.Free;
  end;
end;

Content streams: las ediciones conservan operandos, la normalización es opt-in

El modelo de contenido editable conserva el texto de los operandos por defecto y canoniza números solo cuando la salida canónica es el propósito de la llamada. TPDFContentProgram.CanonicalNumbers vale False por defecto, así que una capa devuelta por ParsePageContentLayer, el mismo modelo sobre el que se construye el rastreador de estados gráficos, reemite cada operando numérico intacto exactamente como estaba escrito. Las ediciones de página que reescriben una capa entera, incluidas ScalePage, TransformPageElement, DeletePageElement, RedactRegion, ConvertTextToPaths y tu propio programa escrito de vuelta con WritePageContentLayer, cambian solo los operandos que la edición tocó

El otro valor por defecto parece más ordenado: canonizar en cada parse, de modo que el camino de edición y NormalizeContentStreams produzcan siempre la misma grafía. Nos parece el defecto equivocado para edición. Una llamada ScalePage sobre una capa que contiene 0.123456 w escribiría 0.1235 w, y todos los demás operandos de la capa se reescribirían también, aunque quien llama solo pidió escalar. La forma canónica pertenece a las operaciones cuyo propósito es una salida comparable

Esas operaciones siguen teniéndola. NormalizeContentStreams y el optimizador de contenido del guardado parsean con CanonicalNumbers a True, porque su trabajo es una salida estable a partir de entrada arbitraria; si 0.50000 0 0 RG y 0.5 0 0 RG se emitieran distinto, cada comparación aguas abajo derivaría con los hábitos de formateo del productor. Canonizar recorre recursivamente arrays y diccionarios, así que patrones de guiones, arrays TJ y diccionarios de propiedades de marked-content quedan cubiertos, mientras que los datos de imagen en línea en bruto se dejan tranquilos. Los operandos suministrados vía SetOperand se canonizan diga lo que diga el flag. Un programa de contenido lleva su propio almacén de números clavado a cuatro decimales, independiente de SetPrecision, así que un operando editado sale con cuatro. Solo Parse lee el flag, lo que significa que hay que fijarlo antes de parsear; para un programa que ParsePageContentLayer ya parseó, fija el flag y llama a Parse(Emit)

Cuándo normaliza números el modelo de contenido de PDFlibPas: con CanonicalNumbers a True cada operando numérico se canoniza cuando el parser lo empuja, los operandos pasados a SetOperand siempre se canonizan, la pasada recorre recursivamente arrays y diccionarios así que patrones de guiones, arrays TJ y diccionarios de propiedades de marked-content quedan cubiertos, y SetTo AsDouble borra el texto original para que 0.50000 y 0.5 se emitan igual
Los operandos numéricos canónicos son lo que piden NormalizeContentStreams y el optimizador: los datos de imagen en línea en bruto se dejan tranquilos, y los números de diccionario intactos fuera de los content streams conservan la garantía literal, así que un simple par LoadFromFile y SaveToFile sigue preservándolos
uses
  PDFlibrary, PDFlibContentModel, PDFlibContentOps;

var
  PDF: TPDFlib;
  Prog: TPDFContentProgram;
  I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    if PDF.LoadFromFile('calibrated.pdf', '') <> 1 then
      Exit;
    PDF.SelectPage(1);
    Prog := PDF.ParsePageContentLayer(1);
    if Prog = nil then
      Exit;
    try
      // Cambia un ancho de línea; todos los demás operandos conservan su texto fuente
      I := Prog.FindNextOp(0, coLineWidth);
      if I >= 0 then
        Prog.SetOperand(I, 0, '0.123456');   // se emite como 0.1235
      PDF.WritePageContentLayer(1, Prog);

      // Copia canónica solo para comparar, no se escribe de vuelta:
      // Parse es el único lector del flag, así que vuelve a parsear tras fijarlo
      Prog.CanonicalNumbers := True;
      Prog.Parse(Prog.Emit);
      // Prog.Emit ahora escribe 0.50000 y 0.5 de la misma forma
    finally
      Prog.Free;
    end;
    PDF.SaveToFile('calibrated-out.pdf');
  finally
    PDF.Free;
  end;
end;

En conjunto, la regla para quien llama es corta. Un LoadFromFile seguido de SaveToFile deja los content streams intactos y los números de diccionario intactos tal como estaban. Una página editada a través del modelo de contenido conserva el texto de cada operando que la edición no cambió. NormalizeContentStreams, el optimizador que corre cuando el contenido se comprime al guardar, y cualquier programa parseado con CanonicalNumbers a True producen números canónicos por diseño, y el resto del documento sigue preservado

Qué cuesta y dónde se acaba la garantía

Cada TPDFNumeric lleva una referencia AnsiString extra, y cada decimal parseado mantiene vivo su texto fuente mientras viva el objeto. En un documento con millones de números reales eso es memoria medible, y pertenece a cualquier benchmark de documentos grandes que corras. La garantía también está acotada al documento propio del número. Copiar objetos entre documentos, o reconstruir valores por la API de objetos, produce números nuevos, y los números nuevos siguen la precisión de salida

Los números preservados y la salida byte a byte idéntica son propiedades separadas. Un load-and-save de un documento intacto conserva los números de calibración que consume el renderer, pero la identidad byte a byte depende además de la numeración de objetos, la compresión de streams y el identificador de trailer tratado en el artículo del ID determinista de PDF. La comparación por huella sigue hasheando el cuerpo normalizado, lo que significa que las diferencias de redondeo en ficheros escritos por otras herramientas quedan fuera de lo que compara; para esas sigue valiendo la comparación de render

El manejo numérico y la semántica exacta de SetPrecision están cubiertos en la referencia de la losLab PDF Developer Library, junto con las llamadas del modelo de contenido usadas arriba