Artículo técnico

Miembros lazy de object streams y rewrite completo en Delphi

Cuando HotPDF Delphi Component carga un archivo PDF 1.5 con LoadFromFile, no parsea los objetos empaquetados dentro de los contenedores /Type /ObjStm. Registra dónde vive cada miembro comprimido y lo parsea solo cuando algo lo pide. Esa invariante perezosa es lo que mantiene el tiempo de carga proporcional a lo que usted realmente toca, y también es la razón por la que un rewrite completo tiene que hacer un trabajo extra antes de que salga un solo byte: expandir cada miembro que todavía no se parseó, porque el rewrite está por tirar a la basura los contenedores donde viven esos miembros

El síntoma que motivó esta nota es fácil de describir y desagradable de depurar. Cargue un archivo cuyas fuentes, espacios de color y árbol de estructura están en object streams, pásarlo por el par de generación BeginDoc y EndDoc, y la salida abre sin quejarse. La cantidad de páginas es la correcta, el texto se ve en las páginas que usted revisa al azar. Después un colega abre la página 40 y el cuerpo del texto se renderiza con una fuente sustituida, o el comando Extract Text devuelve basura donde antes había un reemplazo ActualText. Nada crasheó. El writer simplemente serializó un objeto que nunca se cargó, y un objeto sin cargar se serializa como nada

¿Qué guarda realmente LoadFromFile de un objeto comprimido?

Para cada entrada de referencias cruzadas de tipo 2, LoadFromFile guarda un registro chico en FCompactObjects: el número de objeto, el índice del stream contenedor en la tabla de contenedores, la posición del miembro dentro de ese stream y un puntero ParsedObject que arranca en nil. El contenedor en sí se localiza, se descifra si el documento está cifrado y se infla, pero los cuerpos de los miembros quedan como bytes. ISO 32000-1 §7.5.7 define el formato del contenedor que hace esto posible: una cabecera de pares número de objeto y offset, y después los cuerpos de los miembros concatenados a partir de /First, así que cualquier miembro se puede cortar sin tocar a sus vecinos

EnsureCompressedObjectLoaded es el único camino que convierte un registro en un objeto. Busca el registro por número de objeto y, si ParsedObject ya está asignado, devuelve ese objeto cacheado y cuenta un cache hit. Si no, recarga el contenedor si fue desalojado, calcula el rango de bytes del miembro a partir de la tabla de offsets, le pasa al parser una vista sin copia de ese slice y guarda el resultado de vuelta en el registro. De ahí en adelante el objeto es indirecto, lleva su número de objeto real y queda registrado en el índice de objetos del documento como cualquier objeto que se haya parseado del cuerpo del archivo. El catálogo, el diccionario de información, la raíz del árbol de páginas y los objetos de página pasan por este camino al cargar porque la navegación los necesita. Las fuentes, los espacios de color, los diccionarios ExtGState y los elementos de estructura no, y se quedan como registros hasta que un renderizado de página o un rewrite los toque

Cómo guarda HotPDF Delphi Component un miembro comprimido antes de parsearlo: el registro FCompactObjects mantiene el número de objeto, el índice de contenedor, el índice de miembro y un puntero ParsedObject en nil, mientras que EnsureCompressedObjectLoaded convierte un registro en un objeto registrado a través de cache hits, recargas del contenedor, cortes por tabla de offsets y parseo sin copia
LoadFromFile deja los cuerpos de los miembros /ObjStm como bytes y los parsea solo cuando un lector los pide, así que el tiempo de carga sigue a lo que usted toca — el catálogo y el árbol de páginas llegan temprano mientras las fuentes, los espacios de color y los elementos de estructura se quedan como registros

Esto se puede observar desde afuera. GetLoadedObjectStreamCacheInfo informa cuántos contenedores existen, cuántos miembros se indexaron y cuántos de esos se parsearon hasta ahora:

var
  Pdf: THotPDF;
  Info: THPDFObjectStreamCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('tagged-report.pdf');
    if Pdf.GetLoadedObjectStreamCacheInfo(Info) then
      Writeln(Format('%d containers, %d members indexed, %d parsed so far',
        [Info.ContainerCount, Info.IndexedObjectCount,
         Info.MaterializedObjectCount]));
  finally
    Pdf.Free;
  end;
end;

En un archivo cargado de estructura, el tercer número es una fracción chica del segundo apenas termina la carga. Esa brecha es justamente el punto del lazy loading, y también es exactamente el conjunto de objetos por los que un rewrite completo tiene que volver

¿Por qué un rewrite completo pierde fuentes que un save incremental conserva?

Un rewrite completo descarta los contenedores /ObjStm y /XRef del archivo fuente y reserializa el grafo de objetos desde cero, así que cualquier miembro cuyo ParsedObject siga en nil no tiene representación en la salida. Una actualización incremental nunca tiene este problema, porque agrega los objetos nuevos después de los bytes originales y deja los contenedores viejos en su lugar para que los direccione la sección de referencias cruzadas anterior. La diferencia no está en cómo los dos modos tratan las fuentes. Está en si los contenedores originales sobreviven para que los lea el próximo visor

El fix vive en SaveToStream, el serializador que EndDoc maneja tanto si usted fija FileName como OutputStream. Antes de despachar a cualquier rama del writer, recorre FCompactObjects y llama a EnsureCompressedObjectLoaded en cada entrada. Si un miembro no se puede cargar, el save lanza excepción en lugar de seguir, porque un rewrite que descarta un diccionario de fuente en silencio es peor que uno que se detiene. La expansión tiene que estar a ese nivel, por encima de las ramas clásica, empaquetada y linearizada, y por encima de la poda de streams estructurales recargados que hace la ruta linearizada. Una versión anterior expandía los miembros solo dentro de SaveLoadedDocument, lo que cubría el vocabulario de documento cargado y se perdía por completo el vocabulario de generación. LoadFromFile seguido de BeginDoc, ediciones de página y EndDoc iba directo al writer con cada miembro intacto todavía sin parsear

Dónde se ubica la expansión del rewrite completo en HotPDF: SaveToStream recorre cada entrada de FCompactObjects con EnsureCompressedObjectLoaded antes de despachar al writer clásico, empaquetado o linearizado, así que tanto el vocabulario de SaveLoadedDocument como el de LoadFromFile más BeginDoc más EndDoc serializan objetos totalmente parseados en lugar de registros nil
Una actualización incremental agrega contenido después de los bytes originales y mantiene legibles los contenedores viejos, pero un rewrite completo los descarta — una sola pasada de expansión por encima de cada rama del writer es lo que evita que una fuente o un elemento de estructura sin cargar se serialice como nada
// Los dos vocabularios de rewrite ahora expanden los miembros compactos antes de que corra cualquier writer.
// Ruta de documento cargado:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.SaveLoadedDocument('quarterly-rewritten.pdf');

// Ruta de generación sobre un archivo cargado:
Pdf.LoadFromFile('quarterly.pdf');
Pdf.FileName := 'quarterly-stamped.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(40, 20, 0, 'Reviewed 2026-09-11');
Pdf.EndDoc;   // SaveToStream materializa cada entrada de FCompactObjects primero

Los miembros cacheados conservan lo que usted les haya hecho. Un objeto que se parseó, se editó y se marcó como sucio antes del save vuelve del caché con sus ediciones, y un miembro que usted borró mantiene su estado de borrado a lo largo de saves repetidos. La pasada de expansión es idempotente por construcción: solo rellena slots en nil

Por qué revisar los píxeles de tres páginas no detecta el caso ActualText

Los elementos de estructura son donde este bug se esconde más tiempo. Una entrada ActualText sobre una secuencia de contenido marcado, definida en ISO 32000-1 §14.9.4, reemplaza los glifos para la extracción y la accesibilidad, pero no afecta el renderizado. Si el elemento de estructura vive en un object stream y el rewrite lo pierde, la página igual se dibuja bien, la primera, la del medio y la última página se comparan píxel por píxel contra la fuente, y la regresión solo aparece cuando alguien corre extracción de texto o un lector de pantalla. Una prueba de rewrite que solo renderiza páginas no es una prueba de rewrite para PDF etiquetado. Compare también el texto extraído y el árbol de estructura

¿Cómo cambia la carga una contraseña de usuario vacía?

Una contraseña de usuario vacía igual significa que el archivo está cifrado, y los object streams de un archivo así son texto cifrado hasta que se recupera la file key. El Algoritmo 2 de ISO 32000-1 §7.6.3.4 deriva esa clave de la contraseña, la entrada /O, /P y el primer identificador del documento, y HotPDF tiene que correrlo contra la cadena vacía antes de que la pasada de tipo 2 pueda inflar un solo contenedor. Por eso BeginDoc sobre un documento cifrado cargado llama a DecryptLoadedDocument con una contraseña vacía antes que nada: el grafo de objetos tiene que estar autenticado y descifrado antes de que un rewrite pueda empezar, sin importar si quien llama tiene la intención de proteger la salida. El cifrado de la salida es una decisión aparte, que depende de las opciones de protección de quien llama, y BeginDoc restaura esas opciones después de la pasada de descifrado para que una entrada cifrada no se convierta en silencio en una salida cifrada

La política de contenedores se lee del diccionario /Encrypt antes de probar cualquier contraseña. Para /V 1 y 2 cada stream se cifra con la file key. Con crypt filters, HotPDF resuelve /StmF a través de /CF: un filtro Identity o un /CFM de None significan contenedores en texto plano, mientras que V2 y AESV2 significan contenedores cifrados. La respuesta queda en FReloadObjectStreamsEncrypted, y importa para un caso concreto. Cuando los contenedores están en texto plano pero los strings no, los miembros llevan strings cifrados que hay que descifrar uno por uno, así que MaterializeMembersOfPlaintextObjectStreams expande cada miembro compacto antes de la pasada de descifrado por objeto. No hace nada cuando la política todavía no se conoce ni cuando los contenedores mismos venían cifrados, porque los miembros de un contenedor cifrado ya se descifraron con él y nunca deben descifrarse dos veces

¿Qué pasa cuando un contenedor no se puede descifrar?

Un contenedor que falla al descifrarse queda en cuarentena, no es fatal. La pasada de tipo 2 registra una entrada THPDFObjStmQuarantineInfo en FObjStmQuarantine con el número de objeto del contenedor, un THPDFObjStmQuarantineReason, un string de diagnóstico y la lista de números de objeto de los miembros que las referencias cruzadas habían ruteado hacia él. osqrDecryptFailed se lanza en cuatro situaciones distintas: no se pudo resolver ningún crypt filter, el descifrado AES-256 o AES-GCM lanzó excepción, el descifrado heredado RC4 o AES-128 lanzó excepción, o no existe ninguna file key utilizable. Los contenedores independientes siguen cargando, así que un documento con un contenedor dañado igual abre y sigue renderizando cada página que no dependa de él

Cómo funciona la cuarentena de descifrado de HotPDF en un PDF cargado: un contenedor cuyo descifrado lanza excepción se registra como THPDFObjStmQuarantineInfo con un motivo osqrDecryptFailed y sus números de objeto de miembro, los contenedores independientes siguen cargando, y BeginDoc lanza excepción en la primera entrada fallida antes de que un rewrite pueda reportar éxito
Los registros de cuarentena sobreviven al fallback del parser y BeginDoc los chequea por nombre en lugar de por el flag de cifrado, así que un documento con un contenedor dañado igual abre mientras la ruta de rewrite se detiene en lugar de escribir objetos vacíos

La lista de cuarentena sobrevive al fallback del parser. Si la carga principal de referencias cruzadas falla y HotPDF reconstruye la tabla de objetos escaneando el archivo, el flag de cifrado del primer intento puede no sobrevivir a esa reconstrucción, pero los registros de cuarentena sí. Por eso BeginDoc chequea la lista de cuarentena en lugar del flag de cifrado: sobre un documento cargado recorre FObjStmQuarantine y lanza excepción en la primera entrada osqrDecryptFailed, nombrando el contenedor y pidiendo una recarga con una contraseña válida. Un rewrite que siguiera más allá de ese punto escribiría como objetos vacíos los miembros que el contenedor debía guardar y reportaría éxito. Usted puede correr la misma verificación por su cuenta, más temprano y con su propia política, a través de los accesores públicos:

var
  Info: THPDFObjStmQuarantineInfo;
  I: Integer;
begin
  Pdf.LoadFromFile('vendor-form.pdf');   // contraseña de usuario vacía
  for I := 0 to Pdf.GetLoadedQuarantinedObjStmCount - 1 do
    if Pdf.GetLoadedQuarantinedObjStmInfo(I, Info) and
       (Info.Reason = osqrDecryptFailed) then
      raise Exception.CreateFmt(
        'Object stream %d is unreadable (%s); %d members unresolved',
        [Info.ContainerObjNum, String(Info.Diagnostic),
         Length(Info.MemberObjNums)]);
  // a partir de acá es seguro reescribir
end;

Los otros motivos de cuarentena cubren las fallas no criptográficas: un contenedor que no es un stream, un diccionario faltante, un /N o un /First inválido, un tamaño de stream fuera del rango aceptado, una falla de descompresión, un /First que apunta más allá de los datos, o un cuerpo de miembro que se decodificó pero no se parseó. Vale la pena loguearlos en la ingesta, ya que cada uno nombra exactamente los miembros que le van a faltar aguas abajo

¿Por qué un rewrite necesita el token numérico original?

HotPDF guarda cada objeto numérico como Single, y un Single no puede reproducir el texto fuente de un número real. ISO 32000-1 §7.3.3 permite que un writer emita 0.750000, .75 o 0.75 para el mismo valor, y ninguno de esos sobrevive sin cambios a un ida y vuelta por binario de 24 bits y un formateador genérico. Peor todavía, un valor como 0.7 no es representable en un Single; se parsea al float más cercano, y reformatear ese float puede producir 0.69999999 o un vecino redondeado según el bucle de dígitos. En un color de relleno o una constante de transparencia /CA, eso es una diferencia de un paso en un canal de 8 bits, suficiente para fallar una comparación de píxeles contra la fuente y, en los bordes de un degradado, suficiente para verse

THPDFNumericObject.RememberSourceToken resuelve esto para el caso sin modificar. El parser la llama con el token crudo justo después de asignar Value; el método acepta solo tokens formados por dígitos, como máximo un punto decimal y un signo inicial opcional, y guarda el token junto con el valor al que correspondía en FSourceValue. La propiedad SourceToken devuelve el texto guardado solo mientras Value siga siendo igual a FSourceValue. Cambie el número y el token se evapora, así que un valor modificado siempre pasa por el camino de formateo existente y nunca emite texto viejo. SaveNumericObject chequea SourceToken primero y lo escribe tal cual cuando está presente, y solo cae a las ramas de entero, referencia a espacio de color y fraccionario para los números que se crearon o editaron en memoria

La invariante es chica y vale la pena decirla sin vueltas: un número que usted no tocó se escribe con los bytes con los que se leyó, y un número que sí tocó lo escribe el formateador propio de HotPDF. Los miembros compactos se benefician de esto igual que los objetos del cuerpo, ya que EnsureCompressedObjectLoaded corre el mismo parser sobre el slice del miembro. El formateo de números en sí, y su independencia del locale del proceso, está cubierto en el artículo sobre formateo de números PDF invariante del locale en HotPDF

Probar una ruta de rewrite contra object streams

Tres verificaciones atrapan cada falla descrita arriba, y ninguna necesita Acrobat. Primero, compare IndexedObjectCount contra MaterializedObjectCount después del save; en un rewrite completo tienen que ser iguales, y cualquier brecha es un miembro que se descartó. Segundo, extraiga texto y enumere el árbol de estructura en los dos archivos, no solo los renderice, para que un ActualText perdido o un elemento de estructura perdido aparezcan como un diff. Tercero, cargue la salida con una instancia nueva y verifique que GetLoadedQuarantinedObjStmCount sea cero, lo que además prueba que el writer no produjo un contenedor que el lector no pueda abrir. Las combinaciones de crypt filters que deciden FReloadObjectStreamsEncrypted están detalladas en el artículo sobre las políticas StmF, StrF y EFF. El lado del writer de esta historia, cómo emitir object streams y cuándo preferir una actualización incremental antes que un rewrite, está en la guía de object streams y actualizaciones incrementales

La carga perezosa de miembros, la pasada de expansión previa al writer, la cuarentena de descifrado y la preservación del token fuente vienen todos en HotPDF Delphi Component para Delphi y C++Builder. La página del producto enlaza la referencia de API si usted quiere seguir GetLoadedObjectStreamCacheInfo y los accesores de cuarentena contra su propio pipeline de ingesta