Un filtro de ingestión rechazó un lote de archivos "PDF/A-2b" que se abrían sin problemas 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 una mirada 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 Java a un servicio por lotes en Delphi solo para responder una pregunta de sí o no por archivo. Ese hueco lo cubre ValidatePdfACompliance en PDFium Component, y merece la pena entender cómo llega a un veredicto sin llegar a analizar por completo un flujo de contenido.
Por qué el propio PDFium no puede responder a esto
Lo primero que hay que decir con claridad: el pdfium.dll incluido no tiene ninguna capacidad PDF/A. No existe ConvertToPDFA, ni escritor de OutputIntent, ni API de XMP en la superficie pública. Toda la parte PDF/A de esta biblioteca, tanto la de escritura como la de comprobació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 validación: un archivo solo pasa cuando se detecta un nivel real de conformidad y el conjunto de incidencias está vacío. Un análisis que se completa pero no encuentra ningún marcador pdfaid se resuelve como pacNone, que de forma explícita no es un aprobado. Es el mismo punto que plantea desde fuera la CLI de informes de preflight por lotes: una lista vacía de hallazgos en un archivo no reconocido no equivale a un parte de salud limpia.
Eliminar los cuerpos de los flujos antes de cualquier análisis 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 infracciones buscando tokens de nombre delimitados, cosas como /JavaScript, /LZWDecode, /BM. Si analizas los bytes en bruto del archivo, los cuerpos binarios incrustados de los flujos, las imágenes comprimidas, los perfiles ICC y los programas de fuentes contendrán al azar secuencias de bytes que parecen esos tokens. Informarás de /AA o /3D como "encontrado" porque tres bytes dentro de un JPEG casualmente lo deletrearon. Eso es una fábrica de falsos positivos.
La solución es PdfStructureBytes: recorre el archivo y rellena con espacios los bytes entre cada palabra clave stream y endstream, dejando intacta la estructura de diccionarios. Solo después se ejecuta el análisis. Cada comprobación de tokens de nombre en el validador opera sobre esta copia depurada. Si te quedas con una sola idea de este artículo, quédate con esa. La misma disciplina se refleja en el validador PDF/UA, que conserva su propia copia de la rutina porque ambos estándares evolucionan de forma independiente.
Las 29 incidencias y qué significa cada una
TPdfAValidationIssue es un contrato documentado. Los ordinales están fijados porque las pruebas DUnitX, las demos y la capa de informes dependen de ellos, así que los nuevos hallazgos solo se añaden al final. A fecha de 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,pvaiMissingIccProfileypvaiMixedDeviceColorSpacescuando aparecen a la vez DeviceRGB y DeviceCMYK (6.2.3.3). - Prohibiciones absolutas para todas las partes:
pvaiEncryptionPresent(un diccionario/Encryptestá prohibido sin excepción),pvaiJavaScriptPresent,pvaiForbiddenAction,pvaiAdditionalActions,pvaiLzwUsed,pvaiXfaPresent,pvaiNeedAppearancesTrue,pvaiForbiddenAnnotation. - Fuentes:
pvaiFontNotEmbeddedy la más estrictapvaiUnembeddedFont, además depvaiUnicodeMappingMissingpara una afirmación de Nivel U sin/ToUnicode. - Etiquetado:
pvaiLevelAStructureMissingcuando una afirmación de conformidad A no tiene estructura etiquetada.
Los seis miembros más recientes, añadidos entre los ordinales 24 y 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 usado como acción, no solo como anotación), pvaiTransparentColorSpace (un modo de fusión distinto de Normal o un /CA//ca distinto de 1.0), pvaiAnnotationDictViolation, pvaiUnembeddedFont y pvaiMixedDeviceColorSpaces.
Control sensible a la 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 explícitamente 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 todos los archivos rechazará en masa documentos PDF/A-2 perfectamente válidos.
Así que el validador lee el número de parte del marcador pdfaid mediante PdfAPartOf y condiciona esas comprobaciones con PartNo = 1. Las comprobaciones del modo de fusión y de la alfa de anotación para los nuevos problemas de transparencia son igualmente 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;
Conviene mencionar un valor conservador por defecto: cuando no hay ningún marcador pdfaid, la parte se trata como 1, la más estricta. La lógica es que un archivo no identificado debe someterse a las reglas más duras en lugar de dejarlo pasar. JavaScript, las acciones prohibidas, LZW, XFA, NeedAppearances, las anotaciones prohibidas y las fuentes no incrustadas siguen estando prohibidos en todas las partes, así que esas comprobaciones nunca quedan detrás de la puerta.
Expandir los flujos de objetos para que nada se esconda
PDF 1.5 introdujo el flujo de referencias cruzadas y el flujo de objetos (/Type /ObjStm), y crean un punto ciego para un escáner de bytes ingenuo. Un catálogo, un OutputIntent, un diccionario de acciones, cualquier cosa que no sea en sí misma un flujo, puede comprimirse con Flate dentro de un ObjStm. Analiza la estructura en bruto y no verás nada de eso; luego informarás de que el archivo está limpio cuando no lo está.
PdfExpandObjectStreams cierra ese hueco. Antes de ejecutar cualquier comprobación, el validador hace Data := PdfExpandObjectStreams(Data). La rutina localiza cada ObjStm, lee su cabecera /N y /First para obtener los números y desplazamientos de los objetos contenidos, descomprime el cuerpo con PdfInflate (la zlib de RTL, System.ZLib en Delphi y zstream en FPC), y añade cada objeto contenido como un N 0 obj ... endobj ordinario al final de una copia de los bytes. Las comprobaciones de tokens existentes encuentran entonces esos objetos sin cambiar su lógica.
Hay dos restricciones que hacen que esto sea limpio y no frágil. Los objetos de flujo, los metadatos, el perfil ICC y los programas de fuentes no pueden vivir en un flujo de objetos, solo pueden hacerlo diccionarios que no sean flujos, así que la expansión solo trata con diccionarios y los objetos añadidos no llevan ninguna palabra clave stream que moleste a la pasada de eliminación del cuerpo. Y como el contenido añadido cae después de %%EOF, la búsqueda hacia atrás desde startxref sigue encontrando el tráiler original. El propio tráiler del flujo de referencias cruzadas ya se había tratado antes, en v1.49.3, leyendo Root, Size e ID directamente del diccionario del xref-stream en texto plano, un tema que se explora en la pieza complementaria sobre validación de flujos de objetos y de referencias cruzadas; el trabajo de los flujos de objetos 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 honestos de un comprobador 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 recuento, y acertar con ella requirió una corrección que conviene conocer. La comprobación original usaba PdfCountName('/FontDescriptor'), pero cada fuente aporta dos tokens /FontDescriptor, una referencia desde el diccionario de fuentes y un /Type en el propio objeto descriptor, así que el recuento era 2N frente a N programas incrustados y la prueba era siempre verdadera. La correcció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);
Incluso corregido, sigue siendo tosco: un documento mixto en el que cada descriptor tenga por casualidad algún FontFile aún puede dejar pasar una fuente individual que no cumpla con la norma. Expandir los flujos de objetos también tiene un efecto secundario conocido: expone los recursos predeterminados de estándar 14 que lleva un AcroForm /DR, como /Helv, y la heurística los informa diligentemente como no incrustados aunque veraPDF los acepte porque en realidad nunca se usan para renderizar. Las comprobaciones a nivel de operador de flujo de contenido (6.2.10) quedan totalmente fuera de alcance, ya que necesitarían un análisis completo del contenido en lugar de un escaneo de bytes. Trata el validador como una primera barrera rápida y sin dependencias que detecta las infracciones que la inyección de marcadores no puede arreglar, y reserva un validador completo para la certificación final.
Esta es la mitad de comprobación de la historia. La parte complementaria de escritura, donde SaveAsPdfA inyecta el XMP, el OutputIntent y el perfil ICC sRGB, y rebaja 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 distribuyen en PDFium Component for Delphi, un único paquete VCL sobre una implementación PDF/A en Pascal puro sin ningún runtime externo que instalar.