Technical Article

Finding the PDF/A, PDF/X, PDF/UA, PDF/R and WTPDF Claims in a Loaded PDF

Your detection code reports that a PDF/UA file from another producer claims nothing, and a PDF/R scan stops being PDF/R the moment an editor appends a sticky note to it. HotPDF reads these claims from a loaded document through accessors such as IsLoadedPDFUA, GetLoadedPDFR1Version and GetLoadedPDFDeclarations, each of which looks where its own standard stores the identifier

Where each PDF standard writes its identifier

The subsidiary standards never agreed on a single place for the claim. Most of them use XMP properties in the catalog's /Metadata stream, a few older ones still rely on keys in the document information dictionary, PDF/R puts a comment line next to the file trailer, and the PDF 2.0 generation adds two more mechanisms: developer extension dictionaries in the catalog and PDF Declarations inside XMP. An identifier is the file's statement about itself; whether the content lives up to it is for a validator to decide, and producing conforming PDF/A, PDF/X and PDF/UA output is a topic of its own. For routing (print queue for PDF/X, archive for PDF/A, accessibility pipeline for WTPDF) the claim is what you need, read from the right spot:

StandardWhere the claim livesHotPDF accessor
PDF/A (ISO 19005)XMP pdfaid:part and pdfaid:conformance, plus pdfaid:rev in PDF/A-4IsLoadedPDFA, GetLoadedPDFAVersion, GetLoadedPDFARevision
PDF/X (ISO 15930)GTS_PDFXVersion in the Info dictionary for PDF/X-1a and PDF/X-3, in XMP for later partsIsLoadedPDFX, 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, read from the Info dictionary, the catalog or XMPIsLoadedPDFE, GetLoadedPDFEVersion
PDF/R-1 (ISO 23504-1)%PDF-raster-x.y comment directly above the last startxrefIsLoadedPDFR1, GetLoadedPDFR1Version
PDF/VCR-1 (ISO 16613-1)GTS_PDFVCRVersion in the catalog or XMPIsLoadedPDFVCR, GetLoadedPDFVCRVersion
WTPDF 1.0, ISO/TS 32005pdfd:declarations entries in XMPIsLoadedWTPDF, GetLoadedWTPDFVersion, IsLoadedISO32005
PDF 2.0 developer extensions/Extensions /ISO_ in the catalogHasLoadedISODeveloperExtension, GetLoadedISODeveloperExtensionLevels
uses
  System.SysUtils, HPDFDoc;

procedure ReportClaims(const FileName: string; const Password: AnsiString);
var
  Pdf: THotPDF;
  PDFX, PDFXLevel: AnsiString;
begin
  Pdf := THotPDF.Create(nil);   // a dedicated instance, used only for inspection
  try
    // The password overload decrypts the streams, the XMP packet included;
    // pass '' for files that open without a prompt
    if Pdf.LoadFromFile(FileName, Password) <= 0 then
      raise Exception.CreateFmt('Cannot open %s', [FileName]);

    if Pdf.IsLoadedPDFA then
      Writeln('PDF/A: ', Pdf.GetLoadedPDFAVersion);        // e.g. PDF/A-2b
    PDFX := Pdf.GetLoadedPDFXVersion;                       // XMP, then 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);      // e.g. 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);       // e.g. PDF/R-1.0
    if Pdf.IsLoadedWTPDF then
      Writeln('WTPDF: ', Pdf.GetLoadedWTPDFVersion);
  finally
    Pdf.Free;
  end;
end;

Use a separate THotPDF instance for inspection, as the example does. The IsLoaded… functions answer for whatever document the component holds, including one it is building: IsLoadedPDFUA is also True when PDFUACompliance has been switched on for output, and IsLoadedPDFX agrees with a non-empty PDFXCompliance. Handy mid-construction, but on a component reused for both jobs it mixes your output settings into the verdict about somebody else's file

PDF/X is the one family whose identifier moved between parts. PDF/X-1a and PDF/X-3 are PDF 1.3 profiles that put GTS_PDFXVersion in the document information dictionary and need no XMP at all, while PDF/X-4 and later parts require the XMP property pdfx:GTS_PDFXVersion. GetLoadedPDFXVersion looks in XMP first, under either the pdfx or the pdfxid prefix, then in the Info dictionary, then in the catalog keys some legacy producers write, and returns the declared value unchanged. PDF/X-1a has one wrinkle worth coding for: its version value is PDF/X-1:2001, and the PDF/X-1a:2001 conformance level lives in a key of its own, /GTS_PDFXConformance, which the example reads with GetLoadedInfoKeyValue when it needs to tell X-1a apart from plain X-1

Where each PDF standard writes its conformance identifier, and how HotPDF for Delphi searches XMP, the Info dictionary and catalog keys in a loaded PDF
each standard stores its claim where its specification chose, and the reader searches the spots a claim can move between in a fixed order

What does a valid XMP claim look like?

XMP is RDF serialized as XML, and RDF/XML allows a simple property to be written two ways inside an rdf:Description: as a child element, <pdfuaid:part>1</pdfuaid:part>, or as an attribute on the description itself, pdfuaid:part="1", in single or double quotes. ISO 16684-1 accepts both, and a packet may split its properties across several rdf:Description elements. The split is not hypothetical. HotPDF writes the child-element form for PDF/UA, PDF/VT, PDF/E and PDF/X, while the veraPDF test corpus and many external producers write the shorthand, with descriptions such as pdfuaid:part="1" or pdfvtid:GTS_PDFVTVersion="PDF/VT-1" carrying the whole claim in attributes. A detector built around Pos('<pdfuaid:part>', Xmp) and exercised only on in-house output passes every local check and then calls the first veraPDF sample unclaimed. HotPDF's XMP reader handles both forms for every accessor in the table. GetLoadedPDFAVersion combines pdfaid:part and pdfaid:conformance into identifiers such as PDF/A-1b, PDF/A-2u, PDF/A-4 or PDF/A-4f, and GetLoadedPDFARevision returns pdfaid:rev, the edition year that PDF/A-4 requires (2020) and parts 1 to 3 do not define:

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' for PDF/A-4, empty for parts 1-3
  if Revision <> '' then
    Writeln(Level, ', edition ', Revision)
  else
    Writeln(Level);
  // Expected: "PDF/A-2b" whether the producer wrote
  // <pdfaid:part>2</pdfaid:part> or pdfaid:part="2"
end;

Prefixes are the other moving part. In XML the prefix is only a local alias; what identifies the PDF/A schema is the namespace URI http://www.aiim.org/pdfa/ns/id/, which approved ISO 19005-4 errata also admit in its https:// spelling, and a producer may bind it to any prefix it likes. GetLoadedPDFAVersion looks up the prefix the packet declares for that URI before it reads the part and level, so a packet that writes pdfa:part instead of pdfaid:part still identifies. Any other XMP property you read yourself from the decoded packet that GetLoadedXMPMetadata returns deserves the same care: both serializations, the prefix resolved from its xmlns declaration, and whitespace allowed around the equals sign

Two valid XMP serializations of one PDF/UA claim beside the namespace rule that lets a renamed prefix still identify PDF/A to HotPDF in Delphi
the child element and attribute spellings are equally valid, and the namespace URI identifies the schema rather than the prefix

Encrypted files hide the claim from byte searches

A scan of the raw file for pdfaid or ISO_PDFEVersion skips parsing and breaks on two ordinary cases. Most XMP streams are Flate-compressed, so the property names never appear as text on disk. And in an encrypted document the metadata stream is ciphertext: ISO 32000-2 §7.6.4.2 defines EncryptMetadata with a default of true, so unless the producer explicitly opted out, the packet is encrypted along with every other stream. A byte search against an encrypted PDF/E-1 file therefore reports the ISO_PDFEVersion claim as missing even though it is there. The reliable order is load, decrypt, decode, then search. Open the file through the password overload, LoadFromFile(FileName, Password), and pass an empty string for files that only carry an owner password and open in any viewer without a prompt. The single-argument LoadFromFile(FileName) loads the document structure without decrypting its streams, which leaves the XMP packet unreadable; after the password overload, GetLoadedXMPMetadata and every accessor above work on the decrypted, decompressed packet

Why does a PDF/R file lose its identity after an edit?

PDF/R-1 identifies itself in a way no other subsidiary standard does. ISO 23504-1 clause 5 requires a comment line reading %PDF-raster-1.0 (or whatever version applies) on the line immediately before the last line that holds startxref. A PDF parser throws comments away while it reads the cross-reference data, so an object model has nothing to report; the evidence exists only in the raw bytes at the end of the file. GetLoadedPDFR1Version goes back to the source the document was loaded from, reads at most the final 64 KiB, finds the last startxref and checks the line above it. Now follow an incremental update. An editor that adds an annotation appends new objects, a new cross-reference section, a new trailer and a new startxref after the original %%EOF. The %PDF-raster-1.0 line is still in the file, but it now sits above the second-to-last startxref, and the standard is unambiguous about which one counts. Expected: IsLoadedPDFR1 returns False after the edit, unless the editing tool re-emits the comment in its own update. A detector that scans the whole file for %PDF-raster- would say yes, and by the rule in clause 5 that answer is wrong. A capture workflow that must keep its files PDF/R puts annotations in a sidecar file or in a tool that rewrites the whole file as PDF/R

File tails before and after an incremental update: the PDF/R comment stays but loses the last startxref anchor that HotPDF checks in Delphi
an editor that appends an update leaves the PDF/R comment above the second-to-last startxref, where the standard no longer counts it

Developer extensions: integer levels, names and arrays

PDF 2.0 documents announce ISO technical specifications they rely on (SHA-3 hashes from ISO/TS 32001, ECC signatures from ISO/TS 32002, glTF assets from ISO/TS 32007) through the catalog's /Extensions dictionary, defined in ISO 32000-2 §7.12. Under the registered prefix /ISO_, Table 48 allows either a single developer extensions dictionary or, new in PDF 2.0, an array of them, and Table 49 together with §7.12.5 makes /ExtensionLevel an integer. A reader that handles only one of the two container shapes misses every file written the other way. HasLoadedISODeveloperExtension and GetLoadedISODeveloperExtensionLevels accept both, and resolve indirect references along the way, although §7.12.1 says these dictionaries shall be direct objects:

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);
  // Expected for a file written with 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;

The first edition of ISO/TS 32007:2024 has an example in §4.8 that writes /ExtensionLevel /32007 as a name; PDF Errata Issue #835 corrected it to the integer 32007, but a producer that copied the original example still writes the name. HotPDF's reader therefore falls back to parsing a name or string value, with or without the leading slash, when the integer lookup comes back empty, so HasLoadedISODeveloperExtension(32007) finds both spellings. The other erratum concerns /URL: Table 49 in ISO 32000-2:2020 lists /URL as optional, and Issue #732 promotes it to required. On the writing side HotPDF emits the integer form and a /URL in every EnsureISO…Extensions call; on the reading side an empty URL out-parameter is normal for files written to the 2020 text, so treat it as information and never as a reason to reject the extension

Which WTPDF URI did the producer write?

WTPDF 1.0 and ISO/TS 32005 claims are PDF Declarations: entries in a pdfd:declarations bag in XMP, under the namespace http://pdfa.org/declarations/ defined by the PDF Association's PDF Declarations technical note. Each entry carries a conformsTo URI and optional claimBy, claimDate, claimCredentials and claimReport fields, which GetLoadedPDFDeclarations returns as an array of THPDFDeclarationEntry records; on the writing side, AddPDFDeclaration puts the same five fields into the XMP packet, next to the PDF 2.0, PDF/A-4 and PDF/UA-2 identification namespaces. Cutting the bag into entries with a plain text search has its own snag: split at every occurrence of conformsTo and the closing tag </pdfd:conformsTo> of the child-element form counts as the start of a new entry, which truncates the first declaration before its own end. The HotPDF reader steps over closing tags and accepts both serializations

The URIs themselves are the harder part. The same claim reaches you as http:// or https://, and WTPDF 1.0 Issue #395 changed whether a / sits before the #, so files written before and after the erratum differ in that one character; a plain string comparison treats them as different standards. HasLoadedPDFDeclaration compares URIs case-insensitively, folds https:// onto http:// and drops a / directly before # on both sides, so the query below matches whichever spelling the producer chose. The fragment stays intact: #accessibility1.0 and #accessibility1.0-validated are different claims, the second asserting that a validator checked the file, and you should ask for the one your workflow cares about. GetLoadedWTPDFVersion collapses both into 'WTPDF 1.0 Accessibility' or 'WTPDF 1.0 Reuse' for routing

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);

  // Matches http or https, with or without the slash before '#'
  if Pdf.HasLoadedPDFDeclaration(
    'https://pdfa.org/declarations/wtpdf#accessibility1.0') then
    Writeln('WTPDF 1.0 Accessibility (unvalidated claim)');

  // Covers both the plain and the -validated variants of Reuse and Accessibility
  Writeln('WTPDF profile: ', Pdf.GetLoadedWTPDFVersion);
end;

For a capture or print router, the order of operations matters more than any single accessor: test IsLoadedPDFR1 on the file as it arrived, before an annotation or stamping step appends its update, and route PDF/X on GetLoadedPDFXVersion, which covers the Info dictionary of PDF/X-1a and PDF/X-3 jobs and the XMP of PDF/X-4 jobs alike, reading /GTS_PDFXConformance only where X-1a must be told apart from X-1. Both checks run on any document loaded into the HotPDF Delphi PDF component, including encrypted files opened with their password