Entregas un convertidor que marca todos los archivos como PDF/A-1b, el sistema de registros del cliente los ingiere durante un año y, al hacer una auditoría, se pasa todo el lote por veraPDF y un tercio vuelve como no conforme. Nada se bloqueó, no se lanzó ninguna excepción, los archivos se abren bien en cualquier visor de tu escritorio. Simplemente no eran el estándar que les habías asignado. Este es el modo de fallo normal del PDF de archivo, y por eso "hemos puesto la marca" nunca es lo mismo que "valida".
Lo primero que hay que entender sobre PDFium y PDF/A es que el motor no pinta nada aquí. PDFium renderiza, analiza y escribe PDF, pero su superficie pública no tiene ConvertToPDFA, ni escritor de OutputIntent, ni API de XMP. Toda la conformidad de archivado, el paquete XMP, el OutputIntent y su perfil ICC, los marcadores del catálogo y la validación viven en PDFiumPas, en una unidad puramente Pascal de unas 2.000 líneas (FPdfPdfa.pas) que analiza los bytes guardados y los reescribe mediante una actualización incremental. Saber dónde ocurre el trabajo te dice dónde se esconden los errores, y no se esconden en PDFium.
Lo que PDF/A exige de verdad, y dónde te golpea
PDF/A no es un único formato. ISO 19005 define tres partes (PDF/A-1, -2, -3) y, dentro de cada una, niveles de conformidad que prometen cosas distintas. El nivel B (básico) garantiza solo que el aspecto visual es reproducible. El nivel A (accesible) añade un árbol de estructura etiquetada y el mapeo Unicode encima de B. El nivel U, que solo existe para las partes 2 y 3, se sitúa entre ambos: texto Unicode fiable sin el árbol de estructura completo. ISO 19005-1 no tiene nivel U, una restricción que la biblioteca codifica directamente.
Un puñado de reglas del formato son las que de verdad muerden en la práctica. El cifrado está prohibido de forma absoluta (ISO 19005-1 §6.1.3 y sus sucesores): un archivo PDF/A no puede llevar un diccionario /Encrypt. El documento debe declarar una condición de reproducción de salida mediante un OutputIntent cuyo destino sea un perfil ICC válido (§6.2.3.2). La propia declaración de conformidad debe aparecer como metadatos XMP bajo el esquema de identificación PDF/A. El nivel A además exige la estructura lógica del §6.8, el árbol de etiquetas que hace que el documento sea legible por máquina. Si te dejas cualquiera de estos puntos, un verificador de conformidad rechaza el archivo aunque se renderice perfectamente.
La llamada única que genera un archivo
PDFiumPas expone toda la canalización detrás de TPdf.SaveAsPdfA. La sobrecarga simple toma una conformidad de destino y por defecto usa PDF/A-1b, que es el valor adecuado para el caso común de "haz que esto se pueda renderizar para siempre".
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.LoadFromFile('invoice.pdf');
// Default conformance is pac1b (PDF/A-1b)
if Pdf.SaveAsPdfA('invoice_archive.pdf') then
// file now carries XMP, sRGB OutputIntent, and catalog markers
else
raise Exception.Create('PDF/A save failed');
finally
Pdf.Free;
end;
end;
Por debajo, esto es una operación en dos fases. SaveAsPdfA primero pide a PDFium que serialice el documento con FPDF_SaveAsCopy, y luego entrega ese flujo de bytes a InjectPdfAMarkers, que añade los metadatos XMP, el OutputIntent sRGB con su perfil ICC incrustado y un catálogo reescrito como actualización incremental. El origen se lee desde la posición cero y el destino se escribe desde la posición cero; el árbol de objetos original queda intacto y los marcadores se añaden después del %%EOF existente. Si necesitas los bytes en vez de un archivo, SaveAsPdfAToStream toma un TStream y las mismas opciones.
Elegir la conformidad con el registro de opciones
Para apuntar a una parte y un nivel concretos, pasa un registro TPdfASaveOptions. Su campo Conformance acepta un valor TPdfAConformance. La enumeración cubre todas las combinaciones válidas y nada más: pac1b, pac1a para la parte 1; pac2b, pac2u, pac2a para la parte 2; pac3b, pac3u, pac3a para la parte 3, además de pacUnknown y pacNone para la parte de validación. No existe pac1u, porque ese nivel no existe en el estándar.
var
Pdf: TPdf;
Opts: TPdfASaveOptions;
begin
Pdf := TPdf.Create(nil);
try
Pdf.LoadFromFile('report.pdf');
Opts := TPdfASaveOptions.Default;
Opts.Conformance := pac2u; // PDF/A-2u: reliable Unicode text
Opts.Title := 'Quarterly Report 2026';
Opts.Author := 'Finance';
// Leave IccProfileData empty to use the built-in sRGB IEC61966-2.1 profile
if not Pdf.SaveAsPdfA('report_a2u.pdf', Opts) then
raise Exception.Create('PDF/A-2u save failed');
finally
Pdf.Free;
end;
end;
La mayor parte del registro puede quedarse vacía. Deja Title, Author, Subject, Keywords, Creator y Producer en blanco y SaveAsPdfA los rellena automáticamente desde el diccionario Info del documento mediante FPDF_GetMetaText. Deja CreationDate y ModDate en blanco y usará la hora UTC actual para ambas fechas XMP. Deja DocumentId y InstanceId en blanco y la biblioteca los precompleta a partir de FPDF_GetFileIdentifier, con una caída a un identificador determinista derivado de los bytes de origen. El único campo que quizá quieras sobrescribir de forma explícita es IccProfileData: vacío significa el perfil sRGB IEC61966-2.1 incluido, pero un flujo de trabajo CMYK o en escala de grises debe proporcionar el suyo propio.
Por qué el nivel A se degrada, y por qué esa es la decisión honesta
Aquí hay un matiz que descoloca a quien espera que un indicador sea una garantía. Puedes pedir pac1a en un documento que no tiene árbol de etiquetas, pero PDF/A-1a exige estructura lógica del §6.8, y la biblioteca no puede fabricar un árbol de estructura a partir de un PDF sin etiquetar. En vez de emitir un archivo que afirma nivel A mientras lo incumple, SaveAsPdfA comprueba si existe una estructura etiquetada real (/StructTreeRoot más /MarkInfo con /Marked true) y, si falta, rebaja la declaración: pac1a pasa a pac1b, pac2a pasa a pac2b, y así en las tres partes. Los ayudantes internos son PdfAIsLevelA y PdfADowngradeToLevelB.
La razón merece decirse con claridad: un archivo que declara con honestidad el nivel que cumple es más útil que uno que miente sobre un nivel que no alcanza. El nivel U se gestiona de otra manera. Detectar una cobertura Unicode auténtica obligaría a un test ingenuo de "¿tiene /ToUnicode?", que degradaría en exceso documentos legítimos (WinAnsi y codificaciones similares están exentas), así que la parte de guardado emite la declaración U tal y como la indicó el llamante y deja que la discrepancia se señale en la validación. Si necesitas un archivo de nivel A garantizado, etiqueta el documento antes de convertirlo; el convertidor no inventará una estructura que no existe.
La trampa del ICC que solo detecta un validador real
Este es el fallo que enseñó la lección más dura, porque el propio comprobador de la biblioteca lo aprobó mientras veraPDF, el validador de referencia de ISO 19005, no lo hizo. PDF/A exige que el perfil de destino del OutputIntent sea un flujo ICCBased válido, y el §6.2.3.2 obliga a un verificador a validar ese flujo como espacio de color. Un flujo ICCBased debe declarar /N, el número de componentes de color. Una versión temprana del inyector escribía el diccionario del flujo ICC solo con /Length y sin /N, y veraPDF rechazó el resultado con "The N entry (value null)... is missing".
Lo insidioso es que el rechazo solo saltaba para PDF/A-1b y -1a. Los modelos de conformidad de las partes 2 y 3 no ejecutaban esa comprobación concreta sobre el perfil de destino, así que la misma estructura inyectada validaba bajo pac2b, pac3b y pac2u, pero fallaba bajo pac1b por nada más que el valor pdfaid:part. Un test unitario nunca lo habría visto, porque el propio ValidatePdfACompliance de la biblioteca solo comprobaba que existiera la clave /DestOutputProfile, no lo que vivía dentro del diccionario del flujo. Los tests internos seguían en verde; la validación real fallaba.
La solución es IccComponentCount, que lee la firma del espacio de color de datos en el desplazamiento 16 de la cabecera ICC y la asigna a un recuento de componentes: GRAY es 1, RGB , Lab y XYZ son 3, CMYK es 4, y un perfil desconocido vuelve por defecto a 3. Ese recuento se escribe en el diccionario del flujo como /N. Se calcula, no se fija a 3 a mano, para que quien proporcione un perfil CMYK o en escala de grises mediante IccProfileData siga obteniendo el valor correcto. La lección de fondo es metodológica: el comprobador de la biblioteca y un validador autoritativo tienen cada uno sus puntos ciegos, y la salida PDF/A tiene que probarse de principio a fin contra una implementación de referencia como veraPDF en lugar de fiarse de las autocomprobaciones. La misma disciplina de actualización incremental que hay detrás de los archivos limpios se trata en la validación de objetos comprimidos y flujos xref, que importa porque los PDFs modernos que consume el inyector a menudo se construyen sobre flujos de referencias cruzadas.
Cifrado, flujos xref y otros bordes
Como ISO 19005 prohíbe el cifrado, la ruta de guardado lo elimina antes de escribir. SaveAsPdfA aplica FPDF_REMOVE_SECURITY al serializar, de modo que un origen cifrado (cargado con su contraseña) se descifra al entrar en el archivo. En un documento sin cifrar esto no hace nada y no cambia nada. La consecuencia es la misma restricción que HotPDF impone desde el otro lado: un único archivo no puede estar a la vez cifrado y ser PDF/A. Cuando un flujo de trabajo necesita ambas cosas, la respuesta son dos artefactos, una copia cifrada para distribuir y una copia limpia separada para el archivo.
Otro borde no se ve hasta que da problemas: documentos PDF 1.5+ que usan un flujo de referencias cruzadas puro y no llevan la palabra clave trailer. El inyector lee el trailer para encontrar el /Info de origen y añadir su actualización incremental, y tiene que aceptar la forma de flujo xref, o de lo contrario un documento así se copiaría con los marcadores descartados sin avisar. ISO 32000-1 §7.5.6 permite explícitamente que una actualización incremental de trailer clásica siga a un documento de flujo xref, con /Prev apuntando al desplazamiento del flujo xref, que es exactamente la estructura que emite el inyector. FPDF_SaveAsCopy de PDFium siempre escribe un trailer clásico, así que en la canalización normal el inyector nunca se encuentra con un origen de flujo xref puro, pero la ruta de lectura lo maneja para documentos que llegan desde otro sitio.
Comprobar antes de confiar en la afirmación
La biblioteca incluye un comprobador a nivel de bytes, TPdf.ValidatePdfA, que devuelve un TPdfAValidationResult. Su campo Conformance informa del nivel detectado y Issues es un conjunto de valores TPdfAValidationIssue; el método práctico IsCompliant solo devuelve verdadero cuando se ha detectado un nivel real y el conjunto de incidencias está vacío. Úsalo como primer filtro rápido en un lote.
var
Pdf: TPdf;
Res: TPdfAValidationResult;
begin
Pdf := TPdf.Create(nil);
try
Pdf.LoadFromFile('invoice_archive.pdf');
Res := Pdf.ValidatePdfA;
if Res.IsCompliant then
Writeln('Conformant: detected level ', Ord(Res.Conformance))
else
Writeln('Issues found: ', SizeOf(Res.Issues), ' flags set');
finally
Pdf.Free;
end;
end;
Sé honesto sobre lo que esto te aporta. El comprobador a nivel de bytes detecta problemas estructurales, como un OutputIntent ausente, una acción prohibida, un /Encrypt presente o transparencia donde la parte 1 lo prohíbe, con bastante fiabilidad, y la detección de incrustación de fuentes usa una heurística de recuento que informa deliberadamente solo de una señal de alta confianza en lugar de perseguir la cobertura glifo a glifo. Lo que no hace es analizar operadores de flujos de contenido, algo que requeriría un analizador de contenido completo y queda fuera de alcance por diseño. Para una puerta de salida de publicación, combina el comprobador de la biblioteca con veraPDF: el comprobador es inmediato y funciona en cualquier sitio sin DLL, veraPDF es autoritativo. La conexión de ambos en una ejecución por lotes es el tema de la CLI de informes de preflight por lotes, que es donde encaja esta validación en un flujo real de archivado.
Las API SaveAsPdfA, InjectPdfAMarkers y ValidatePdfA que se muestran aquí se incluyen con PDFium Component para Delphi, C++Builder y Lazarus/FPC. La página del producto enlaza la referencia completa de la API, incluida la enumeración completa de conformidad y el registro de opciones detrás de estos ejemplos.