Technical Article

Validación de archivos PDF comprimidos: Flujos de objetos y referencias cruzadas (XRef)

Escribe un pequeño validador. Abre un PDF, busca al final, encuentra startxref, lee el desplazamiento y espera aterrizar en la palabra clave xref con una tabla de referencia cruzada de ancho fijo debajo. A partir de esa tabla, recopila los desplazamientos de los objetos y luego escanea hacia atrás buscando la palabra clave trailer para conocer la /Root y el /Size. Funciona perfectamente en cada archivo que generó para probarlo. Luego llega un archivo producido por una versión actual de Word, o por una librería que tiene como objetivo PDF 1.5, y el validador lo declara dañado. No hay ninguna palabra clave xref donde apunta el desplazamiento, ningún diccionario trailer por ningún lado, y la tabla de objetos que construyó el validador está casi vacía. El archivo es válido. El validador lo está leyendo a través de una lente de hace quince años.

Esta es la razón más común por la que una comprobación de PDF a nivel de bytes escrita contra el diseño clásico falla en los documentos modernos. La estructura de la que depende, la tabla de referencia cruzada de texto plano y la palabra clave trailer, se hizo opcional en PDF 1.5 y con frecuencia está ausente. Dos características las reemplazaron: el flujo de referencia cruzada (cross-reference stream) y el flujo de objetos comprimido. Ambos se describen en la norma ISO 32000-1, y un validador que no los conozca ve un archivo sano como un montón de objetos faltantes.

Qué cambió PDF 1.5 sobre la cola del archivo

La norma ISO 32000-1 §7.5.8 define el flujo de referencia cruzada, y el §7.5.7 define el flujo de objetos de tipo /ObjStm. Juntos permiten al escritor descartar las dos estructuras en las que se basa un analizador clásico. Un archivo PDF 1.5 puede terminar sin ninguna tabla xref en absoluto. En su lugar, el objeto al que apunta startxref es un objeto de flujo común cuyo diccionario contiene /Type /XRef, y ese flujo contiene los datos de referencia cruzada en una forma binaria compacta. Tampoco hay palabra clave trailer, porque el trailer es ahora el propio diccionario del flujo. Las claves que buscaba un analizador clásico, /Root, /Size e /ID, viven dentro de ese diccionario.

El segundo cambio mueve los objetos mismos. En lugar de escribir cada objeto indirecto en su propio desplazamiento de bytes, un escritor puede empacar muchos objetos pequeños, los diccionarios de página, los diccionarios de anotaciones, el árbol estructural, en un único flujo de objetos y comprimir todo el contenedor con 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 escanee los bytes sin procesar buscando 1 0 obj nunca los encuentra, porque ese texto solo existe tras el inflado. Para un analizador clásico, la mitad del documento simplemente ha desaparecido.

Las claves del trailer son de texto plano, incluso en un archivo comprimido

La parte tranquilizadora es que leer el trailer de un flujo de referencia cruzada no requiere inflar 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 modo que cuando startxref apunta a un flujo de referencia cruzada, los bytes inmediatamente posteriores al número de objeto se ven como un diccionario común, y /Root, /Size e /ID se sitúan allí a la vista, antes de que comiencen la palabra clave stream y los datos Flate.

Eso significa que un validador puede conocer los tres hechos que más necesita —dónde está el catálogo, cuántos objetos reclama el archivo y el identificador del archivo— analizando únicamente el diccionario de flujo. No tiene que descomprimir los datos de referencia cruzada, y no tiene que interpretar las entradas binarias dentro de él. El trabajo que derrota a un analizador ingenuo no es leer el trailer; es encontrar los objetos. Esos son dos problemas separables, y resolver el primero es económico.

Flujos de objetos: un encabezado y luego un bloque Flate

Un flujo de objetos es un contenedor. Su diccionario contiene /Type /ObjStm, una entrada /N que indica el número de objetos empaquetados en su interior, y una entrada /First que indica el desplazamiento de bytes, dentro de los datos inflados, donde comienza el cuerpo del primer objeto. La carga útil comprimida, una vez inflada, comienza con un pequeño encabezado de pares de enteros /N. Cada par es un número de objeto y el desplazamiento del cuerpo de ese objeto con respecto a /First. Después del encabezado vienen los cuerpos de los objetos mismos, concatenados.

Expandir uno es mecánico una vez que los bytes están inflados. Lee el diccionario para obtener /N y /First, infla el flujo con un decodificador Flate, recorre los pares /N principales para saber qué número de objeto vive en qué desplazamiento, y luego extrae cada cuerpo como si fuera un objeto indirecto común. La única dependencia real es el decodificador Flate, y ya tiene uno: Delphi distribuye System.ZLib, y Free Pascal distribuye la unidad zstream, ambas de las cuales envuelven zlib e inflan un flujo Flate sin procesar sin ningún código de terceros. Una rutina que añade cada objeto extraído a la tabla de objetos del validador hace que el resto del validador —la parte que recorre /Root y comprueba el árbol de páginas— se comporte exactamente como lo haría en un archivo clásico.

Lo que no tiene que implementar

Es fácil sobreestimar el trabajo. Leer las claves del trailer de un archivo comprimido no requiere decodificar las entradas binarias del flujo de referencia cruzada. El flujo de referencia cruzada del §7.5.8 utiliza tres tipos de entrada, y la entrada tipo 2, la que dice este objeto vive dentro del flujo de objetos N en el índice i, es lo que decodificaría para construir un mapa de desplazamiento completo. Necesita ese mapa para resolver objetos arbitrarios por número. No lo necesita para leer /Root, /Size e /ID, que están en el diccionario de texto plano, y no lo necesita para expandir flujos de objetos, porque cada /ObjStm anuncia su propio contenido a través de /N and /First.

Tampoco tiene que manejar las funciones predictoras PNG y TIFF que un flujo de referencia cruzada puede aplicar a través de su /DecodeParms solo para obtener las claves del trailer. Los predictores filtran las filas de referencia cruzada binarias para que se compriman mejor; no tienen nada que ver con el diccionario que precede al flujo. Por lo tanto, la actualización mínima que hace a un validador clásico consciente de los PDF modernos es pequeña: cuando startxref aterrice en un flujo en lugar de la palabra clave xref, analice el diccionario del flujo para obtener las claves del trailer, y expanda cualquier objeto /ObjStm que encuentre para que su contenido entre en la tabla de objetos. Decodificar entradas tipo 2 y predictores es una tarea separada y más grande que puede posponer hasta que realmente necesite resolución de objetos aleatorios.

Por qué una comprobación de cumplimiento debe expandir los flujos primero

Esto deja de ser académico en el momento en que se ejecuta una comprobación de perfil. Un validador de PDF/A or PDF/X inspecciona objetos específicos: el catálogo del documento buscando una matriz /OutputIntents, el flujo /Metadata buscando un paquete XMP con el identificador correcto, cada descriptor de fuente buscando un archivo de fuente incrustado, el trailer buscando 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 puede ver las claves del catálogo, no puede encontrar los metadatos y no puede enumerar las fuentes. Reportará un documento perfectamente conforme como falto de su intención de salida, falto de su XMP y falto de la mitad de su estructura, porque la evidencia que necesita sigue sentada en un bloque Flate que nunca infló.

El orden importa. La expansión tiene que ocurrir antes de que se ejecuten las comprobaciones, no junto a ellas, porque cada comprobación asume que puede alcanzar un objeto por número. Si conecta una comprobación de perfil directamente sobre un escaneo de bytes sin procesar, hereda la ceguera del analizador clásico y produce violaciones falsas precisamente en los archivos modernos que tienen más probabilidades de estar bien formados, ya que provienen de cadenas de herramientas lo suficientemente nuevas como para escribir flujos de referencias cruzadas en primer lugar.

Dejar que PDFium haga el análisis por usted

El componente PDFium analiza los flujos de referencia cruzada y los flujos de objetos como parte de la carga de un documento, que es la forma práctica de evitar tener que implementar a mano el paso de inflar y expandir. Cuando carga un archivo con el componente TPdf, los objetos empaquetados en contenedores /ObjStm ya están resueltos, y los puntos de entrada de validación ven el documento completamente expandido. ValidatePdfA devuelve un registro TPdfAValidationResult cuyo campo Conformance es un valor TPdfAConformance como pac1b o pacNone, cuyo campo Issues is un conjunto de los problemas específicos encontrados, y cuyo método IsCompliant es verdadero solo cuando se detectó un nivel de conformidad y el conjunto de problemas está vacío. Debido a que los objetos se expandieron durante la carga, se encuentra una matriz /OutputIntents o una fuente incrustada que vivía dentro de un flujo de objetos, no se reporta como faltante.

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 se aplica a ValidatePdfX, que devuelve un TPdfXValidationResult con la misma forma. El punto de enrutar a través de PDFium is que la descompresión estructural descrita anteriormente ocurre una vez, correctamente, dentro del cargador, por lo que su código de validación nunca ve la diferencia entre un archivo clásico y uno completamente comprimido. Ambos llegan al validador como un conjunto resuelto de objetos.

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
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: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

Si los bytes ya están en memoria en lugar de en el disco, la misma secuencia de carga y validación funciona a través de la sobrecarga LoadDocument(const Data: TBytes), que toma el contenido del archivo sin procesar y analiza sus flujos de referencias cruzadas y de objetos de la misma manera que lo hace la ruta del archivo. Lo que debe llevarse para un validador escrito a mano es la regla estructural, no la API: lea las claves del trailer del diccionario de flujo en texto plano, expanda cada objeto /ObjStm con un decodificador Flate antes de recorrer el documento, y trate la decodificación de las entradas de referencia cruzada binarias como el trabajo más grande y opcional que es.

Una vez que la estructura está expandida, un validador puede controlar el resto de un flujo de trabajo sobre ella. Para obtener una herramienta de línea de comandos previa a la validación que informe la conformidad en una carpeta de entradas, consulte nuestro tutorial sobre la creación de un informe CLI de comprobación preliminar por lotes. Cuando la validación es una barrera antes de dividir un documento grande, las técnicas en nuestra guía para dividir documentos PDF en varios archivos se emparejan naturalmente con el patrón de carga y verificación que se muestra aquí. Ambos se basan en la superficie de carga y validación del Componente PDFium para Delphi y C++Builder.