Artículo técnico

Flujos de objetos y xref streams de PDF en Delphi

Los flujos de objetos de PDF 1.5 empaquetan muchos objetos indirectos pequeños en un solo contenedor comprimido con Flate, y losLab PDF Library los emite en un guardado completo mediante su indicador PackObjectStreams. La ganancia es real: cientos de diccionarios de páginas, fuentes y anotaciones, cada uno con un costo de decenas de bytes sin comprimir, colapsan en un puñado de blobs comprimidos. El costo es que cada objeto empaquetado ahora necesita un flujo de referencias cruzadas que lo describa

Esa segunda mitad es donde fallan los escritores de PDF. Construir un contenedor /ObjStm es aritmética; enseñarle 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 luego describe a sus miembros con desplazamientos ordinarios de tipo 1 ha producido un archivo que Acrobat abrirá justo el tiempo suficiente para declararlo dañado. Las dos características son en realidad una sola, 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 flujo de objetos es un flujo cuyos bytes decodificados forman dos regiones concatenadas, y ISO 32000-1 §7.5.7 le da al diccionario exactamente tres claves relevantes para su construcción. /Type /ObjStm lo identifica, /N indica la cantidad de miembros, y /First indica la longitud en bytes de la región de encabezado — equivalentemente, el desplazamiento donde comienza el cuerpo. El encabezado son pares separados por espacios de número de objeto y desplazamiento; el cuerpo son los miembros serializados uno tras otro, con cada desplazamiento medido desde el inicio del cuerpo y no desde el inicio del payload decodificado. Leer un contenedor totalmente decodificado lo hace evidente: abajo, /First vale 14 porque las tres líneas del encabezado ocupan catorce bytes, y el objeto 7 se ubica 55 bytes dentro del cuerpo porque el objeto 4 se serializó en 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 provienen directamente de §7.5.7. Un objeto de flujo nunca puede ser miembro, porque un flujo lleva bytes crudos que tendrían que anidarse dentro de otro flujo. 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 a dó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 y no un límite de la especificación: un lector que quiere un solo miembro tiene que inflar el contenedor completo, así que los contenedores demasiado grandes hacen costosas las búsquedas pequeñas

Por qué los miembros de ObjStm deben usar entradas de referencia cruzada tipo 2

Porque un objeto empaquetado no tiene ningún desplazamiento de archivo que registrar. ISO 32000-1 §7.5.8 responde esto con tres tipos de entrada en un flujo de referencias cruzadas binario: tipo 0 para objetos libres, tipo 1 para objetos ordinarios en uso almacenados en un desplazamiento de bytes, 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 clásica en texto plano, que es precisamente por qué PDF 1.5 introdujo ambas características juntas

El orden que sigue confunde a casi toda primera implementación, incluida la nuestra. Los objetos ordinarios reciben entradas tipo 1. Los propios contenedores /ObjStm reciben entradas tipo 1, porque un contenedor es un objeto de flujo indirecto perfectamente normal escrito en un desplazamiento real. Solo los miembros reciben entradas tipo 2. Y el propio flujo de referencias cruzadas es un objeto indirecto dentro del archivo, así que necesita su propia entrada tipo 1 que apunte al desplazamiento donde acaba de escribirse — el mismo desplazamiento que registra startxref. Una versión temprana de nuestro escritor excluía los números de objeto de los contenedores del bucle de escritura en lugar de excluir a los miembros, y el resultado fue un archivo con un flujo de referencias cruzadas y ningún flujo de objetos: estructuralmente coherente, semánticamente vacío, rechazado más adelante. El valor /Size esconde un error de desfase equivalente, ya que es el número de objeto más alto más uno, y el flujo de referencias cruzadas se asigna como el número de objeto más alto, así que también debe contarse

Dimensionar el arreglo /W: por qué cuatro bytes no alcanzan

El arreglo /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, lo cual cubre tanto números de generación de hasta 65535 como índices de miembro. El campo 2 es el que no puede ser constante, porque transporta dos cantidades sin relación entre sí: en una entrada tipo 1 es un desplazamiento de bytes acotado solo por el tamaño del archivo, mientras que en una entrada tipo 2 es un número de objeto de contenedor y en una entrada tipo 0 es el siguiente objeto libre en la cadena. Un campo 2 fijo de cuatro bytes funciona bien hasta que el archivo cruza los 4 GB, momento en el cual todo desplazamiento más allá del límite se trunca en silencio y toda la tabla se convierte en basura. El escritor por eso recorre la tabla ya ensamblada buscando el valor más grande que cualquier ranura de campo 2 llegará a contener, incluido el desplazamiento del propio flujo 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 conocidos los anchos, el tamaño del payload se conoce con exactitud, así que el escritor preasigna el buffer completo y lo llena por índice; agregar entradas byte por 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 todos notan en un documento con doscientos mil objetos. Dos detalles adicionales mantienen contentos a los lectores estrictos. /Index declara qué rangos de números de objeto cubre la tabla, y para una reescritura completa eso es simplemente [0 N] sin huecos. Y cada ranura que el escritor no emitió realmente debe quedar por defecto como libre en lugar de en uso: el objeto 0 encabeza la cadena de libres, cada ranura libre enlaza con la siguiente, y una ranura 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 flujo de referencias cruzadas nunca debe estar cifrado

Porque un lector tiene que analizarlo antes de poder saber cómo descifrar cualquier otra cosa. El flujo de referencias cruzadas es lo que le indica al lector dónde vive el diccionario /Encrypt; si sus bytes estuvieran a su vez cifrados, el lector necesitaría la clave del archivo para encontrar el objeto que describe la clave del archivo. losLab PDF Library impone esto en un único predicado: ShouldCryptStreamData devuelve False siempre que el diccionario del flujo lleve /Type /XRef, así que la excepción se mantiene sin importar qué ruta llegue al serializador

El contenedor /ObjStm recibe el tratamiento opuesto, y la asimetría es deliberada. Un contenedor se cifra completo, con clave basada en su propio número de objeto, exactamente como cualquier otro flujo. Sus miembros no se cifran individualmente — se empaquetan en su forma de texto plano descifrado, y el único paso sobre el contenedor ya ensamblado los cubre a todos, cadenas incluidas. Cifrar dos veces a los miembros produce un archivo que se descifra en texto cifrado, y como la capa exterior tiene éxito, el fallo aparece 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 mantiene por completo fuera del esquema: en un documento cifrado, el Catálogo se conserva como un objeto directo tipo 1 y nunca se empaqueta, porque empaquetarlo obligaría al cargador a inflar y descifrar un flujo de objetos 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 campo en TPDFlibSaveOptions, como el setter independiente SetPackObjectStreams, y como propiedad del objeto documento. Está habilitado por defecto y se restringe automáticamente por versión: el escritor solo empaqueta cuando el documento ya es PDF 1.5 o posterior, y llama a la protección interna de versión mínima para que un documento empaquetado se eleve a 1.5 en lugar de quedar mal etiquetado. Después del guardado, GetLastSaveUsedObjectStreams informa si la restricción realmente se activó, que es la verificación que conviene usar en una prueba 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 dentro de 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 sin forma de ser localizado. Ejecutar primero el recolector también significa que los objetos muertos nunca entran a un contenedor, 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 de optimización del tamaño de archivo PDF y subconjunto de fuentes cubre las palancas que actúan sobre el contenido de los flujos, mientras que los flujos de objetos actúan sobre la estructura

Límites que conviene conocer antes de activarlo

Los guardados incrementales nunca empaquetan. Una actualización incremental anexa objetos nuevos y una nueva sección de referencias cruzadas mientras deja físicamente intactas las revisiones anteriores, así que reempaquetar objetos existentes en contenedores nuevos dejaría huérfanas las entradas tipo 1 que la revisión anterior todavía referencia; losLab PDF Library deshabilita el empaquetado siempre que el modo de anexado está activo, y el artículo sobre actualizaciones incrementales y transmisión en modo de anexado cubre esa ruta por completo. Los documentos anteriores a PDF 1.5 conservan la tabla de referencias cruzadas en texto plano de forma incondicional: un consumidor 1.4 no tiene idea de qué significa /ObjStm, y promover en silencio un documento porque el escritor prefirió un archivo más pequeño sería la decisión equivocada para tomar en nombre de quien invoca la función. 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 toda una clase de errores de ciclos y referencias colgantes del escritor — aunque los lectores, por supuesto, deben seguir respetando /Extends cuando lo encuentren en archivos de otros productores

El empaquetado de flujos de objetos y la emisión de flujos de referencias cruzadas forman parte de losLab PDF Library para Delphi y C++Builder, junto con el recolector de basura y el optimizador de flujos de contenido con los que se combinan; la página de producto incluye la referencia completa de las opciones de guardado