Artículo técnico

Validating Compressed PDFs: Object and XRef Streams

Usted escribe un validador sencillo: abre un PDF, busca al final, localiza startxref, lee el desplazamiento y espera encontrar la palabra clave xref con una tabla de referencias cruzadas de ancho fijo debajo. A partir de esa tabla recopila los desplazamientos de los objetos y luego busca hacia atrás la palabra clave trailer para conocer el /Root y el /Size. Funciona perfectamente en cada archivo que generó para probarlo. Sin embargo, luego llega un archivo producido por una versión actual de Word, o por una biblioteca orientada a PDF 1.5, y el validador lo declara dañado. No hay ninguna palabra clave xref donde apunta el desplazamiento, ningún diccionario trailer en ningún sitio, y la tabla de objetos que construyó el validador está casi vacía. El archivo es válido; lo que ocurre es que el validador lo lee bajo una perspectiva de hace quince años

Qué cambió PDF 1.5 en la sección final del archivo

Esta es la razón más común por la que una comprobación de PDF a nivel de bytes escrita para el diseño clásico falla en los documentos modernos. La estructura de la que depende (la tabla de referencias cruzadas en texto plano y la palabra clave trailer) se volvió opcional en PDF 1.5 y con frecuencia no se incluye. Dos características la reemplazaron: el flujo de referencias cruzadas (xref-stream) y el flujo de objetos comprimidos. Ambos se describen en la norma ISO 32000-1, y un validador que no los contemple interpretará un archivo correcto como un conjunto de objetos ausentes

La norma ISO 32000-1 §7.5.8 define el flujo de referencias cruzadas, y §7.5.7 define el flujo de objetos del tipo /ObjStm. Juntos permiten que un escritor descarte las dos estructuras que busca un analizador clásico. Un archivo PDF 1.5 puede terminar sin ninguna tabla xref. En su lugar, el objeto al que apunta startxref es un objeto de flujo común cuyo diccionario contiene /Type /XRef, y ese flujo almacena los datos de referencias cruzadas en un formato binario compacto. Tampoco existe la palabra clave trailer, porque el bloque final (trailer) es ahora el propio diccionario del flujo. Las claves que buscaba un analizador clásico (/Root, /Size e /ID) residen dentro de ese diccionario

El segundo cambio afecta a la ubicación de los objetos. En lugar de escribir cada objeto indirecto en su propio desplazamiento de bytes, un generador puede agrupar muchos objetos pequeños (los diccionarios de páginas, los diccionarios de anotaciones, el árbol de estructura) en un único flujo de objetos y comprimir todo el contenedor mediante Flate. Los objetos individuales ya no tienen un desplazamiento de bytes en el archivo: tienen una posición dentro de un bloque comprimido. Un validador que busque en los bytes sin procesar la cadena 1 0 obj nunca los encontrará, porque ese texto solo existe después de la descompresión. Para un analizador clásico, la mitad del documento simplemente ha desaparecido

Las claves del trailer están en texto plano, incluso en un archivo comprimido

El aspecto favorable es que leer el trailer de un flujo de referencias cruzadas no requiere descomprimir nada. Un objeto de flujo se escribe como un diccionario seguido de la palabra clave stream y luego los bytes comprimidos. El diccionario está en texto plano. De este modo, cuando startxref apunta a un flujo de referencias cruzadas, los bytes inmediatamente posteriores al número de objeto se muestran como un diccionario común, y /Root, /Size e /ID se ubican de forma visible, antes de que comiencen la palabra clave stream y los datos Flate

Esto significa que un validador puede obtener los tres datos más necesarios (dónde está el catálogo, cuántos objetos declara el archivo y el identificador del archivo) analizando únicamente el diccionario del flujo. No tiene que descomprimir los datos de referencias cruzadas ni interpretar las entradas binarias internas. La tarea que supera a un analizador sencillo no es leer el trailer: es localizar los objetos. Esos son dos problemas independientes, y resolver el primero requiere pocos recursos

Flujos de objetos: una cabecera y luego un bloque Flate

Un flujo de objetos es un contenedor. Su diccionario incluye /Type /ObjStm, una entrada /N que indica la cantidad de objetos agrupados en su interior y una entrada /First que define el desplazamiento de bytes (dentro de los datos descomprimidos) donde comienza el cuerpo del primer objeto. El contenido comprimido, una vez descomprimido, se inicia con una pequeña cabecera de /N pares de enteros. Cada par representa un número de objeto y el desplazamiento del cuerpo de ese objeto en relación con /First. Tras la cabecera se encuentran los cuerpos de los objetos concatenados

Expandir uno de ellos es un proceso mecánico una vez descomprimidos los bytes. Lee el diccionario para obtener /N y /First, descomprime el flujo con un decodificador Flate, recorre los pares /N iniciales para identificar qué número de objeto corresponde a cada desplazamiento y luego extrae cada cuerpo como si fuera un objeto indirecto común. La única dependencia real es el decodificador Flate, y ya cuenta con uno: Delphi incluye System.ZLib y Free Pascal distribuye la unidad zstream; ambas integran zlib y descomprimen un flujo Flate sin código de terceros. Una rutina que añada cada objeto extraído a la tabla de objetos del validador permite que el resto del proceso (la sección que recorre /Root y verifica el árbol de páginas) funcione exactamente igual que en un archivo clásico

Qué no es necesario implementar

Es fácil sobrestimar el trabajo. Leer las claves del trailer de un archivo comprimido no requiere decodificar las entradas binarias del flujo de referencias cruzadas. El flujo de referencias cruzadas de la sección §7.5.8 utiliza tres tipos de entradas, y la entrada de tipo 2 (la que indica que este objeto reside dentro del flujo de objetos N en el índice i) es la que decodificaría para construir un mapa de desplazamientos completo. Necesita ese mapa para resolver objetos según su número, pero no para leer /Root, /Size e /ID, que se encuentran en el diccionario de texto plano; tampoco lo requiere para expandir flujos de objetos, ya que cada /ObjStm declara su propio contenido mediante /N y /First

Tampoco tiene que implementar las funciones de predictores PNG y TIFF que un flujo de referencias cruzadas puede aplicar mediante su /DecodeParms solo para obtener las claves del trailer. Los predictores filtran las filas binarias de referencias cruzadas para mejorar su compresión, lo cual no se relaciona con el diccionario que antecede al flujo. La actualización mínima necesaria para adaptar un validador clásico a los PDFs modernos es pequeña: cuando startxref apunta a un flujo en lugar de a la palabra clave xref, analice el diccionario del flujo en busca de las claves del trailer y expanda cualquier objeto /ObjStm que encuentre para incorporar su contenido a la tabla de objetos. Decodificar entradas de tipo 2 y predictores es una tarea independiente y más compleja que puede posponer hasta que requiera resolver objetos de forma aleatoria

Por qué un control de conformidad debe expandir los flujos primero

Esto deja de ser un planteamiento teórico en el momento en que ejecuta un control de perfil. Un validador de PDF/A o PDF/X inspecciona objetos específicos: el catálogo del documento en busca de un arreglo /OutputIntents, el flujo /Metadata para un paquete XMP con el identificador correcto, cada descriptor de fuente para un archivo de fuente incrustado y el trailer para un /ID. En un archivo comprimido, la mayoría de esos objetos están dentro de flujos de objetos. Un validador que no haya expandido los flujos de objetos no podrá ver las claves del catálogo, no localizará los metadatos y no podrá enumerar las fuentes. Reportará que un documento conforme carece de intención de salida, de XMP y de la mitad de su estructura, debido a que la información que requiere sigue dentro de un bloque Flate que nunca descomprimió

El orden es importante. La expansión debe realizarse antes de que se ejecuten las comprobaciones, no en paralelo, porque cada control asume que puede acceder a un objeto mediante su número. Si vincula una comprobación de perfil directamente a un escaneo de bytes sin procesar, heredará la limitación del analizador clásico y generará infracciones falsas exactamente en los archivos modernos que tienen más probabilidades de estar bien formados, ya que provienen de herramientas actualizadas que escriben flujos de referencias cruzadas

Permitir que PDFium realice el análisis por usted

El PDFium Component analiza los flujos de referencias cruzadas y los flujos de objetos como parte de la carga del documento, lo cual es la forma práctica de evitar programar manualmente el paso de descompresión y expansión. Cuando carga un archivo con el componente TPdf, los objetos contenidos en los bloques /ObjStm ya están resueltos, y los puntos de validación acceden al documento expandido. ValidatePdfA devuelve un registro TPdfAValidationResult cuyo campo Conformance es un valor TPdfAConformance (como pac1b o pacNone), cuyo campo Issues es un conjunto con los problemas detectados y cuyo método IsCompliant es verdadero solo cuando se determina un nivel de conformidad y el conjunto de problemas está vacío. Debido a que los objetos se expandieron durante la carga, el arreglo /OutputIntents o la fuente incrustada que residían dentro de un flujo de objetos se localizan en lugar de reportarse como ausentes

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

Lo mismo aplica para ValidatePdfX, que devuelve un resultado TPdfXValidationResult con la misma estructura. El ventaja de procesar a través de PDFium es que la descompresión estructural descrita ocurre una vez, de forma correcta, dentro del cargador, por lo que su código de validación nunca ve la diferencia entre un archivo clásico y uno comprimido. Ambos llegan al validador como un conjunto resuelto de objetos

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

Si los bytes ya se encuentran en memoria y no en el disco, la misma secuencia de carga y validación funciona mediante la sobrecarga de LoadDocument(const Data: TBytes), la cual toma el contenido del archivo sin procesar y analiza sus flujos de referencias cruzadas y de objetos del mismo modo que con la ruta de archivo. La lección para un validador escrito manualmente es la regla estructural, no la API: lea las claves del trailer del diccionario de flujo en texto plano, expanda cada /ObjStm con un decodificador Flate antes de recorrer el documento y considere la decodificación de las entradas de referencias cruzadas binarias como la tarea secundaria y opcional que es

Una vez expandida la estructura, un validador puede gestionar el resto del flujo de trabajo sobre ella. Para un entorno de verificación previa por línea de comandos que reporte la conformidad en una carpeta de entradas, consulte nuestra guía sobre construir un reporte de verificación previa por lotes en CLI. Cuando la validación es un paso previo a la división de un documento grande, las técnicas descritas en nuestra guía sobre dividir documentos PDF en varios archivos se complementan con el modelo de carga y verificación mostrado aquí. Ambos se basan en las funciones de carga y validación del PDFium Component para Delphi y C++Builder