Artículo técnico

Miembros lazy de object stream y reescritura completa de PDF

Cuando HotPDF Delphi Component carga con LoadFromFile un archivo PDF 1.5, no parsea los objetos empaquetados dentro de contenedores /Type /ObjStm. Registra dónde vive cada miembro comprimido y lo parsea solo cuando algo lo pide. Ese invariante lazy es lo que mantiene el tiempo de carga proporcional a lo que tocas de verdad, y también es la razón por la que una reescritura completa tiene que hacer un trabajo extra antes de soltar un solo byte: expandir cada miembro que sigue sin parsear, porque la reescritura está a punto de tirar los contenedores en los que viven esos miembros

El síntoma que motivó esta nota es fácil de describir y desagradable de depurar. Carga un archivo cuyas fuentes, espacios de color y árbol de estructura viven en object streams, pásalo por el par de generación BeginDoc y EndDoc, y la salida abre sin quejarse. El número de páginas es correcto, el texto se ve en las páginas que compruebas al azar. Luego un compañero abre la página 40 y el texto del cuerpo se renderiza con una fuente sustituida, o el comando Extract Text devuelve basura donde antes había un reemplazo ActualText. No petó nada. El writer simplemente serializó un objeto que nunca se había cargado, y un objeto sin cargar se serializa como nada

¿Qué guarda LoadFromFile en realidad para un objeto comprimido?

Para cada entrada de cross-reference de tipo 2, LoadFromFile guarda un pequeño registro 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 se dejan como bytes. La ISO 32000-1 §7.5.7 define la disposición del contenedor que lo hace posible: una cabecera de pares de número de objeto y offset, y después los cuerpos de los miembros concatenados tras /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 acierto de caché. En caso contrario recarga el contenedor si se había expulsado, calcula el rango de bytes del miembro a partir de la tabla de offsets, le pasa al parser una vista sin copia de ese trozo, y guarda el resultado de vuelta en el registro. A partir de ahí 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 desde el cuerpo del archivo. El catálogo, el diccionario de información, la raíz del árbol de páginas y los objetos página pasan por este camino en tiempo de carga 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 los toque un render de página o una reescritura

Cómo guarda HotPDF Delphi Component un miembro comprimido antes de parsearlo: el registro FCompactObjects conserva el número de objeto, el índice de contenedor, el índice de miembro y un puntero ParsedObject a nil, mientras EnsureCompressedObjectLoaded convierte un registro en un objeto registrado mediante aciertos de caché, recargas de 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 tocas — el catálogo y el árbol de páginas llegan pronto mientras las fuentes, los espacios de color y los elementos de estructura se quedan como registros

Esto se puede ver desde fuera. GetLoadedObjectStreamCacheInfo informa de cuántos contenedores existen, cuántos miembros se indexaron, y cuántos de esos se han parseado 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 pequeña del segundo justo después de cargar. Esa diferencia es justo el sentido de la carga lazy, y es también exactamente el conjunto de objetos por el que una reescritura completa tiene que volver

¿Por qué una reescritura completa pierde fuentes que un guardado incremental conserva?

Una reescritura completa descarta los contenedores /ObjStm y /XRef del archivo de origen 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 añade objetos nuevos después de los bytes originales y deja los contenedores viejos en su sitio para que la sección de cross-reference anterior los direccione. La diferencia no está en cómo tratan las fuentes los dos modos. Está en si los contenedores originales sobreviven para que los lea el siguiente visor

El fix vive en SaveToStream, el serializador que EndDoc maneja tanto si pones FileName como OutputStream. Antes de despachar a cualquier rama del writer, recorre FCompactObjects y llama a EnsureCompressedObjectLoaded sobre cada entrada. Si un miembro no se puede cargar, el guardado lanza excepción en lugar de continuar, porque una reescritura que se come un diccionario de fuente en silencio es peor que una que se detiene. La expansión tiene que estar a ese nivel, por encima de las ramas classic, packed y linearized, y por encima de la poda que hace la ruta linearized de los streams estructurales recargados. Una versión anterior expandía los miembros solo dentro de SaveLoadedDocument, lo que cubría el vocabulario de documento cargado y se dejaba 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 sitúa la expansión de la reescritura completa en HotPDF: SaveToStream recorre cada entrada de FCompactObjects con EnsureCompressedObjectLoaded antes de despachar al writer classic, packed o linearized, 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 añade después de los bytes originales y mantiene legibles los contenedores viejos, pero una reescritura completa los descarta — una pasada de expansión por encima de todas las ramas del writer es lo que evita que una fuente o un elemento de estructura sin cargar se serialice como nada
// Los dos vocabularios de reescritura expanden ahora los miembros compactos antes de que corra ningún 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 antes cada entrada de FCompactObjects

Los miembros cacheados conservan lo que les hayas hecho. Un objeto que se parseó, se editó y se marcó como sucio antes del guardado se devuelve desde la caché con sus ediciones, y un miembro que borraste conserva su estado de borrado entre guardados repetidos. La pasada de expansión es idempotente por construcción: solo rellena huecos nil

Por qué las comprobaciones de píxeles en tres páginas no ven el caso ActualText

Los elementos de estructura son donde más tiempo se esconde este bug. Una entrada ActualText en una secuencia de contenido marcado, definida en la ISO 32000-1 §14.9.4, sustituye los glifos para la extracción y la accesibilidad pero no afecta al renderizado. Si el elemento de estructura vive en un object stream y la reescritura lo pierde, la página se sigue dibujando bien, la primera, la del medio y la última página comparan píxel a píxel contra el origen, y la regresión solo aparece cuando alguien lanza la extracción de texto o un lector de pantalla. Un test de reescritura que solo renderiza páginas no es un test de reescritura para PDF etiquetado. Compara 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 sigue significando que el archivo está cifrado, y los object streams de un archivo así son texto cifrado hasta que se recupera la clave del archivo. El algoritmo 2 de la ISO 32000-1 §7.6.3.4 deriva esa clave de la contraseña, la entrada /O, /P y el primer identificador de documento, y HotPDF tiene que ejecutarlo 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 autenticarse y descifrarse antes de que pueda empezar una reescritura, independientemente de si quien llama pretende proteger la salida. El cifrado de la salida es una decisión aparte, guiada por los ajustes de protección de quien llama, y BeginDoc restaura esos ajustes tras 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 ninguna contraseña. Para /V 1 y 2 cada stream se cifra con la clave del archivo. Para los crypt filters, HotPDF resuelve /StmF a través de /CF: un filtro Identity o un /CFM de None significa contenedores en texto plano, mientras que V2 y AESV2 significan cifrados. La respuesta aterriza en FReloadObjectStreamsEncrypted, y importa para un caso concreto. Cuando los contenedores están en texto plano pero las cadenas no, los miembros llevan cadenas cifradas que hay que descifrar una a una, así que MaterializeMembersOfPlaintextObjectStreams expande cada miembro compacto antes de la pasada de descifrado por objeto. No hace nada cuando la política aún no se conoce ni cuando los propios contenedores estaban cifrados, porque los miembros de un contenedor cifrado ya se descifraron con él y no deben descifrarse nunca dos veces

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

Un contenedor que falla al descifrarse se pone 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, una cadena de diagnóstico, y la lista de números de objeto de miembro que el cross-reference había enrutado 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 RC4 o AES-128 heredado lanzó excepción, o no existe ninguna clave de archivo utilizable. Los contenedores independientes siguen cargándose, así que un documento con un contenedor dañado se sigue abriendo y sigue renderizando todas las páginas que no dependan 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 un THPDFObjStmQuarantineInfo con motivo osqrDecryptFailed y sus números de objeto de miembro, los contenedores independientes siguen cargándose, y BeginDoc lanza excepción en la primera entrada fallida antes de que una reescritura pueda reportar éxito
Los registros de cuarentena sobreviven al fallback del parser y BeginDoc los comprueba por nombre en lugar de por el flag de cifrado, así que un documento con un contenedor dañado se sigue abriendo mientras el camino de reescritura se detiene en lugar de escribir objetos vacíos

La lista de cuarentena sobrevive al fallback del parser. Si la carga primaria del cross-reference 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 comprueba 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. Una reescritura que continuara más allá de ese punto escribiría los miembros que el contenedor debía contener como objetos vacíos y reportaría éxito. Puedes hacer la misma comprobación por tu cuenta, antes y con tu 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 aquí se puede reescribir con seguridad
end;

Los demás motivos de cuarentena cubren los fallos no criptográficos: un contenedor que no es un stream, un diccionario ausente, un /N o /First inválido, un tamaño de stream fuera del rango aceptado, un fallo de descompresión, un /First que apunta más allá de los datos, o un cuerpo de miembro que se decodificó pero no parseó. Merece la pena registrarlos al ingerir, ya que cada uno nombra exactamente los miembros que te van a faltar más adelante

¿Por qué necesita una reescritura el token numérico original?

HotPDF guarda cada objeto numérico como un Single, y un Single no puede reproducir el texto de origen de un número real. La ISO 32000-1 §7.3.3 permite que un writer emita 0.750000, .75 o 0.75 para el mismo valor, y ninguno de ellos sobrevive intacto a un ida y vuelta por binario de 24 bits y un formateador genérico. Peor aún, un valor como 0.7 no es representable en un Single en absoluto; 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 en una constante de transparencia /CA, eso es una diferencia de un paso en un canal de 8 bits, que basta para fallar una comparación de píxeles contra el origen y, en los límites de un degradado, basta para verlo

THPDFNumericObject.RememberSourceToken resuelve esto para el caso sin modificar. El parser lo llama con el token crudo justo después de asignar Value; el método acepta solo tokens formados por dígitos, como mucho 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. Cambia el número y el token se evapora, así que un valor modificado pasa siempre por el camino de formateo existente y nunca emite texto obsoleto. SaveNumericObject comprueba SourceToken primero y lo escribe tal cual cuando está presente, y recurre a las ramas de entero, referencia a espacio de color y fraccionario solo para números que se crearon o se editaron en memoria

El invariante es pequeño y merece decirse claro: un número que no tocaste se escribe con los bytes con los que se leyó, y un número que sí tocaste lo escribe el formateador propio de HotPDF. Los miembros compactos se benefician de esto igual que los objetos del cuerpo, ya que EnsureCompressedObjectLoaded ejecuta el mismo parser sobre el trozo 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 independiente del locale en HotPDF

Probar un camino de reescritura contra object streams

Tres comprobaciones cazan cada fallo descrito arriba, y ninguna necesita Acrobat. Primero, compara IndexedObjectCount con MaterializedObjectCount después del guardado; en una reescritura completa tienen que ser iguales, y cualquier diferencia es un miembro que se perdió. Segundo, extrae el texto y enumera el árbol de estructura en los dos archivos, no solo los renderices, para que un ActualText perdido o un elemento de estructura perdido aparezcan como un diff. Tercero, carga la salida con una instancia nueva y afirma que GetLoadedQuarantinedObjStmCount es cero, lo que además demuestra que el writer no produjo un contenedor que el lector no pueda abrir. Las combinaciones de crypt filters que deciden FReloadObjectStreamsEncrypted están expuestas 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 a una reescritura, está en la guía de object streams y actualizaciones incrementales

La carga lazy de miembros, la pasada de expansión previa al writer, la cuarentena de descifrado y la conservación del token de origen vienen todas en el HotPDF Delphi Component para Delphi y C++Builder. La página de producto enlaza la referencia de API si quieres seguir GetLoadedObjectStreamCacheInfo y los accesores de cuarentena contra tu propio pipeline de ingesta