Artículo Técnico

Validación previa de PDF/A en Delphi con PDFium VCL

Una puerta de ingesta de archivos rechazó un lote de archivos "PDF/A-2b" que se abrían sin problema en todos los visores del equipo. El proveedor juraba que eran conformes. No lo eran: cada uno llevaba una acción de JavaScript oculta en el catálogo, el tipo de cosa que un vistazo casual nunca detecta y que un validador completo de PDF/A como veraPDF marca al instante. El problema es que nadie quería acoplar una cadena de herramientas de Java a un servicio por lotes en Delphi solo para responder una pregunta de sí o no por archivo. Ese vacío lo cubre ValidatePdfACompliance en PDFium Component, y vale la pena entender cómo llega a un veredicto sin llegar a analizar por completo un flujo de contenido.

Por qué PDFium no puede responder esto

Lo primero que hay que admitir: el pdfium.dll incluido no tiene ninguna capacidad de PDF/A. No existe ConvertToPDFA, ni escritor de OutputIntent, ni API de XMP en la superficie pública. Toda la parte de PDF/A en esta biblioteca, tanto la escritura como la verificación, vive en Pascal puro en FPdfPdfa.pas y funciona mediante análisis a nivel de bytes más actualización incremental. Así que cuando llamas al validador no le estás preguntando nada al renderizador de Chromium. Estás ejecutando un escáner de tokens en Pascal sobre los bytes estructurales del archivo.

La API pública es deliberadamente pequeña. Una función lee un flujo desde la posición 0 y devuelve un registro:

function ValidatePdfACompliance(Source: TStream): TPdfAValidationResult;

type
  TPdfAValidationResult = record
    Conformance: TPdfAConformance;        // pacUnknown, pacNone, pac1b, pac2u, ...
    Issues: TPdfAValidationIssues;        // a set of TPdfAValidationIssue
    function IsCompliant: Boolean;        // True only when level <> unknown/none
  end;                                    // AND Issues is empty

IsCompliant codifica la regla que importa en una puerta de control: un archivo solo aprueba cuando se detectó un nivel real de conformidad y el conjunto de problemas está vacío. Un análisis que termina bien pero no encuentra ningún marcador pdfaid se resuelve como pacNone, que explícitamente no aprueba. Ese es el mismo punto que marca la CLI de informe previo por lotes desde afuera: una lista vacía de hallazgos en un archivo no reconocido no significa que esté limpio.

Eliminar los cuerpos de los flujos antes de cualquier escaneo de tokens

Este es el detalle de implementación más importante, y el más fácil de hacer mal si escribes tu propio escáner. El detector encuentra violaciones buscando nombres con delimitadores, cosas como /JavaScript, /LZWDecode, /BM. Si analizas los bytes crudos del archivo, los cuerpos binarios de los flujos incrustados, las imágenes comprimidas, los perfiles ICC y los programas de fuentes contendrán al azar secuencias de bytes que parecen esos tokens. Terminarás informando /AA o /3D como "encontrado" porque tres bytes dentro de un JPEG casualmente lo deletrearon. Eso fabrica falsos positivos por montón.

La solución es PdfStructureBytes: recorre el archivo y reemplaza por espacios los bytes entre cada palabra clave stream y endstream, dejando intacta la estructura del diccionario. Solo después corre el escaneo. Cada verificación de tokens de nombre en el validador opera sobre esta copia depurada. Si te quedas con una sola idea de este artículo, que sea esa. La misma disciplina se replica en el validador PDF/UA, que mantiene su propia copia de la rutina porque los dos estándares evolucionan de forma independiente.

Los 29 problemas y qué significa cada uno

TPdfAValidationIssue es un contrato documentado. Los ordinales están congelados porque las pruebas de DUnitX, las demos y la capa de informes dependen de ellos, así que los hallazgos nuevos siempre se agregan al final. A partir de la v1.63.0 hay 29 miembros. Se agrupan en varias familias:

  • Metadatos e identidad: pvaiMissingXmpMetadata, pvaiMissingPdfAIdentifier, pvaiMissingTrailerId (ISO 19005-1 6.1.3), pvaiMissingXmpDates.
  • Color y salida: pvaiMissingOutputIntent, pvaiMissingIccProfile y pvaiMixedDeviceColorSpaces cuando aparecen tanto DeviceRGB como DeviceCMYK (6.2.3.3).
  • Prohibiciones absolutas para cualquier parte: pvaiEncryptionPresent (un diccionario /Encrypt está prohibido de forma absoluta), pvaiJavaScriptPresent, pvaiForbiddenAction, pvaiAdditionalActions, pvaiLzwUsed, pvaiXfaPresent, pvaiNeedAppearancesTrue, pvaiForbiddenAnnotation.
  • Fuentes: pvaiFontNotEmbedded y la más estricta pvaiUnembeddedFont, además de pvaiUnicodeMappingMissing para una declaración de Nivel U sin /ToUnicode.
  • Etiquetado: pvaiLevelAStructureMissing cuando una declaración de conformidad A no tiene estructura etiquetada.

Los seis miembros más recientes, agregados en los ordinales 24 a 29, cubren los casos sutiles en los que de verdad tropiezan los revisores: pvaiTrappedTrue (un /Trapped /True en el diccionario Info, un falso amigo porque el valor debe ser False o Unknown), pvaiForbiddenActionSubtype (Sound o Movie usados como acción, no solo como anotación), pvaiTransparentColorSpace (un modo de fusión distinto de Normal o un /CA//ca diferente de 1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont y pvaiMixedDeviceColorSpaces.

Control por parte: A-1 es estricto, A-2 y A-3 relajan

PDF/A no es un único libro de reglas. Tres cosas que PDF/A-1 prohíbe están permitidas de forma explícita a partir de PDF/A-2: la transparencia, un grupo /Transparency o una /SMask activa, 6.4, el contenido opcional /OCProperties, 6.1.13, y los archivos incrustados /EmbeddedFiles o /EF, 6.1.11. Un validador ingenuo que marque las tres para cada archivo rechazará en masa documentos PDF/A-2 perfectamente válidos.

Así que el validador lee el número de parte desde el marcador pdfaid a través de PdfAPartOf y pone esas verificaciones detrás de PartNo = 1. Las comprobaciones de modo de fusión y alfa de anotación para los nuevos problemas de transparencia también son solo para la parte 1:

if PartNo = 1 then
begin
  if PdfHasName(Struct, '/BM') then
    if not PdfHasBMNormal(Struct) then          // only /Normal or /Compatible allowed
      Include(Result.Issues, pvaiTransparentColorSpace);
  if PdfHasCaNotOne(Struct, '/CA') or PdfHasCaNotOne(Struct, '/ca') then
    Include(Result.Issues, pvaiTransparentColorSpace);
end;

Vale la pena mencionar un valor conservador por defecto: cuando no hay ningún marcador pdfaid, la parte se trata como 1, la más estricta. La razón es que un archivo no identificado debe someterse a las reglas más duras en vez de dejarlo pasar. JavaScript, las acciones prohibidas, LZW, XFA, NeedAppearances, las anotaciones prohibidas y las fuentes sin incrustar siguen prohibidos para cualquier parte, así que esas verificaciones nunca quedan detrás de la compuerta.

Expandir los object streams para que nada quede oculto

PDF 1.5 introdujo el flujo de referencias cruzadas y el object stream (/Type /ObjStm), y eso crea un punto ciego para un escáner ingenuo a nivel de bytes. Un catálogo, un OutputIntent, un diccionario de acciones, cualquier cosa que no sea en sí misma un flujo, puede quedar comprimida con Flate dentro de un ObjStm. Si revisas la estructura en bruto no verás nada de eso, y luego reportarás como limpio un archivo que no lo es en absoluto.

PdfExpandObjectStreams cierra esa brecha. Antes de correr cualquier verificación, el validador hace Data := PdfExpandObjectStreams(Data). La rutina encuentra cada ObjStm, lee sus encabezados /N y /First para obtener los números y desplazamientos de los objetos contenidos, descomprime el cuerpo con PdfInflate (la zlib del RTL, System.ZLib en Delphi y zstream en FPC) y agrega cada objeto contenido como un N 0 obj ... endobj normal al final de una copia de los bytes. Las verificaciones de tokens existentes luego encuentran esos objetos sin cambiar su lógica.

Dos restricciones hacen que esto sea limpio y no frágil. Los objetos de flujo, los metadatos, el perfil ICC y los programas de fuente no pueden vivir en un object stream, solo los diccionarios que no son flujos, así que la expansión solo trata con diccionarios y los objetos agregados no llevan ninguna palabra clave stream que interfiera con el paso de eliminación del cuerpo. Y como el contenido agregado queda después de %%EOF, la búsqueda inversa desde startxref sigue encontrando el trailer original. El propio trailer del flujo de referencias cruzadas ya se había resuelto antes, en la v1.49.3, al leer Root, Size e ID directamente desde el diccionario xref-stream en texto plano, un tema explorado en la pieza complementaria sobre validación de flujos de objetos y referencias cruzadas; el trabajo con object streams solo tuvo que añadir el paso de descompresión, sin necesidad de decodificar entradas xref de tipo 2 ni deshacer un predictor PNG.

Los límites reales de un verificador a nivel de bytes

Esto es una herramienta de preflight, no un validador certificado, y los límites son reales. La incrustación de fuentes es una heurística de conteo, y dejarla bien requirió una corrección que vale la pena conocer. La verificación original usaba PdfCountName('/FontDescriptor'), pero cada fuente aporta dos tokens /FontDescriptor, una referencia desde el diccionario de la fuente y un /Type en el propio objeto descriptor, así que el conteo era 2N frente a N programas incrustados y la prueba siempre daba true. La solución es PdfCountDescriptorRefs, que cuenta solo la forma de referencia /FontDescriptor N G R, una por fuente, y solo marca pvaiUnembeddedFont cuando los programas incrustados son realmente menos:

K := PdfCountDescriptorRefs(Struct);                 // one per font dict
Emb := PdfCountName(Struct, '/FontFile')
     + PdfCountName(Struct, '/FontFile2')
     + PdfCountName(Struct, '/FontFile3');
if (K > 0) and (Emb < K) then
  Include(Result.Issues, pvaiUnembeddedFont);

Aun corregida, sigue siendo una heurística gruesa: un documento mixto en el que todos los descriptores casualmente tengan algún FontFile puede dejar pasar una fuente individual que no cumple. Expandir los object streams también tiene un efecto secundario conocido: expone los recursos predeterminados estándar 14 que lleva un AcroForm /DR, como /Helv, y la heurística los reporta obedientemente como no incrustados aunque veraPDF los acepte porque en realidad nunca se usan para renderizar. Las comprobaciones a nivel de operadores de flujo de contenido (6.2.10) quedan completamente fuera de alcance, porque requerirían analizar el contenido completo en vez de un escaneo de bytes. Trata al validador como una primera compuerta rápida y sin dependencias que detecta las violaciones que la inyección de marcadores no puede corregir, y reserva un validador completo para la certificación final.

Esta es la mitad de verificación de la historia. La mitad de escritura, donde SaveAsPdfA inyecta el XMP, el OutputIntent y el perfil ICC sRGB, y degrada de forma honesta una solicitud de Nivel A que no tiene estructura etiquetada, se apoya en la misma maquinaria a nivel de bytes. Ambas mitades se incluyen en PDFium Component para Delphi, un solo paquete VCL sobre una implementación PDF/A en Pascal puro, sin runtime externo que instalar.