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 cualquier entero 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 incluso si nunca toca formularios PDF. Nada se colgó, no se lanzó ninguna excepción, y la mayoría de los archivos de prueba pasaban, porque la mayoría de las máscaras reales son pequeñas. La falla solo aparecía cuando un bit alto y los flags comunes bajos estaban presentes juntos, 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 punto flotante es una trampa clásica de Delphi, y así se ve 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 entre sí. Una máscara de field flags con el bit 32 activo vive justo 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 de 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 sí 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 redondeo al más cercano 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 entero y real como tipos de número separados, y el Anexo C lista diferentes límites de implementación para cada uno, así que un entero PDF no tiene 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 por bit, no como una cantidad. Redondear una cantidad por una fracción mínima es inofensivo; redondear una máscara arrastra a los bits vecinos. Puede reproducir todo el efecto en unas pocas líneas de Delphi puro:

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 activado

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: bits 1-14 limpiados, bit 15 activado
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 casualmente prueba son pequeños. Double empuja el rango exacto hasta 2^53, lo que esconde el problema para máscaras de 32 bits pero aún deja al tipo expresando la intención equivocada. Use Cardinal, Integer o Int64 y convierta desde el texto de origen, no desde un float que ya fue redondeado

Qué field flags se 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 un lector ve. 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 campos 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 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, lo que pierde ReadOnly y Required y agrega NoExport

El último caso importaba para HotPDF más allá de la edición de formularios. ExportLoadedPagesStructured construye su modelo de formulario JSON y XML a partir de 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 reportar los 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 de 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 se 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 de 32 bits exacto

HotPDF Delphi Component corrige el lado de lectura sin cambiar el modelo de objetos numéricos. 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 la API pública devuelve. Las APIs numéricas genéricas y el viejo fallback para no enteros quedan como estaban, y solo los consumidores de field flags toman la ruta exacta

Ruta de lectura exacta de field flags de AcroForm de HotPDF en Delphi: el parser conserva 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 un 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 archivos 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 toman el viejo fallback
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // acotado, sin desbordamiento
    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 verifica 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); la ruta exacta mejora las máscaras enteras sin inventar semántica nueva para las malformadas

Desde el código de la 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 descrita en cómo HotPDF resuelve y restablece valores heredados de campos AcroForm, 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 mueva 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 reemplaza la máscara completa 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 cada otro bit exacto. Para llenar valores en lugar de flags, vea 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 una cadena vacía tan pronto como 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 obsoleto

La regla se convierte en una trampa del lado de escritura. Recuerde el token primero y asigne el valor después, y la instantánea contendrá el valor viejo, el getter reportará que no hay token, y el serializador recurrirá 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 coincide con Value, así 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í 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 archivo 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 corrigió un segundo defecto del parser encontrado mientras se probaban estas máscaras: un valor null en un diccionario leído del flujo nunca se consumía. HotPDF asoma la vista al siguiente token para clasificarlo, y esa verificación deja la posición del flujo al inicio del token. La rama otNull del lector de diccionarios por flujo 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 adelante desde el lugar equivocado, pasó el final del diccionario actual, y leyó claves del siguiente objeto indirecto, que luego sobrescribía el campo actual. En la lista de campos el síntoma era dos campos distintos reportando el mismo nombre y el mismo valor. Los parsers de arreglos y de spans ya consumían null correctamente, así que la corrección avanza el flujo cuatro bytes en esa única rama en lugar de reescribir el parser general. Un cursor que no se mueve es la misma familia de bug que el de los atascos del cursor del parser PDF que colgaban a HotPDF con entrada malformada, con un síntoma distinto

La corrección 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 hacia el padre, mientras que un 0 explícito es una máscara real que invalida la del padre

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

La primera hipótesis para el bug de flags fue un error de range-check al asignar una máscara grande a un Integer. Estaba equivocada, y la razón es un detalle de Delphi que vale conocer: una directiva local {$R-} colocada antes de ParseSimpleDictionary en HPDFDoc.pas no tenía una restauración correspondiente, así que toda función posterior de la unidad compilaba con las verificaciones de rango apagadas. Delphi no reinicia una directiva de conmutador al final de una función, y un archivo include hereda el estado de directivas 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 particular más abajo. Para saber si la verificación de rango está activa para una rutina dada, busque la directiva más cercana arriba de ella, y luego confirme el comportamiento con un valor de frontera real. Los dos consumidores locales de flags más antiguos 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 una corrección. El defecto real era el redondeo Single a través de bits de flags conocidos

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

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 corrige el bug para máscaras de bits sin cambiar ninguna declaración o convención de llamada existente

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

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

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

Las lecturas exactas de field flags y la corrección 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 de AcroForm exactos, la búsqueda de campos heredados y la edición de formularios cargados son parte del HotPDF Component for Delphi y C++Builder, una biblioteca PDF nativa para crear, cargar y editar documentos PDF