Artículo técnico

Detectar la conformidad PDF cargada con HotPDF en Delphi

Su código de detección reporta que un archivo PDF/UA de otro productor no declara nada, y un escaneo PDF/R deja de ser PDF/R en el momento en que un editor le adjunta una nota adhesiva. HotPDF lee estas declaraciones de un documento cargado mediante funciones de acceso como IsLoadedPDFUA, GetLoadedPDFR1Version y GetLoadedPDFDeclarations, cada una de las cuales mira donde su propio estándar guarda el identificador

Dónde escribe su identificador cada estándar PDF

Los estándares subsidiarios nunca acordaron un único lugar para la declaración. La mayoría usa propiedades XMP en el flujo /Metadata del catálogo, unos cuantos antiguos todavía confían en claves del diccionario de información del documento, PDF/R pone una línea de comentario junto al trailer del archivo, y la generación PDF 2.0 añade dos mecanismos más: diccionarios de developer extensions en el catálogo y PDF Declarations dentro del XMP. Un identificador es la declaración del archivo sobre sí mismo; si el contenido está a la altura lo decide un validador, y producir salida conforme a PDF/A, PDF/X y PDF/UA es un tema aparte. Para enrutar (cola de impresión para PDF/X, archivado para PDF/A, pipeline de accesibilidad para WTPDF) la declaración es lo que necesita, leída del lugar correcto:

EstándarDónde vive la declaraciónFunción de acceso de HotPDF
PDF/A (ISO 19005)XMP pdfaid:part y pdfaid:conformance, más pdfaid:rev en PDF/A-4IsLoadedPDFA, GetLoadedPDFAVersion, GetLoadedPDFARevision
PDF/X (ISO 15930)GTS_PDFXVersion en el diccionario Info para PDF/X-1a y PDF/X-3, en XMP para las partes posterioresIsLoadedPDFX, GetLoadedPDFXVersion
PDF/UA (ISO 14289)XMP pdfuaid:partIsLoadedPDFUA, GetLoadedPDFUAVersion
PDF/VT (ISO 16612-2)XMP pdfvtid:GTS_PDFVTVersionIsLoadedPDFVT, GetLoadedPDFVTVersion
PDF/E (ISO 24517-1)ISO_PDFEVersion, leído del diccionario Info, del catálogo o del XMPIsLoadedPDFE, GetLoadedPDFEVersion
PDF/R-1 (ISO 23504-1)Comentario %PDF-raster-x.y justo encima del último startxrefIsLoadedPDFR1, GetLoadedPDFR1Version
PDF/VCR-1 (ISO 16613-1)GTS_PDFVCRVersion en el catálogo o en XMPIsLoadedPDFVCR, GetLoadedPDFVCRVersion
WTPDF 1.0, ISO/TS 32005Entradas pdfd:declarations en XMPIsLoadedWTPDF, GetLoadedWTPDFVersion, IsLoadedISO32005
Developer extensions de PDF 2.0/Extensions /ISO_ en el catálogoHasLoadedISODeveloperExtension, GetLoadedISODeveloperExtensionLevels
uses
  System.SysUtils, HPDFDoc;

procedure ReportClaims(const FileName: string; const Password: AnsiString);
var
  Pdf: THotPDF;
  PDFX, PDFXLevel: AnsiString;
begin
  Pdf := THotPDF.Create(nil);   // una instancia dedicada, usada solo para inspección
  try
    // La sobrecarga con contraseña descifra los flujos, paquete XMP incluido;
    // pase '' para archivos que abren sin preguntar
    if Pdf.LoadFromFile(FileName, Password) <= 0 then
      raise Exception.CreateFmt('Cannot open %s', [FileName]);

    if Pdf.IsLoadedPDFA then
      Writeln('PDF/A: ', Pdf.GetLoadedPDFAVersion);        // p. ej. PDF/A-2b
    PDFX := Pdf.GetLoadedPDFXVersion;                       // XMP, luego Info
    if PDFX <> '' then
    begin
      PDFXLevel := Pdf.GetLoadedInfoKeyValue('GTS_PDFXConformance');
      if PDFXLevel <> '' then
        Writeln('PDF/X: ', PDFX, ', conformance ', PDFXLevel)
      else
        Writeln('PDF/X: ', PDFX);
    end;
    if Pdf.IsLoadedPDFUA then
      Writeln('PDF/UA: ', Pdf.GetLoadedPDFUAVersion);      // p. ej. PDF/UA-1
    if Pdf.IsLoadedPDFVT then
      Writeln('PDF/VT: ', Pdf.GetLoadedPDFVTVersion);
    if Pdf.IsLoadedPDFE then
      Writeln('PDF/E: ', Pdf.GetLoadedPDFEVersion);
    if Pdf.IsLoadedPDFR1 then
      Writeln('PDF/R: ', Pdf.GetLoadedPDFR1Version);       // p. ej. PDF/R-1.0
    if Pdf.IsLoadedWTPDF then
      Writeln('WTPDF: ', Pdf.GetLoadedWTPDFVersion);
  finally
    Pdf.Free;
  end;
end;

Use una instancia THotPDF aparte para inspeccionar, como hace el ejemplo. Las funciones IsLoaded… responden por cualquier documento que el componente tenga, incluido uno que está construyendo: IsLoadedPDFUA también es True cuando PDFUACompliance se activó para la salida, y IsLoadedPDFX coincide con un PDFXCompliance no vacío. Útil a mitad de construcción, pero en un componente reutilizado para ambos trabajos mezcla sus ajustes de salida en el veredicto sobre el archivo de alguien más

PDF/X es la única familia cuyo identificador se mudó entre partes. PDF/X-1a y PDF/X-3 son perfiles PDF 1.3 que ponen GTS_PDFXVersion en el diccionario de información del documento y no necesitan nada de XMP, mientras que PDF/X-4 y las partes posteriores exigen la propiedad XMP pdfx:GTS_PDFXVersion. GetLoadedPDFXVersion busca primero en XMP, bajo el prefijo pdfx o el pdfxid, luego en el diccionario Info, después en las claves del catálogo que escriben algunos productores antiguos, y devuelve el valor declarado sin cambios. PDF/X-1a tiene un detalle que conviene contemplar en el código: su valor de versión es PDF/X-1:2001, y el nivel de conformidad PDF/X-1a:2001 vive en una clave propia, /GTS_PDFXConformance, que el ejemplo lee con GetLoadedInfoKeyValue cuando necesita distinguir X-1a del X-1 simple

Dónde escribe su identificador de conformidad cada estándar PDF, y cómo busca HotPDF para Delphi en XMP, el diccionario Info y las claves del catálogo de un PDF cargado
Cada estándar guarda su declaración donde eligió su especificación, y el lector busca en los puntos entre los que una declaración puede mudarse, en un orden fijo

¿Cómo se ve una declaración XMP válida?

XMP es RDF serializado como XML, y RDF/XML permite escribir una propiedad simple de dos maneras dentro de un rdf:Description: como elemento hijo, <pdfuaid:part>1</pdfuaid:part>, o como atributo en la propia description, pdfuaid:part="1", con comillas simples o dobles. ISO 16684-1 acepta ambas, y un paquete puede repartir sus propiedades entre varios elementos rdf:Description. La división no es hipotética. HotPDF escribe la forma de elemento hijo para PDF/UA, PDF/VT, PDF/E y PDF/X, mientras que el corpus de pruebas de veraPDF y muchos productores externos escriben la forma abreviada, con descriptions como pdfuaid:part="1" o pdfvtid:GTS_PDFVTVersion="PDF/VT-1" que llevan la declaración completa en atributos. Un detector construido alrededor de Pos('<pdfuaid:part>', Xmp) y ejercitado solo con salida propia pasa todas las comprobaciones locales y luego reporta la primera muestra de veraPDF como sin declaración. El lector XMP de HotPDF maneja ambas formas para cada función de acceso de la tabla. GetLoadedPDFAVersion combina pdfaid:part y pdfaid:conformance en identificadores como PDF/A-1b, PDF/A-2u, PDF/A-4 o PDF/A-4f, y GetLoadedPDFARevision devuelve pdfaid:rev, el año de edición que PDF/A-4 exige (2020) y que las partes 1 a 3 no definen:

procedure ReportPDFALevel(Pdf: THotPDF);
var
  Level, Revision: AnsiString;
begin
  Level := Pdf.GetLoadedPDFAVersion;      // 'PDF/A-1b', 'PDF/A-2u', 'PDF/A-4f', ...
  if Level = '' then
    Exit;
  Revision := Pdf.GetLoadedPDFARevision;  // '2020' para PDF/A-4, vacío para las partes 1-3
  if Revision <> '' then
    Writeln(Level, ', edition ', Revision)
  else
    Writeln(Level);
  // Esperado: "PDF/A-2b" sin importar si el productor escribió
  // <pdfaid:part>2</pdfaid:part> o pdfaid:part="2"
end;

Los prefijos son la otra parte móvil. En XML el prefijo es solo un alias local; lo que identifica al esquema PDF/A es el URI de espacio de nombres http://www.aiim.org/pdfa/ns/id/, que las erratas aprobadas de ISO 19005-4 también admiten en su grafía https://, y un productor puede atarlo al prefijo que le guste. GetLoadedPDFAVersion resuelve el prefijo que el paquete declara para ese URI antes de leer parte y nivel, así que un paquete que escribe pdfa:part en lugar de pdfaid:part igualmente se identifica. Cualquier otra propiedad XMP que usted lea por su cuenta del paquete decodificado que devuelve GetLoadedXMPMetadata merece el mismo cuidado: ambas serializaciones, el prefijo resuelto desde su declaración xmlns, y espacios en blanco permitidos alrededor del signo igual

Dos serializaciones XMP válidas de una declaración PDF/UA junto a la regla de espacio de nombres que deja que un prefijo renombrado siga identificando PDF/A ante HotPDF en Delphi
La grafía de elemento hijo y la de atributo son igual de válidas, y el URI de espacio de nombres identifica al esquema, no al prefijo

Los archivos cifrados ocultan la declaración a las búsquedas de bytes

Un escaneo del archivo crudo buscando pdfaid o ISO_PDFEVersion se salta el parseo y falla en dos casos ordinarios. La mayoría de los flujos XMP están comprimidos con Flate, así que los nombres de las propiedades nunca aparecen como texto en disco. Y en un documento cifrado el flujo de metadatos es texto cifrado: ISO 32000-2 §7.6.4.2 define EncryptMetadata con un valor por defecto de true, así que salvo que el productor haya excluido explícitamente, el paquete va cifrado junto con todos los demás flujos. Una búsqueda de bytes contra un archivo PDF/E-1 cifrado reporta por tanto la declaración ISO_PDFEVersion como ausente aunque esté ahí. El orden confiable es cargar, descifrar, decodificar y luego buscar. Abra el archivo por la sobrecarga con contraseña, LoadFromFile(FileName, Password), y pase una cadena vacía para los archivos que solo llevan contraseña de owner y abren en cualquier visor sin preguntar. El LoadFromFile(FileName) de un solo argumento carga la estructura del documento sin descifrar sus flujos, lo que deja el paquete XMP ilegible; tras la sobrecarga con contraseña, GetLoadedXMPMetadata y todas las funciones de acceso de arriba trabajan sobre el paquete descifrado y descomprimido

¿Por qué un archivo PDF/R pierde su identidad tras una edición?

PDF/R-1 se identifica de una manera en la que ningún otro estándar subsidiario lo hace. ISO 23504-1, cláusula 5, exige una línea de comentario con el texto %PDF-raster-1.0 (o la versión que aplique) en la línea inmediatamente anterior a la última línea que contiene startxref. Un parser de PDF tira los comentarios mientras lee los datos de referencias cruzadas, así que un modelo de objetos no tiene nada que reportar; la evidencia existe solo en los bytes crudos al final del archivo. GetLoadedPDFR1Version vuelve a la fuente desde la que se cargó el documento, lee como mucho los últimos 64 KiB, encuentra el último startxref y revisa la línea de arriba. Ahora siga una actualización incremental. Un editor que añade una anotación adjunta objetos nuevos, una sección de referencias cruzadas nueva, un trailer nuevo y un startxref nuevo después del %%EOF original. La línea %PDF-raster-1.0 sigue en el archivo, pero ahora queda encima del penúltimo startxref, y el estándar es inequívoco sobre cuál cuenta. Resultado esperado: IsLoadedPDFR1 devuelve False tras la edición, salvo que la herramienta de edición reemita el comentario en su propia actualización. Un detector que escanea el archivo completo buscando %PDF-raster- diría que sí, y por la regla de la cláusula 5 esa respuesta está mal. Un flujo de captura que deba mantener sus archivos PDF/R pone las anotaciones en un archivo sidecar o en una herramienta que reescribe el archivo completo como PDF/R

Colas de archivo antes y después de una actualización incremental: el comentario PDF/R se queda pero pierde el ancla del último startxref que HotPDF revisa en Delphi
Un editor que adjunta una actualización deja el comentario PDF/R encima del penúltimo startxref, donde el estándar ya no lo cuenta

Developer extensions: niveles enteros, nombres y arreglos

Los documentos PDF 2.0 anuncian las especificaciones técnicas ISO de las que dependen (hashes SHA-3 de ISO/TS 32001, firmas ECC de ISO/TS 32002, activos glTF de ISO/TS 32007) a través del diccionario /Extensions del catálogo, definido en ISO 32000-2 §7.12. Bajo el prefijo registrado /ISO_, la Tabla 48 admite un único diccionario de developer extensions o, novedad de PDF 2.0, un arreglo de ellos, y la Tabla 49 junto con §7.12.5 hace de /ExtensionLevel un entero. Un lector que maneja solo una de las dos formas de contenedor pierde todos los archivos escritos de la otra manera. HasLoadedISODeveloperExtension y GetLoadedISODeveloperExtensionLevels aceptan ambas y resuelven referencias indirectas en el camino, aunque §7.12.1 dice que estos diccionarios deben ser objetos directos:

procedure ReportISOExtensions(Pdf: THotPDF);
var
  Levels: THPDFIntegerArray;
  Revision, URL: AnsiString;
  I: Integer;
begin
  Levels := Pdf.GetLoadedISODeveloperExtensionLevels;
  for I := 0 to High(Levels) do
    Writeln('ISO_ extension level ', Levels[I]);

  if Pdf.HasLoadedISODeveloperExtension(32001, Revision, URL) then
    Writeln('ISO/TS 32001 revision ', Revision, ', documented at ', URL);
  // Esperado para un archivo escrito con EnsureISO32001Extensions:
  //   ISO/TS 32001 revision :2022, documented at https://www.iso.org/standard/45874.html
  if Pdf.HasLoadedISODeveloperExtension(32007) then
    Writeln('glTF 3D assets per ISO/TS 32007');
end;

La primera edición de ISO/TS 32007:2024 tiene un ejemplo en §4.8 que escribe /ExtensionLevel /32007 como name; PDF Errata Issue #835 lo corrigió al entero 32007, pero un productor que copió el ejemplo original todavía escribe el name. El lector de HotPDF por eso recurre a parsear un valor name o string, con o sin la barra inicial, cuando la búsqueda del entero vuelve vacía, así que HasLoadedISODeveloperExtension(32007) encuentra ambas grafías. La otra errata concierne a /URL: la Tabla 49 de ISO 32000-2:2020 lista /URL como opcional, y el Issue #732 la promueve a obligatoria. Del lado de la escritura HotPDF emite la forma entera y un /URL en cada llamada EnsureISO…Extensions; del lado de la lectura, un parámetro out URL vacío es lo normal para archivos escritos según el texto de 2020, así que trátelo como información y nunca como razón para rechazar la extensión

¿Qué URI WTPDF escribió el productor?

Las declaraciones WTPDF 1.0 e ISO/TS 32005 son PDF Declarations: entradas en un bag pdfd:declarations en XMP, bajo el espacio de nombres http://pdfa.org/declarations/ definido por la nota técnica PDF Declarations de la PDF Association. Cada entrada lleva un URI conformsTo y campos opcionales claimBy, claimDate, claimCredentials y claimReport, que GetLoadedPDFDeclarations devuelve como un arreglo de registros THPDFDeclarationEntry; del lado de la escritura, AddPDFDeclaration pone los mismos cinco campos en el paquete XMP, junto a los espacios de nombres de identificación de PDF 2.0, PDF/A-4 y PDF/UA-2. Cortar el bag en entradas con una búsqueda de texto plano tiene su propia trampa: divida en cada aparición de conformsTo y la etiqueta de cierre </pdfd:conformsTo> de la forma de elemento hijo cuenta como inicio de una entrada nueva, lo que trunca la primera declaración antes de su propio final. El lector de HotPDF salta las etiquetas de cierre y acepta ambas serializaciones

Los URI en sí son la parte más difícil. La misma declaración le llega como http:// o https://, y el Issue #395 de WTPDF 1.0 cambió si una / va antes del #, así que los archivos escritos antes y después de la errata difieren en ese único carácter; una comparación de cadenas simple los trata como estándares distintos. HasLoadedPDFDeclaration compara URI sin distinguir mayúsculas, pliega https:// sobre http:// y quita una / justo antes del # en ambos lados, así que la consulta de abajo coincide con la grafía que haya elegido el productor. El fragmento queda intacto: #accessibility1.0 y #accessibility1.0-validated son declaraciones distintas, la segunda afirma que un validador revisó el archivo, y usted debe pedir la que le importa a su flujo de trabajo. GetLoadedWTPDFVersion colapsa ambas en 'WTPDF 1.0 Accessibility' o 'WTPDF 1.0 Reuse' para enrutar

procedure ReportDeclarations(Pdf: THotPDF);
var
  Decls: THPDFDeclarationEntryArray;
  I: Integer;
begin
  Decls := Pdf.GetLoadedPDFDeclarations;
  for I := 0 to High(Decls) do
    Writeln(Decls[I].ConformsTo, ' claimed by ', Decls[I].ClaimBy,
      ' on ', Decls[I].ClaimDate);

  // Coincide con http o https, con o sin la barra antes del '#'
  if Pdf.HasLoadedPDFDeclaration(
    'https://pdfa.org/declarations/wtpdf#accessibility1.0') then
    Writeln('WTPDF 1.0 Accessibility (unvalidated claim)');

  // Cubre tanto la variante simple como la -validated de Reuse y Accessibility
  Writeln('WTPDF profile: ', Pdf.GetLoadedWTPDFVersion);
end;

Para un router de captura o de impresión, el orden de las operaciones importa más que cualquier función de acceso: pruebe IsLoadedPDFR1 sobre el archivo tal como llegó, antes de que un paso de anotación o de estampado adjunte su actualización, y enrute PDF/X con GetLoadedPDFXVersion, que cubre por igual el diccionario Info de los trabajos PDF/X-1a y PDF/X-3 y el XMP de los trabajos PDF/X-4, leyendo /GTS_PDFXConformance solo donde haya que distinguir X-1a de X-1. Ambas comprobaciones corren sobre cualquier documento cargado en el HotPDF Delphi Component, incluidos los archivos cifrados abiertos con su contraseña