Artículo técnico

Salida PDF 2.0, PDF/A-4 y PDF/UA-2 con HotPDF en Delphi

Una checklist de PDF/A-3 suele decir que el paquete XMP tiene que llevar dos entradas de identificación, pdfaid:part y pdfaid:conformance. Un archivo PDF/A-4 bien escrito lleva pdfaid:part 4 y pdfaid:rev 2020, y para el perfil base no hay entrada de conformidad alguna. Añadir una letra para "arreglarlo" deja el archivo mal: ISO 19005-4 identifica la parte base por número y año de revisión, y solo las dos extensiones conservan una letra

PDF/UA-2 tiene una trampa parecida un nivel más abajo, dentro del árbol de estructura. Declarar un rol personalizado en su propio espacio de nombres parece todo el trabajo, ya que los espacios de nombres son lo que la parte 2 añadió. Es la mitad. Un rol como WidgetGroup en https://example.com/ns/widgets todavía tiene que mapear a un rol estándar antes de que un checker conforme lo acepte, y nada en la página renderizada insinúa que falta el mapeo

Elegir PDF/A-4 con PDFACompliance

En HotPDF los perfiles PDF/A-4 son tres valores de la propiedad PDFACompliance existente: '4' para la parte base, '4E' para documentos de ingeniería y '4F' para archivos que llevan archivos incrustados. Con '4' seleccionado, el paquete XMP recibe pdfaid:part 4 y pdfaid:rev 2020 y nada más del espacio de nombres pdfaid. '4E' y '4F' añaden pdfaid:conformance con E o F. Las funciones decodificadoras informan la misma división: PDFAPart devuelve 4 para los tres valores, y PDFAConformanceLevel devuelve E, F o #0 para el perfil base

Matriz de valores de selector PDF/A-4 en HotPDF mostrando el base 4 sin letra de conformidad junto a los perfiles 4E de ingeniería y 4F de archivos incrustados sobre restricciones PDF 2.0 compartidas
el '4' base escribe part 4 rev 2020 sin letra alguna; solo 4E y 4F conservan una

Los tres fuerzan una cabecera PDF 2.0. La actualización ocurre dentro de EndDoc, así que un documento que empezó como PDF 1.4 sale como 2.0 sin llamada aparte. El único caso que lo frena es StrictVersionLock. Ese flag prohíbe las actualizaciones automáticas, así que EndDoc lanza "PDF/A-4 requires PDF 2.0; StrictVersionLock prevents the version upgrade" en vez de escribir un archivo 1.7 que dice ser parte 4 en sus metadatos. Una base de código que clava la versión para un flujo de impresión antiguo verá exactamente ese mensaje la primera vez que alguien cambie el perfil

Dos reglas de PDF/A-4 cambian lo que aterriza en el trailer y el Catalog. ISO 19005-4 §6.1.3 permite un diccionario de información de documento solo cuando el Catalog tiene una entrada PieceInfo, y aun así lo limita a ModDate, así que HotPDF no escribe ningún diccionario Info para la parte 4 y el título viaja como dc:title en XMP. Title y Lang siguen siendo obligatorios. Dejad cualquiera de los dos vacío y EndDoc lanza una excepción. La lanza también cuando no se añadió ningún output intent con AddPDFAOutputIntent, y EnableEncrypt se niega a correr mientras haya cualquier perfil PDF/A seleccionado, porque un archivo archivístico cifrado es una contradicción que el estándar no admite

Archivos asociados bajo PDF/A-4f y 4e

Los archivos asociados son la razón principal para elegir PDF/A-4f: una factura XML detrás de la página renderizada, un payload JSON que un sistema posterior parsea. AddPDFAssociatedFile toma un nombre de archivo, un tipo MIME, una descripción, un valor de AFRelationship y los bytes, y devuelve el diccionario FileSpec indirecto para que podáis colgarle claves opcionales vuestras

var
  Pdf: THotPDF;
  Icc: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  Icc := TFileStream.Create('sRGB2014.icc', fmOpenRead or fmShareDenyWrite);
  try
    Pdf.FileName := 'invoice-archive.pdf';
    Pdf.PDFACompliance := '4F';        // part 4, rev 2020, conformance F
    Pdf.Title := 'Invoice 2026-0731';  // se escribe solo al dc:title de XMP
    Pdf.Lang := 'en-US';
    Pdf.BeginDoc;
    Pdf.AddPDFAOutputIntent('sRGB IEC61966-2.1', 'sRGB IEC61966-2.1',
      Icc, 3, 'DeviceRGB');
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-0731');
    // LoadInvoiceBytes es una función vuestra que devuelve TBytes
    Pdf.AddPDFAssociatedFile('invoice.xml', 'text/xml',
      'Structured invoice data', 'Data', LoadInvoiceBytes);
    Pdf.EndDoc;
  finally
    Icc.Free;
    Pdf.Free;
  end;
end;

Cada llamada hace cuatro cosas. Escribe un flujo EmbeddedFile cuyo /Subtype lleva el tipo MIME como nombre PDF, con un diccionario /Params que carga el tamaño, una fecha de modificación y un checksum MD5. Construye un FileSpec con /F y /UF, la descripción como /Desc, y el nombre de relación. Luego registra ese FileSpec dos veces, en el array /AF del Catalog y en el árbol de nombres EmbeddedFiles. Ambos registros son obligatorios. Un archivo registrado en un solo sitio puede que aún abra desde la lista de adjuntos de un visor, que es por qué una comprobación visual suelta prueba muy poco sobre un archivo archivístico

La cadena de relación tiene que ser una de Source, Data, Alternative, Supplement o Unspecified; cualquier otra cosa lanza una excepción antes de escribir un byte, y también un tipo MIME vacío. El perfil también se comprueba. AddPDFAssociatedFile acepta PDF/A-3, PDF/A-4e y PDF/A-4f, y el perfil base '4' lanza una excepción porque no admite archivos asociados. En la dirección contraria, AddDocumentAttachment se niega a correr bajo PDF/A-3, 4e y 4f, ya que un adjunto plano carece del registro /AF que esos perfiles necesitan. El código escrito para PDF/A-3 contra AddPDFA3AssociatedFile sigue compilando y se comporta igual; AddPDFAssociatedFile es el nombre neutral de perfil para ese método

Las dos extensiones reparten entre sí los permisos restantes. Bajo '4F', EndDoc se asegura de que el Catalog lleva un árbol de nombres EmbeddedFiles, y AddFileAttachmentAnnotation se acepta, mientras que el perfil base y 4e lo rechazan. Bajo '4E', Add3DAnnotation y AddRichMediaAnnotation están permitidas, junto con las acciones SetOCGState y GoTo3DView. Todos los demás perfiles PDF/A rechazan las cuatro

¿Por qué un rol con espacio de nombres aún falla en PDF/UA-2?

Un rol personalizado pasa PDF/UA-2 solo cuando se cumplen dos condiciones: su entrada /NS apunta a un diccionario de espacio de nombres listado en la raíz del árbol de estructura, y el rol resuelve a través del RoleMapNS de ese espacio de nombres hacia un rol en un espacio de nombres estándar. HotPDF reparte el trabajo entre tres métodos. RegisterStructureNamespace crea un diccionario indirecto /Type /Namespace, lo añade a StructTreeRoot /Namespaces y lo devuelve; una segunda llamada con la misma URI devuelve el diccionario existente en vez de añadir otro. AddStructureElementNS crea el elemento y fija su /NS, registrando el espacio de nombres por el camino. AddStructRoleMapNS escribe el mapeo

Diagrama que contrasta el vocabulario plano de roles de PDF/UA-1 con los espacios de nombres de PDF/UA-2 atados mediante RegisterStructureNamespace y AddStructureElementNS en Delphi
una URI siempre reutiliza un único diccionario de espacio de nombres — nada de montones de duplicados
const
  PDF2NS   = 'http://iso.org/pdf2/ssn';
  WidgetNS = 'https://example.com/ns/widgets';
var
  DocElem, Group: THPDFDictionaryObject;
begin
  Pdf.PDFUACompliance := True;
  Pdf.PDFUAPart := 2;                // PDF/UA-2 exige PDF 2.0
  Pdf.Title := 'Widget catalog';     // un Title vacío lanza excepción en EndDoc
  Pdf.Lang := 'en-US';
  Pdf.BeginDoc;

  DocElem := Pdf.AddStructureElement('Document', nil);  // atado a PDF2NS
  Group := Pdf.AddStructureElementNS('WidgetGroup', WidgetNS, DocElem);
  // sin este mapeo EndDoc lanza ua2.rolemap.unresolved
  Pdf.AddStructRoleMapNS('WidgetGroup', WidgetNS, 'Div', PDF2NS);

  Pdf.CurrentPage.SetFont('Arial', [], 18);
  Pdf.CurrentPage.BeginMarkedContentMCID('H1', 0);
  Pdf.CurrentPage.TextOut(50, 760, 0, 'Widgets');
  Pdf.CurrentPage.EndMarkedContent;
  Pdf.AddStructureElement('H1', Group, 0, 0);  // índice de página 0, MCID 0
  Pdf.EndDoc;
end;

Parte de ese registro ocurre sin que se os pida. Con PDFUAPart en 2, AddStructureElement('Document', nil) ata el elemento al espacio de nombres estándar PDF 2.0, http://iso.org/pdf2/ssn, porque la raíz de un árbol PDF/UA-2 tiene que ser un Document en ese espacio de nombres. El mismo atado ocurre para cada rol del vocabulario PDF 2.0, así que H1 y P no necesitan argumento de espacio de nombres. Los roles que existen solo en el conjunto 1.7, como Note, Code o BlockQuote, se quedan en el espacio de nombres 1.7

El destino del mapeo es una elección de verdad. Pasar un espacio de nombres de destino vacío a AddStructRoleMapNS escribe la forma corta, un nombre de rol desnudo resuelto contra el conjunto 1.7. Pasar la URI PDF 2.0 escribe la forma de dos elementos [role namespace]. En un documento PDF/UA-2, mapead al espacio de nombres PDF 2.0: el árbol entero resuelve entonces en un solo vocabulario, y destinos como Aside o FENote, que no tienen contrapartida 1.7, quedan disponibles. Dos formas de mapeo se rechazan sin más en EndDoc: una cadena que se devuelve sobre sí misma y un rol en un espacio de nombres explícito mapeado a otro rol de ese mismo espacio de nombres

El orden de llamada es más flexible de lo que parece. El mapeo puede añadirse antes o después de que el elemento exista, porque nada lo comprueba hasta que el árbol está terminado. Lo que falla de inmediato es AddStructureElementNS mientras PDFUAPart vale 1. La parte 1 no tiene espacios de nombres, y la llamada lanza "structure namespaces require PDFUAPart=2" en el acto

Qué comprueba EndDoc antes de escribir el archivo

Con PDFUACompliance activado y PDFUAPart en 2, EndDoc ejecuta una validación de estructura PDF/UA-2 completa antes de serializar nada, y el primer error se convierte en excepción. Cuatro de sus comprobaciones vigilan el esqueleto del árbol. La raíz tiene que sostener exactamente un elemento Document de primer nivel. Cada diccionario de espacio de nombres tiene que ser indirecto, tipado Namespace, y llevar una URI no vacía que aparezca solo una vez. Cada referencia /NS tiene que resolver a un diccionario listado en el array /Namespaces de la raíz. Y cada rol tiene que resolver a un espacio de nombres estándar, directamente o a través de RoleMap y RoleMapNS. Los elementos que no se pueden alcanzar desde el elemento Document también se informan

Flujo de las cuatro compuertas de árbol de estructura entre las comprobaciones que HotPDF ejecuta en EndDoc: raíz Document única, diccionarios de espacio de nombres limpios, referencias NS resolubles, roles estándar o mapeados
cazar las compuertas antes rechazaría estados intermedios válidos

La misma pasada cubre los requisitos a nivel de documento: un Lang no vacío y válido; MarkInfo /Marked en true; DisplayDocTitle activado; y un paquete XMP con un dc:title no vacío y pdfuaid:part 2. HotPDF fija la marca de etiquetado, la preferencia del visor y la identificación XMP por su cuenta en cuanto PDF/UA está activado, y escribe pdfuaid:rev 2024 junto al número de parte. Título e idioma van de vuestra cuenta, y un valor vacío de cualquiera de los dos lanza antes de que el validador arranque siquiera. Encabezados, listas, tablas, anotaciones y formularios se comprueban en la misma pasada

Esperar hasta EndDoc va a propósito. A mitad de generación un árbol está legítimamente incompleto: el elemento Document puede que aún no tenga hijos, un espacio de nombres puede registrarse antes de que ningún elemento lo use, un mapeo puede llegar después de su elemento. Comprobar en cada llamada rechazaría esos estados. En EndDoc el árbol está terminado y aún en memoria, y la excepción nombra un código de regla y la ruta del elemento que la rompió

try
  Pdf.EndDoc;
except
  on E: Exception do
  begin
    // E.Message tiene esta forma
    //   PDF/UA-2 validation failed [ua2.heading.skipped at ...]:
    //   Heading level skips from H2 to H4
    LogBuildFailure(Pdf.FileName, E.Message);  // logger vuestro
    raise;
  end;
end;

La salida PDF/A-4 recibe el mismo trato de su propio validador. Cuando PDFACompliance guarda un valor de parte 4, EndDoc comprueba el grafo generado contra ISO 19005-4 y lanza con el código de regla y la cláusula, por ejemplo pdfa4.filespec.relationship para un FileSpec sin relación válida o pdfa4.trailer.info_entries para una clave Info suelta. La edición Enterprise expone ambos validadores como ValidatePDFUA2Structure y ValidatePDFA4, que rellenan un array de incidencias con cada diagnóstico retenido en vez de pararse en el primer error. Para un paso de CI que debería imprimir una lista completa, ese es el punto de entrada mejor

Roles PDF 2.0 y encabezados más allá de H6

La enumeración tipada THPDFStandardStructureType gana sstDocumentFragment, sstAside, sstTitle, sstFENote, sstSub, sstEm, sstStrong y sstArtifact. Tres de ellos cambian cómo se etiquetan los documentos de negocio corrientes. Aside da a las barras laterales y a las citas destacadas un hogar que no es un Sect mal usado. FENote marca notas al pie y notas finales como notas, así que un lector de pantalla puede ofrecerlas a petición en vez de leérselas a media frase. Em y Strong llevan el énfasis como estructura, donde un árbol PDF 1.7 solo podía envolverlo en un Span

Los encabezados son abiertos en PDF 2.0. La enumeración se para en sstH6, pero la sobrecarga de cadena de AddStructureElement acepta H7, H8 y más allá, porque el conjunto PDF 2.0 define Hn para cualquier n desde 1 hacia arriba. PDF 1.7 se paraba en H6, lo que obligaba a los documentos técnicos profundos a aplanar su esquema o a reutilizar niveles. Para normas, códigos legales y catálogos de piezas, eso solo puede justificar mover la salida a PDF 2.0

Los encabezados más profundos vienen con una regla más estricta. La pasada PDF/UA-2 dentro de EndDoc exige que los niveles de encabezado sean secuenciales: el primer encabezado tiene que ser H1, y un salto de H2 a H4 lanza con ua2.heading.skipped. Una jerarquía puede empezar más abajo solo dentro de un DocumentFragment, el rol pensado para contenido extraído de un documento mayor. Un generador que mapea un esquema de Markdown o HTML directo sobre roles Hn hereda cada nivel que el autor se saltó por tamaño visual, así que normalizad el esquema antes de etiquetarlo

Antes de cambiar la salida de producción a PDF 2.0

PDF 2.0 empieza con un cambio de cabecera, y la cabecera es lo primero que los consumidores viejos leen. Un servicio de ingesta, un flujo de impresión o un visor de línea de negocio que solo acepta hasta PDF 1.7 se para en %PDF-2.0 no importa lo limpio que esté el resto del archivo. Seleccionar cualquier perfil PDF/A-4 o PDF/UA parte 2 selecciona PDF 2.0 lo pidáis o no, así que confirmad cada sistema consumidor antes del cambio, no después

El despliegue de menor riesgo conserva PDF/A-3 para los documentos que salen hacia lectores que no controláis, usa PDF/A-4f para archivos internos donde vosotros sois el lado de ingesta, y adopta PDF/UA-2 donde una política de accesibilidad lo nombra. StrictVersionLock hace de guardia durante la transición: fijad Version en pdf17 y el lock en las rutas de código que tienen que quedarse en 1.7, y una selección accidental de PDF/A-4 falla en EndDoc con un mensaje en vez de llegar a un sistema que no puede abrir el archivo. Las guías de validación PDF/A, PDF/X y PDF/UA y de facturas híbridas ZUGFeRD y Factur-X sobre PDF/A-3 cubren las decisiones de perfil que van antes del número de versión, y las notas sobre informes preflight automatizados muestran cómo convertir el veredicto en un paso de build

Todo esto corre como código VCL nativo dentro del componente para Delphi y C++Builder, así que la salida PDF/A-4 y PDF/UA-2 viaja con vuestro ejecutable y no necesita ningún motor externo. Los perfiles soportados y las versiones de RAD Studio están listados en la página del componente HotPDF Delphi