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
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
// 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
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