Artículo técnico

Por qué un Single corrompe los flags de AcroForm en Delphi

HotPDF Delphi Component guarda cada número PDF en THPDFNumericObject.Value, que está declarado como Single, y un Single no puede contener todos los enteros de 32 bits. Los field flags de AcroForm (/Ff) son una máscara de 32 bits, así que cualquier máscara con un bit alto activo se redondeaba al cargar: $8000FFFF volvía como $80010000. Desde v2.989.5, las lecturas de field flags parsean el token entero original de forma exacta y copian sus bits en la API Integer existente

El bug merece una mirada de cerca aunque nunca toqueis formularios PDF. Nada crasheaba, no se lanzaba ninguna excepción, y la mayoría de ficheros de prueba pasaban, porque la mayoría de máscaras reales son pequeñas. El fallo solo aparecía cuando un bit alto y los flags bajos comunes estaban presentes a la vez, y entonces no perdía un bit, sino que reescribía varios a la vez. Guardar una máscara de bits en un tipo de coma flotante es una trampa clásica de Delphi, y esto es lo que parece en código de producción

Por qué un Single pierde bits de field flags de AcroForm

Un Single de Delphi tiene un significando de 24 bits, así que representa cada entero hasta 2^24 (16777216) de forma exacta y nada más allá sin huecos. Entre 2^31 y 2^32 los valores Single consecutivos distan 256. Una máscara de field flags con el bit 32 activo vive exactamente en ese rango, así que sus ocho bits bajos, y a menudo más, se redondean al múltiplo de 256 más cercano

Diagrama de redondeo de los field flags AcroForm de HotPDF para Delphi: la máscara /Ff de 32 bits $8000FFFF, 2147549183, pasa por un Single con significando de 24 bits y vuelve como $80010000 porque los valores Single representables distan 256 entre 2^31 y 2^32
Por encima de 2^31 un Single de Delphi solo puede guardar múltiplos de 256, así que el round-to-nearest reescribe el byte bajo de una máscara de field flags de 32 bits sin ninguna excepción

ISO 32000-1 §7.3.3 define los objetos integer y real como clases de número separadas, y el Anexo C lista límites de implementación distintos para cada uno, así que un entero PDF no tiene ninguna obligación de caber en un float de 32 bits. La entrada de field flags está definida en la Tabla 221 como un entero, y se lee bit a bit, no como una cantidad. Redondear una cantidad por una fracción minúscula es inofensivo; redondear una máscara arrastra a bits vecinos. Podéis reproducir el efecto completo en unas líneas de Delphi plano:

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183, no representable como Single
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: bits 1-16 limpiados, bit 17 activo

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: bits 1-14 limpiados, bit 15 activo
end;

La lección se generaliza más allá de PDF. Un Single, un Double, un Variant que contiene un float, o un número JSON decodificado a float es el contenedor equivocado para un campo de bits, incluso cuando los valores con los que probáis por casualidad son pequeños. Double empuja el rango exacto hasta 2^53, lo que esconde el problema para máscaras de 32 bits pero sigue dejando al tipo expresando la intención equivocada. Usad Cardinal, Integer o Int64 y convertid desde el texto de origen, no desde un float que ya está redondeado

Qué field flags voltean: Radio, Pushbutton, Password y Multiline

Las dos máscaras de arriba no son arbitrarias; muestran cómo un solo paso de redondeo cambia el tipo de campo que ve un lector. PDF numera los bits de flags desde 1, así que el bit n tiene el valor 2^(n-1). ISO 32000-1 §12.7.3.1 Tabla 226 define los bits 1 a 3 para todo campo, la Tabla 228 define los flags de botón, y la Tabla 230 define los flags de campo de texto

  • $8000FFFF a $80010000: el bit 16 (Radio, $8000, Tabla 228) se limpia y el bit 17 (Pushbutton, $10000) se activa, así que un radio button se convierte en un push button
  • El mismo redondeo limpia los bits 1 a 3, así que ReadOnly, Required y NoExport de la Tabla 226 desaparecen junto con Radio
  • $80003FFF a $80004000: el bit 13 (Multiline, $1000) y el bit 14 (Password, $2000) de la Tabla 230 se limpian ambos, así que una caja de contraseña enmascarada se convierte en un campo de texto plano de una línea
  • Por debajo del bit 32 el daño empieza en 2^24: 16777219 (Comb más ReadOnly y Required) se redondea a 16777220, que suelta ReadOnly y Required y añade NoExport

El último caso importaba a HotPDF más allá de la edición de formularios. ExportLoadedPagesStructured construye su modelo JSON y XML de formularios desde la máscara efectiva y usa NoExport en su decisión de supresión de valores, así que una máscara redondeada podía esconder un valor ordinario o informar miembros readOnly y required equivocados. La ruta de exportación estructurada recibió la misma lectura exacta en v2.989.8

Mapa de bits de los field flags AcroForm de HotPDF para Delphi: la máscara /Ff $8000FFFF con el bit 32 y los bits 1 a 16 activos pierde los bits 1 a 16 en un solo redondeo Single y gana el bit 17 Pushbutton, quedando en $80010000
Los flags de las Tablas 226, 228 y 230 de ISO 32000-1 se mueven en grupo: Radio voltea a Pushbutton mientras ReadOnly, Required, NoExport, Multiline y Password pueden desaparecer en el mismo paso de redondeo

Cómo lee HotPDF el token entero exacto de 32 bits

HotPDF Delphi Component arregla el lado de lectura sin cambiar el modelo de objetos numérico. El parser ya recuerda el texto del token original junto al valor Single, así que el lector de field flags parsea ese token como un entero, lo acumula en un Int64 acotado, y copia los 32 bits resultantes en el Integer que devuelve la API pública. Las APIs numéricas genéricas y el antiguo fallback no entero se quedan como estaban, y solo los consumidores de field flags toman el camino exacto

Ruta de lectura exacta de field flags AcroForm en HotPDF para Delphi: el parser guarda el token de origen junto al valor Single, TryParseFlagMask lo acumula en un Int64 acotado, y los 32 bits llegan a GetLoadedFormFieldFlags y SetLoadedFormFieldFlags como Integer
Los field flags parsean el texto del token recordado en lugar del Single redondeado, así que ambas grafías de una máscara de bit alto, 2147549183 y -2147418113, llegan con bits idénticos

El parser tiene que aceptar ambas formas en que los ficheros reales escriben una máscara de bit alto. Algunos escritores emiten el valor sin signo 2147549183, que está por encima del límite de entero del Anexo C de 2^31 - 1 pero es común en la práctica; otros emiten el valor con signo -2147418113. Ambos describen los mismos bits. Una versión del parser acotado se ve así:

function TryParseFlagMask(const Token: AnsiString; out Mask: Cardinal): Boolean;
var
  P, Digit: Integer;
  Negative: Boolean;
  Number, Limit: Int64;
begin
  Result := False;
  Mask := 0;
  if Token = '' then Exit;
  P := 1;
  Negative := Token[1] = '-';
  if Token[1] in ['+', '-'] then Inc(P);
  if P > Length(Token) then Exit;
  // Tokens con signo hasta -2^31, tokens sin signo hasta 2^32 - 1
  if Negative then Limit := 2147483648 else Limit := 4294967295;
  Number := 0;
  while P <= Length(Token) do
  begin
    if not (Token[P] in ['0'..'9']) then Exit;  // los reales van al fallback antiguo
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // acotado, sin overflow
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

Dos detalles son deliberados. El límite se comprueba antes de cada multiplicación, así que un token de 400 dígitos falla limpiamente en lugar de desbordar el Int64. Y un token como 3.0 se rechaza, lo que devuelve al llamador a Round(Value); el camino exacto mejora las máscaras enteras sin inventar semántica nueva para las malformadas

Desde el código de aplicación nada cambia en las firmas. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer devuelve la máscara efectiva, recorriendo la cadena de /Parent de la misma manera que se describe en cómo HotPDF resuelve los valores de campo AcroForm heredados y los reinicia, y THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer) escribe una máscara local completa. Una máscara con el bit 32 activo es simplemente un Integer negativo, así que moved los bits en lugar de convertir el valor:

const
  FF_RADIO      = $00008000;  // bit 16, ISO 32000-1 Tabla 228
  FF_PUSHBUTTON = $00010000;  // bit 17, Tabla 228
var
  Pdf: THotPDF;
  I, Native: Integer;
  Bits: Cardinal;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    for I := 0 to Pdf.GetFormFieldCount - 1 do
    begin
      Native := Pdf.GetLoadedFormFieldFlags(I);
      Move(Native, Bits, SizeOf(Bits));   // los mismos 32 bits, sin conversión
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

    Bits := $80000002;                    // bit alto más Required
    Move(Bits, Native, SizeOf(Native));   // Native = -2147483646
    Pdf.SetLoadedFormFieldFlags(0, Native);
    Pdf.SaveLoadedDocument('survey-out.pdf');
  finally
    Pdf.Free;
  end;
end;

Leer una máscara heredada deja el diccionario hijo intacto; escribir sustituye toda la máscara con una entrada local en el campo seleccionado. Los atajos SetLoadedFormFieldReadOnly y SetLoadedFormFieldRequired parten de la máscara heredada efectiva, conmutan un bit, y mantienen todos los demás bits exactos. Para rellenar valores en lugar de flags, ved establecer valores de campos de formulario en un PDF cargado con HotPDF

Por qué importa el orden de Value y RememberSourceToken

El orden importa porque THPDFNumericObject solo confía en su token recordado mientras el valor Single no cambia. RememberSourceToken guarda el token junto con una instantánea del Value actual, y el getter SourceToken devuelve un string vacío en cuanto Value difiere de esa instantánea. Esa regla de invalidación es lo que evita que un número editado se guarde con su texto original caducado

La regla se convierte en una trampa en el lado de escritura. Recordad primero el token y asignad el valor después, y la instantánea contiene el valor viejo, el getter informa de que no hay token, y el serializador vuelve a formatear el Single redondeado. Todo escritor de flags dentro de la biblioteca, incluidos los atajos y los constructores nativos de campos, sigue la misma secuencia:

// Correcto: la instantánea casa con Value, así que el token exacto sobrevive al guardado
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// Incorrecto: la instantánea se toma antes de que Value cambie, así que SourceToken
// se invalida y en su lugar se escribe el Single redondeado
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

Como SaveNumericObject escribe el token recordado siempre que haya uno válido, acertar con el orden es lo que hace que un fichero guardado y reabierto lleve el entero original, no una aproximación redondeada de él

El token null que hizo que dos campos compartieran un nombre

La misma versión arregló un segundo defecto del parser encontrado mientras se probaban estas máscaras: un valor null en un diccionario leído del stream nunca se consumía. HotPDF asoma la vista al siguiente token para clasificarlo, y esa comprobación deja la posición del stream al inicio del token. La rama otNull del lector de diccionarios en streaming creaba el objeto null pero no avanzaba más allá de los cuatro bytes de null

La consecuencia no fue un cuelgue sino una lectura errónea silenciosa. La siguiente búsqueda de clave escaneó hacia delante desde el lugar equivocado, corrió más allá del final del diccionario actual, y leyó claves del siguiente objeto indirecto, que entonces sobrescribía el campo actual. En la lista de campos el síntoma eran dos campos distintos informando el mismo nombre y el mismo valor. Los parsers de array y de span ya consumían null correctamente, así que el arreglo avanza el stream cuatro bytes en esa única rama en lugar de reescribir el parser general. Un cursor que no se mueve es de la misma familia de bug que los atascos de cursor del parser PDF que colgaban HotPDF con entrada malformada, con un síntoma distinto

El arreglo también aclaró la herencia. Un /Ff null local, o una referencia que resuelve a null, se trata como ausente y la búsqueda continúa al padre, mientras que un 0 explícito es una máscara real que anula al padre

Qué prueba y qué no una directiva {$R-}

La primera hipótesis para el bug de flags era un error de range check al asignar una máscara grande a un Integer. Estaba equivocada, y el motivo es un detalle de Delphi que conviene conocer: una directiva local {$R-} colocada antes de ParseSimpleDictionary en HPDFDoc.pas no tenía restauración correspondiente, así que toda función posterior en la unidad compilaba con los range checks apagados. Delphi no reinicia una directiva de conmutador al final de una función, y un include file hereda el estado de la directiva en el punto donde se incluye

Así que un -$R+ o -Cr de línea de comandos solo prueba la configuración inicial de la unidad. No dice nada sobre una función concreta más abajo. Para saber si el range checking está activo para una rutina dada, buscad la directiva más cercana por encima de ella, y luego confirmad el comportamiento con un valor de frontera real. Los dos consumidores antiguos de flags locales nunca lanzaron la excepción de rango que la hipótesis predecía, así que esa excepción ausente no podía citarse como evidencia de un arreglo. El defecto real era el redondeo Single a través de bits de flags conocidos

La lectura exacta del token es honesta también con su alcance. No cambia el estado de directivas de la unidad, no asigna significado a bits reservados, y no afirma que cada combinación de bits sea un campo válido. Garantiza que los 32 bits del fichero son los 32 bits que ve vuestro código

FAQ

¿Por qué no cambiar THPDFNumericObject.Value a Double o Int64?

Cambiar el tipo de un campo público rompería a todo llamador que lo lea o le asigne, y los números PDF son reales tan a menudo como enteros. Mantener Single y leer los field flags desde el token original arregla el bug para las máscaras de bits sin cambiar ninguna declaración ni convención de llamada existente

¿Acepta SetLoadedFormFieldFlags máscaras con el bit 32 activo?

Sí. Pasad la máscara como un Integer con signo; $80000002 y -2147483646 son los mismos 32 bits. GetLoadedFormFieldFlags devuelve el mismo valor después de guardar y recargar, incluido Required

¿Qué versión de HotPDF lee field flags AcroForm exactos?

Las lecturas exactas de field flags y el arreglo del token null llegaron en HotPDF v2.989.5, y la exportación estructurada JSON y XML adoptó la máscara exacta en v2.989.8. Las versiones anteriores redondean cualquier máscara por encima de 2^24 a través de Single

Los field flags AcroForm exactos, la búsqueda de campos heredados y la edición de formularios cargados son parte del HotPDF Component para Delphi y C++Builder, una biblioteca PDF nativa para crear, cargar y editar documentos PDF