Artículo Técnico

Cumplimiento de archivo PDF/A en Delphi con PDFium VCL

Lanzas un convertidor que marca cada archivo como PDF/A-1b, el sistema de registros del cliente los ingiere durante un año y luego una auditoría pasa todo el lote por veraPDF y un tercio regresa como no conforme. Nada falló, no se lanzó ninguna excepción, los archivos se abren bien en todos los visores de tu escritorio. Simplemente no cumplían el estándar que les estampaste encima. Este es el modo de falla normal del PDF de archivo y por eso decir "pusimos la bandera" nunca equivale a decir "valida"

El primer punto a entender sobre PDFium y PDF/A es que el motor no tiene nada que ver con esto. 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 archivo, el paquete XMP, el OutputIntent y su perfil ICC, los marcadores del catálogo, la validación, vive en el propio PDFiumPas, en una unidad pura de 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 realmente exige PDF/A y dónde complica todo

PDF/A no es un solo 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) solo garantiza que la apariencia visual se pueda reproducir. El Nivel A (accesible) añade un árbol de estructura etiquetada y mapeo Unicode sobre B. El Nivel U, que existe solo para las partes 2 y 3, se ubica entre ambos: texto Unicode confiable sin el árbol de estructura completo. ISO 19005-1 no tiene Nivel U, una restricción que la biblioteca codifica de forma directa

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 de §6.8, el árbol de etiquetas que vuelve legible el documento para una máquina. Si falta cualquiera de estas piezas, un verificador de conformidad rechaza el archivo aunque se vea perfecto

La llamada que genera un archivo

PDFiumPas expone todo el flujo detrás de TPdf.SaveAsPdfA. La sobrecarga simple toma una conformidad de destino y por defecto usa PDF/A-1b, que es la elección correcta para el caso común de "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 dentro esto es un movimiento en dos etapas. SaveAsPdfA primero le pide a PDFium serializar el documento con FPDF_SaveAsCopy, luego entrega ese flujo de bytes a InjectPdfAMarkers, que agrega 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 incorporan 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 específicos, pasa un registro TPdfASaveOptions. Su campo Conformance toma 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 el lado 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 completa 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 desde FPDF_GetFileIdentifier, con una reserva a un ID determinista derivado de los bytes de origen. El único campo que quizá quieras sobrescribir a propósito es IccProfileData: vacío significa el perfil sRGB IEC61966-2.1 incluido, pero un flujo CMYK o en escala de grises debe aportar el suyo

Por qué el Nivel A se degrada y por qué esa es la elección honesta

Aquí hay un matiz que hace tropezar a quien espera que una bandera sea una garantía. Puedes pedir pac1a en un documento que no tiene árbol de etiquetas, pero PDF/A-1a exige la estructura lógica de §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 pero lo incumple, SaveAsPdfA comprueba que exista una estructura etiquetada real (/StructTreeRoot más /MarkInfo con /Marked true) y, si no está, rebaja la afirmación: pac1a pasa a pac1b, pac2a pasa a pac2b, y así en las tres partes. Los auxiliares internos son PdfAIsLevelA y PdfADowngradeToLevelB

La lógica merece decirse con claridad: un archivo que declara con honestidad el nivel que sí cumple es más útil que uno que miente sobre un nivel que no cumple. El Nivel U se maneja distinto. Detectar cobertura Unicode real implicaría una prueba ingenua de "¿tiene /ToUnicode?" que degradaría en exceso documentos legítimos (las codificaciones WinAnsi y similares están exentas), así que el lado de guardado emite la declaración U tal como la escribió el llamador y deja que la discrepancia se marque del lado de validación. Si necesitas un archivo Nivel A garantizado, etiqueta el documento antes de convertirlo; el convertidor no inventará una estructura que no existe

La trampa de ICC que solo detecta un validador real

Este es el fallo que dejó la lección más dura, porque el propio verificador 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 §6.2.3.2 obliga a que un verificador valide 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 escribió 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 que lo volvía insidioso es que el rechazo solo aparecía para PDF/A-1b y -1a. Los modelos de conformidad de la parte 2 y la parte 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 solo por el valor de pdfaid:part. Una prueba unitaria nunca lo iba a ver, porque el propio ValidatePdfACompliance de la biblioteca solo comprobaba que existiera la clave /DestOutputProfile, no lo que vivía dentro del diccionario del flujo. Las pruebas internas seguían en verde; la validación real de archivo fallaba

La corrección es IccComponentCount, que lee la firma del espacio de color de datos en el desplazamiento 16 del encabezado ICC y la convierte en un número de componentes: GRAY es 1, RGB , Lab y XYZ son 3, CMYK es 4, y un perfil desconocido cae por defecto a 3. Ese conteo entra en el diccionario del flujo como /N. Se calcula, no está fijado en 3, de modo que quien suministre un perfil CMYK o en escala de grises mediante IccProfileData siga obteniendo el valor correcto. La lección más amplia es metodológica: el verificador interno de la biblioteca y un validador autoritativo tienen sus propios puntos ciegos, y la salida PDF/A tiene que probarse de extremo a extremo contra una implementación de referencia como veraPDF, no confiarse a autocomprobaciones. La misma disciplina de actualización incremental detrás de archivos limpios se cubre en la validación de objetos comprimidos y flujos xref, que importa porque los PDF modernos que consume el inyector suelen construirse 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, así que un origen cifrado (cargado con su contraseña) se descifra en el camino hacia el archivo. En un documento sin cifrar esto no hace nada y no cambia nada. La consecuencia es la misma restricción que HotPDF aplica en sentido inverso: un solo archivo no puede ser a la vez cifrado y PDF/A. Cuando un flujo necesita ambas cosas, la respuesta son dos artefactos, una copia cifrada para distribución y una copia limpia separada para archivo

Otro borde invisible hasta que golpea es el de los 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 anexar su actualización incremental, y tiene que aceptar la forma de xref-stream, porque de otro modo un documento así se copiaría con los marcadores descartados en silencio. ISO 32000-1 §7.5.6 permite explícitamente que una actualización incremental con trailer clásico siga a un documento xref-stream, con /Prev apuntando al desplazamiento del xref-stream, que es exactamente la estructura que emite el inyector. El propio FPDF_SaveAsCopy de PDFium siempre escribe un trailer clásico, así que en el flujo normal el inyector nunca se encuentra con un origen xref-stream puro, pero la ruta de lectura lo maneja para documentos que llegan desde otro lado

Verificar antes de confiar en la afirmación

La biblioteca incluye un verificador a nivel de bytes, TPdf.ValidatePdfA, que devuelve un TPdfAValidationResult. Su campo Conformance informa el nivel detectado y Issues es un conjunto de valores TPdfAValidationIssue; el método práctico IsCompliant solo es verdadero cuando se detectó un nivel real y el conjunto de incidencias está vacío. Úsalo como una primera barrera rápida 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 compra. El verificador a nivel de bytes detecta problemas estructurales, como un OutputIntent ausente, una acción prohibida, un /Encrypt presente o transparencia donde la parte 1 la prohíbe, con alta confianza, y la detección de incrustación de fuentes usa una heurística por conteo que reporta deliberadamente solo una señal de alta confianza en vez de perseguir cobertura por glifo. Lo que no hace es analizar operadores de flujo de contenido, algo que exigiría un parser completo de contenido y está fuera de alcance por diseño. Para un control de salida, combina el verificador de la biblioteca con veraPDF: el verificador es instantáneo y corre en todas partes sin DLL, veraPDF es la autoridad. Integrar esa combinación en una ejecución por lotes es el tema del CLI de informe de verificación previa por lotes, que es donde pertenece esta validación en un flujo real de archivo

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