Los streams de objeto de PDF 1.5 empaquetan muchos objetos indirectos pequeños en un único contenedor comprimido con Flate, y losLab PDF Library los emite en un guardado completo mediante su flag PackObjectStreams. La ganancia es real: cientos de diccionarios de página, fuente y anotación que cada uno cuesta docenas de bytes sin comprimir colapsan en un puñado de blobs comprimidos. El coste es que cada objeto empaquetado ahora necesita un stream de referencias cruzadas que lo describa
Esa segunda mitad es donde se rompen los escritores. Construir un contenedor /ObjStm es aritmética; enseñar a la maquinaria de referencias cruzadas a apuntar dentro de él es un rediseño. Un escritor que produce un contenedor perfectamente válido y después describe a sus miembros con offsets ordinarios de tipo 1 ha producido un fichero que Acrobat abrirá justo el tiempo suficiente para declararlo dañado. Las dos características son una sola característica, y este artículo cubre el lado de escritura de ambas, tal como se definen en ISO 32000-1 §7.5.7 y §7.5.8
Qué contiene realmente un contenedor ObjStm
Un stream de objeto es un stream cuyos bytes decodificados son dos regiones concatenadas, y ISO 32000-1 §7.5.7 le da al diccionario exactamente tres claves que importan para la construcción. /Type /ObjStm lo identifica, /N da el número de miembros, y /First da la longitud en bytes de la región de cabecera —equivalentemente, el offset en el que empieza el cuerpo. La cabecera son pares de número de objeto y offset separados por espacios en blanco; el cuerpo son los miembros serializados uno tras otro, con cada offset medido desde el inicio del cuerpo en lugar de desde el inicio del contenido decodificado. Leer un contenedor completamente decodificado lo deja claro: abajo, /First es 14 porque las tres líneas de cabecera ocupan catorce bytes, y el objeto 7 se sitúa 55 bytes dentro del cuerpo porque el objeto 4 serializó a 54 caracteres más un separador
// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
// /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]
Dos reglas de pertenencia son absolutas y ambas vienen directamente de §7.5.7. Un objeto de stream nunca puede ser un miembro, porque un stream lleva bytes en crudo que tendrían que anidarse dentro de otro stream. Y un miembro debe ser un valor de objeto completo, nunca una referencia indirecta desnuda —un objeto comprimido que es simplemente 5 0 R crea una indirección que el lector no puede resolver sin saber ya adónde apunta. losLab PDF Library filtra ambos casos durante la recolección de candidatos, junto con el diccionario de cifrado y el objeto 0, y luego empaqueta lo que sobrevive en grupos de 200 por contenedor. Ese límite es una decisión de acceso aleatorio más que un límite de la especificación: un lector que quiere un miembro tiene que inflar el contenedor entero, así que los contenedores sobredimensionados hacen costosas las búsquedas pequeñas
¿Por qué los miembros de ObjStm deben usar entradas de referencia cruzada de tipo 2?
Porque un objeto empaquetado no tiene ningún offset de fichero que registrar. ISO 32000-1 §7.5.8 responde a esto con tres tipos de entrada en un stream de referencias cruzadas binario: tipo 0 para objetos libres, tipo 1 para objetos en uso ordinarios almacenados en un offset de byte, y tipo 2 para objetos comprimidos, cuyos dos campos de datos contienen el número de objeto del contenedor y el índice del miembro dentro de él. No hay forma de expresar un objeto empaquetado en la tabla xref de texto plano clásica, que es precisamente por lo que PDF 1.5 introdujo ambas características juntas
El orden que sigue hace tropezar a casi cualquier primera implementación, incluida la nuestra. Los objetos ordinarios reciben entradas de tipo 1. Los propios contenedores /ObjStm reciben entradas de tipo 1, porque un contenedor es un objeto de stream indirecto perfectamente normal escrito en un offset real. Solo los miembros reciben entradas de tipo 2. Y el propio stream de referencias cruzadas es un objeto indirecto en el fichero, así que necesita su propia entrada de tipo 1 apuntando al offset en el que se acaba de escribir —el mismo offset que registra startxref. Una versión temprana de nuestro escritor excluía los números de objeto de contenedor del bucle de escritura en lugar de excluir a los miembros, y el resultado fue un fichero con un stream de referencias cruzadas y ningún stream de objeto en absoluto: estructuralmente coherente, semánticamente vacío, rechazado río abajo. El valor de /Size esconde un error de desplazamiento equivalente, ya que es el número de objeto más alto más uno y el stream de referencias cruzadas se asigna como el número de objeto más alto, así que también hay que contarlo
Dimensionar el array /W: por qué cuatro bytes no bastan
El array /W declara el ancho en bytes de cada uno de los tres campos, y losLab PDF Library lo escribe como /W [1 Field2 Field3] con el campo 1 fijo en un byte para el código de tipo y el campo 3 fijo en dos bytes, que cubre tanto números de generación hasta 65535 como índices de miembro. El campo 2 es el que no puede ser una constante, porque lleva dos cantidades no relacionadas: en una entrada de tipo 1 es un offset de byte acotado solo por el tamaño del fichero, mientras que en una entrada de tipo 2 es un número de objeto contenedor y en una entrada de tipo 0 es el siguiente objeto libre en la cadena. Un campo 2 fijo de cuatro bytes funciona bien hasta que el fichero cruza los 4 GB, momento en el que cada offset más allá del límite se trunca silenciosamente y toda la tabla se convierte en basura. El escritor por tanto escanea la tabla ensamblada en busca del valor más grande que cualquier slot de campo 2 llegará a tener, incluyendo el offset del propio stream de referencias cruzadas, y ensancha el campo hasta ocho bytes
// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
Field2Value := XRefTable[X].ObjStrNum // type-2: container number
else
Field2Value := XRefTable[X].ObjPos; // type-1 offset / type-0 next-free
if Field2Value > MaxField2Value then
MaxField2Value := Field2Value;
end;
Field2 := 4;
while (Field2 < 8) and
(MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
Inc(Field2);
Field3 := 2; // generation numbers and member indices both fit
Una vez que se conocen los anchos, el tamaño del contenido se conoce exactamente, así que el escritor preasigna el búfer entero y lo rellena por índice; añadir entradas byte a byte a un AnsiString vuelve cuadrática la construcción de la tabla, algo que nadie nota en una factura de diez páginas y que todo el mundo nota en un documento con doscientos mil objetos. Dos detalles más mantienen contentos a los lectores estrictos. /Index declara qué rangos de número de objeto cubre la tabla, y para una reescritura completa eso es simplemente [0 N] sin huecos. Y cada slot que el escritor no emitió realmente debe tener por defecto libre en lugar de en-uso: el objeto 0 encabeza la cadena libre, cada slot libre enlaza con el siguiente, y un slot que alguna vez contuvo un objeto eliminado conserva su número de generación incrementado en uno. La nota complementaria sobre seguridad de memoria al analizar PDF no confiables hace el mismo argumento de límites desde el lado de la lectura
¿Por qué el stream de referencias cruzadas nunca debe cifrarse?
Porque un lector tiene que analizarlo antes de poder saber cómo descifrar nada. El stream de referencias cruzadas es lo que le dice al lector dónde vive el diccionario /Encrypt; si sus bytes estuvieran en sí mismos cifrados, el lector necesitaría la clave del fichero para encontrar el objeto que describe la clave del fichero. losLab PDF Library exige esto en un único predicado: ShouldCryptStreamData devuelve False siempre que el diccionario de stream lleva /Type /XRef, así que la exención se mantiene sin importar qué ruta llegue al serializador
El contenedor /ObjStm recibe el trato opuesto, y la asimetría es deliberada. Un contenedor se cifra entero, con clave sobre su propio número de objeto, exactamente como cualquier otro stream. Sus miembros no se cifran individualmente —se empaquetan en su forma de texto plano descifrado, y la única pasada sobre el contenedor ensamblado los cubre, cadenas incluidas. Cifrar dos veces los miembros produce un fichero que descifra a texto cifrado, y como la capa exterior tiene éxito el fallo aflora como un error de análisis en lo profundo del grafo de objetos en lugar de como un fallo de autenticación. Un objeto entonces se queda enteramente fuera del esquema: en un documento cifrado el Catalog se mantiene como un objeto de tipo 1 directo y nunca se empaqueta, porque empaquetarlo obligaría al cargador a inflar y descifrar un stream de objeto para llegar a la raíz del documento, antes de que el contexto de descifrado que la raíz ayuda a establecer esté completamente construido
Activar el empaquetado desde Delphi
El interruptor público es PackObjectStreams, expuesto como un campo en TPDFlibSaveOptions, como el setter independiente SetPackObjectStreams, y como una propiedad del objeto documento. Por defecto está activado y se condiciona automáticamente por versión: el escritor solo empaqueta cuando el documento ya es PDF 1.5 o posterior, y llama a la salvaguarda interna de versión mínima para que un documento empaquetado se eleve a 1.5 en lugar de etiquetarse mal. Tras el guardado, GetLastSaveUsedObjectStreams reporta si la condición realmente se abrió, que es la aserción que quieres en un test de regresión en lugar de una comparación de tamaño en bytes
var
Doc: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('report.pdf', '') <= 0 then
Exit;
Doc.SetInformation(0, '1.5'); // packing is gated on PDF 1.5+
FillChar(Options, SizeOf(Options), 0);
Options.CompressContent := True;
Options.GarbageCollect := True; // drop orphans before packing
Options.PackObjectStreams := True;
if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
if Doc.GetLastSaveUsedObjectStreams = 1 then
Writeln('Saved with ObjStm containers and an xref stream');
finally
Doc.Free;
end;
end;
El orden importa entre el empaquetado y la recolección de basura. El análisis de alcanzabilidad tiene que ejecutarse primero, porque un miembro que sobrevive hacia un contenedor arrastra consigo al contenedor —si un objeto vivo está empaquetado, su número de contenedor es alcanzable por definición, y barrer el contenedor deja al miembro varado sin forma de localizarlo. Ejecutar primero el recolector también significa que los objetos muertos nunca entran en un contenedor en absoluto, que es de donde viene la ganancia de tamaño acumulada. El empaquetado complementa a las demás palancas de tamaño en lugar de reemplazarlas; el recorrido por la optimización de tamaño de fichero PDF y el subsetting de fuentes cubre las palancas que actúan sobre el contenido de los streams, mientras que los streams de objeto actúan sobre la estructura
Límites que conviene conocer antes de activarlo
Los guardados incrementales nunca empaquetan. Una actualización incremental añade objetos nuevos y una nueva sección de referencias cruzadas dejando intactas físicamente las revisiones anteriores, así que reempaquetar objetos existentes en contenedores frescos dejaría huérfanas las entradas de tipo 1 que la revisión anterior todavía referencia; losLab PDF Library desactiva el empaquetado siempre que el modo append está activo, y el artículo sobre actualizaciones incrementales y streaming en modo append cubre esa ruta por completo. Los documentos por debajo de PDF 1.5 mantienen incondicionalmente la tabla de referencias cruzadas de texto plano: un consumidor 1.4 no tiene ni idea de qué significa /ObjStm, y promover silenciosamente un documento porque el escritor prefería un fichero más pequeño sería el intercambio equivocado a hacer en nombre del llamante. Una clave opcional que deliberadamente no emitimos es /Extends, que ISO 32000-1 §7.5.7 define para que un contenedor pueda nombrar a un predecesor y los lectores puedan tratar una cadena de contenedores como un grupo lógico. Es genuinamente opcional, cada contenedor que escribimos es autocontenido y decodificable de forma independiente, y omitirlo elimina del escritor toda una clase de bugs de ciclos y referencias colgantes —aunque los lectores por supuesto deben seguir respetando /Extends cuando lo encuentren en ficheros de otros productores
El empaquetado de streams de objeto y la salida de streams de referencias cruzadas se incluyen como parte de losLab PDF Library para Delphi y C++Builder, junto con el recolector de basura y el optimizador de streams de contenido con los que componen; la página del producto incluye la referencia completa de opciones de guardado