HotPDF v2.743.0 aplana las anotaciones PDF que no llevan un stream de apariencia /AP en lugar de omitirlas silenciosamente. FlattenLoadedAnnotations dirige ahora un widget sin apariencia a través de EnsureLoadedFieldAppearanceStream y construye un Form XObject para el markup sin apariencia a partir de las propias propiedades de la anotación, de modo que los valores escritos en un formulario /NeedAppearances sobreviven al aplanado y pasan al contenido de la página en lugar de desaparecer. El fallo que obligó a este cambio parece un no-op. Un cliente envía un formulario de solicitud rellenado e impreso a PDF desde un navegador. Se carga en HotPDF, se llama a FlattenLoadedAnnotations, devuelve 0, se guarda y se entrega un documento con cajas vacías donde el solicitante había escrito un nombre y un importe. No se lanzó nada ni se registró nada. Los valores habían estado en el fichero todo el tiempo, dentro de la entrada /V de cada campo, y la pasada de flattening los recorrió sin hacer nada porque ninguno de esos widgets llevaba un stream de apariencia
¿Por qué el aplanado de un formulario impreso desde el navegador pierde los valores escritos?
Porque un formulario /NeedAppearances guarda el valor sin guardar una imagen del valor. ISO 32000-1 12.7.2 permite que un formulario interactivo establezca /NeedAppearances true en el diccionario AcroForm, lo que indica al visor que construya la superficie visual de cada campo al abrirlo a partir de /V, /DA y /Q. Los productores que generan formularios de forma barata — rutas de impresión del navegador, rellenadores del lado del servidor y algunos frontends de escaneo — aceptan esa posibilidad y no escriben ningún /AP. El aplanado, tal como lo define el algoritmo de apariencia de ISO 32000-1 12.5.5, es un trabajo de transcripción: tomar el stream de apariencia normal de la anotación, mapear su /BBox sobre su /Rect, invocarlo desde el content stream de la página con un operador Do y después eliminar la anotación. Sin stream de origen no hay nada que transcribir. La implementación original de HotPDF, desde v2.386.0, trataba esto como «omitir», una decisión defendible en aislamiento y desastrosa en conjunto: los documentos que más probablemente necesitan aplanarse son los que menos probabilidades tienen de llevar apariencias. El mismo hueco absorbía el markup — un Highlight de una herramienta de revisión, un Square de un pase de redline o una firma Ink — cuando el productor confiaba en que el visor lo dibujara
Dónde se engancha la síntesis en FlattenLoadedAnnotations
El punto de enganche es deliberadamente tardío: después de que falle la búsqueda de la apariencia, no antes. FlattenLoadedAnnotations sigue solicitando primero la apariencia normal mediante GetLoadedAnnotationAppearanceStream, y una anotación que ya tenga una se aplana exactamente como en v2.386.0. Solo un resultado nil, en una anotación con un /Rect no degenerado y sin flag hidden, entra en la ruta de síntesis. El orden importa: un autor de documento que se tomó la molestia de escribir un /AP recupera sus propios bytes, no una reconstrucción de HotPDF
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
if (NStrm= nil) and (RR> RL) and (RT> RB) and ((FlagsValue and 2)= 0) then
begin
if Subtype= 'Widget' then
begin
FieldIdx:= GetLoadedFormFieldIndexForAnnotation(Indices[PgI], AnI, WidgetIdx);
if FieldIdx>= 0 then
EnsureLoadedFieldAppearanceStream(FieldIdx);
// Volver a preguntar: el generador ha asociado /AP /N al widget
NStrm:= GetLoadedAnnotationAppearanceStream(Indices[PgI], AnI, aakNormal);
end
else
NStrm:= SynthesizeMarkupAppearance(AnnotDict, Subtype, RL, RB, RR, RT);
end;
A partir de ahí las dos familias de anotaciones se separan. Un widget se resuelve hasta su campo propietario mediante GetLoadedFormFieldIndexForAnnotation y se entrega a EnsureLoadedFieldAppearanceStream, el generador de apariencias de campos que existe en esta biblioteca PDF para Delphi desde v2.328.0. Reutilizarlo en lugar de escribir un segundo renderer de campos es el objetivo: ya cubre fuentes Type0, ajuste de líneas, quadding, estados /AS de checkboxes y radios y rotación /MK, la misma maquinaria que está detrás de añadir campos AcroForm a un PDF ya cargado. Todo lo demás va al sintetizador de markup. Para el caller nada cambia: la misma llamada de una línea al flattening devuelve ahora un recuento distinto de cero en documentos que antes devolvían cero
Doc:= THotPDF.Create(nil);
try
Doc.LoadFromFile('needappearances-form.pdf');
// v2.743.0: los widgets y el markup sin AP se sintetizan y luego se aplanan
Flattened:= Doc.FlattenLoadedAnnotations; // todas las páginas, todos los subtipos
// Flattened:= Doc.FlattenLoadedAnnotations('1-3', 'Highlight');
if Flattened= 0 then
raise Exception.Create('nothing was flattened');
Doc.SaveLoadedDocument('flattened.pdf');
finally
Doc.Free;
end;
¿Por qué QuadPoints e InkList acaban en el lugar equivocado?
Porque esas coordenadas están en el user space de la página, mientras que el stream de apariencia sintetizado dibuja en su propio espacio /BBox, y los dos orígenes no son el mismo punto. La tabla 176 de ISO 32000-1 define /QuadPoints para las anotaciones de markup de texto en el default user space, y la tabla 174 hace lo mismo para los extremos /L de una anotación de línea; /InkList sigue la misma convención. HotPDF da al Form sintetizado un /BBox de [0 0 W H] cuyo origen se sitúa en la esquina inferior izquierda de /Rect. Por eso cada punto extraído de /QuadPoints, /L o /InkList tiene que trasladarse mediante el lower-left negado de /Rect antes de escribirlo en el content stream. Si se hace mal, un highlight en una línea situada 700 puntos más arriba de la página se dibuja 700 puntos por encima de su propia caja, lo que en la práctica significa que no aparece en ningún sitio. La corrección es una resta por coordenada y compone con el cm que emite el bake después: esa matriz vuelve a mapear el /BBox sobre /Rect, así que ambos pasos se cancelan hasta producir la geometría absoluta correcta
// Los extremos /L están en el user space de la página (ISO 32000-1 Tabla 174); el origen
// del BBox del Form se sitúa en el lower-left de /Rect, así que se desplaza por -(RL, RB)
X1:= ArrNum(LA, 0, 0)- RL;
Y1:= ArrNum(LA, 1, 0)- RB;
X2:= ArrNum(LA, 2, 0)- RL;
Y2:= ArrNum(LA, 3, 0)- RB;
StrokeOp:= ColorOp(DArr('C'), true);
if StrokeOp= '' then
StrokeOp:= '0 G';
Result:= _FloatToStrR(BW)+ ' w '#10+ StrokeOp+ #10+
_FloatToStrR(X1)+ ' '+ _FloatToStrR(Y1)+ ' m '+
_FloatToStrR(X2)+ ' '+ _FloatToStrR(Y2)+ ' l S'#10;
Qué dibuja realmente la apariencia de markup sintetizada
El sintetizador de markup lee el diccionario de la anotación y nada más, lo que mantiene predecible la salida y honesto lo que no puede saber. FreeText y Stamp dibujan /Contents usando la fuente y el color extraídos de /DA, alineados mediante /Q y con un padding de 2 pt. Square y Circle dibujan un contorno re o un Bezier de cuatro arcos trazado en /C, relleno con /IC cuando está presente y con la anchura de /BS /W. Line e Ink trazan sus vértices. Highlight rellena cada quad, mientras que Underline, StrikeOut y Squiggly trazan una regla en la parte inferior del quad, en su punto medio o como un zigzag de un punto. Un /CA inferior a 1 se convierte en un ExtGState con una entrada ca, referenciada como /GSA gs al principio del stream
La codificación del texto se decide a partir de la entrada /DR /Font del AcroForm nombrada por /DA. Si el /Subtype de esa fuente es Type0, HotPDF escribe la string como literal hexadecimal UTF-16BE con la marca de orden de bytes FEFF; en otro caso escribe una string literal escapada, con paréntesis y barras invertidas escapados y bytes superiores a 126 escritos en octal. El operador Tf de /DA se emite antes de BT, algo legal porque el estado de texto persiste entre los límites del objeto de texto, y evita desmontar la string /DA. Conviene declarar claramente dos límites. La anchura de línea para el wrapping y el quadding se estima con una heurística de medio em / em completo en lugar de con métricas reales de fuente, por lo que la alineación en una fuente proporcional es aproximada, no exacta. Y un subtipo sin nada sintetizable — Popup, Link o un Stamp cuyo único contenido sea un nombre de icono — devuelve nil y queda intacto, exactamente como antes
El intercambio temporal de /Annots que castiga una limpieza bienintencionada
FlattenOneWidget, la ruta por widget que utiliza FlattenLoadedFormFields, es una trampa de aliasing que cualquier cambio dentro del bucle compartido de flattening tiene que respetar. Sustituye temporalmente el valor /Annots de la página por un array de un elemento para que la pasada genérica opere sobre un solo widget, y después restaura el puntero original de PHPDFDictionaryItem dentro de un bloque finally. La restauración vuelve a escribir en una ranura del diccionario cuyo puntero capturó antes de la llamada
DictItem:= PHPDFDictionaryItem(PageObj.Items.Items[AnnotsIndex]);
Item:= DictItem^.Value;
TemporaryAnnots:= THPDFArrayObject.Create(nil);
TemporaryAnnots.AddObject(Target);
DictItem^.Value:= TemporaryAnnots;
try
Result:= FlattenLoadedAnnotations(IntToStr(PageIndex+ 1), 'Widget')= 1;
finally
DictItem^.Value:= Item; // colgante si el bucle interno ha liberado este elemento
TemporaryAnnots.Free;
end;
Añada una limpieza de aspecto razonable dentro del bucle interno compartido — un DeleteValue('Annots') cuando el array se queda vacío, para que la página guardada no conserve un array vacío vestigial — y esa llamada liberará el mismo elemento de diccionario al que apunta DictItem. El finally escribirá entonces mediante un puntero colgante y el proceso morirá con «Invalid pointer operation». Dos pruebas existentes lo detectaron inmediatamente, que es la única razón por la que esto es una nota al margen y no un ticket de soporte. La regla general es clara: antes de añadir una limpieza a un bucle compartido, revise los contratos de alias o de intercambio de sus callers. Un array /Annots vacío que sobra es una imperfección estética y no merece cambiar una garantía de vida útil del puntero por ella
Qué queda sin aplanar y cuánto cuesta el flattening
Las anotaciones ocultas se excluyen deliberadamente. Una anotación cuyo entero /F tiene activado el bit de posición 2 está oculta según ISO 32000-1 12.5.3, y cuando además no tiene /AP resulta tentador sintetizar uno y aplanarlo como el resto. Sería un bug con consecuencias de seguridad: incrustar una nota invisible en el contenido de la página la haría visible para todo el que abra el fichero. HotPDF deja esas anotaciones exactamente donde estaban y no las cuenta en el valor de retorno. Sea igual de claro con los usuarios sobre el precio de las que sí se aplanan. El flattening es irreversible: la anotación se elimina del array /Annots de la página y su visual pasa a ser contenido de página, de modo que ya no se puede editar el valor del campo, mantener un hilo de comentarios, cambiar un estado /AS ni recuperar los datos estructurados salvo desde el fichero original. Aplane una copia, conserve el original y utilice esa copia solo cuando el documento deje de ser un formulario para convertirse en un registro. Si el problema está respaldado por XFA y no por una apariencia ausente, la ruta separada de aplanado de XFA a AcroForm en HotPDF es el punto de partida, y si todavía está creando el formulario, las notas sobre conectar acciones y validación de campos AcroForm cubren el lado de escritura
Conviene añadir una advertencia de verificación, porque de lo contrario le costará una tarde. ExtractLoadedPageGlyphs no desciende a los Form XObjects, y una apariencia aplanada vive dentro de uno: el content stream de la página solo contiene una secuencia q ... cm /FlatAn<n> Do Q. Por eso la extracción de glifos en una página aplanada no informa de nada, y ese es el comportamiento correcto, no un bake perdido. Verifique a nivel de bytes, comprobando el nombre de recurso /FlatAn, la invocación Do y /Subtype /Form, o mediante la pipeline de renderizado, que sí expande los XObjects
El aplanado de anotaciones parece una transcripción de tres líneas hasta que aparecen los documentos que realmente genera la gente. Si trabaja con formularios rellenados, markup de revisión o salidas de archivo en Delphi o C++Builder, merece la pena leer cómo gestiona el componente PDF Delphi HotPDF el lado de documentos cargados de AcroForms y anotaciones antes de construir por su cuenta un generador de apariencias sobre él