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. HotPDF reads these claims from a loaded document through accessors such as IsLoadedPDFUA, GetLoadedPDFR1Version and GetLoadedPDFDeclarations, each of which looks wherever 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 further 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:
| Standard | Where the claim lives | HotPDF accessor |
|---|---|---|
| PDF/A (ISO 19005) | XMP pdfaid:part and pdfaid:conformance, plus pdfaid:rev in PDF/A-4 | IsLoadedPDFA, GetLoadedPDFAVersion, GetLoadedPDFARevision |
| PDF/X (ISO 15930) | GTS_PDFXVersion in the Info dictionary for PDF/X-1a and PDF/X-3, in XMP for later parts | IsLoadedPDFX, GetLoadedPDFXVersion |
| PDF/UA (ISO 14289) | XMP pdfuaid:part | IsLoadedPDFUA, GetLoadedPDFUAVersion |
| PDF/VT (ISO 16612-2) | XMP pdfvtid:GTS_PDFVTVersion | IsLoadedPDFVT, GetLoadedPDFVTVersion |
| PDF/E (ISO 24517-1) | ISO_PDFEVersion, read from the Info dictionary, the catalog or XMP | IsLoadedPDFE, GetLoadedPDFEVersion |
| PDF/R-1 (ISO 23504-1) | %PDF-raster-x.y comment directly above the last startxref | IsLoadedPDFR1, GetLoadedPDFR1Version |
| PDF/VCR-1 (ISO 16613-1) | GTS_PDFVCRVersion in the catalog or XMP | IsLoadedPDFVCR, GetLoadedPDFVCRVersion |
| WTPDF 1.0, ISO/TS 32005 | pdfd:declarations entries in XMP | IsLoadedWTPDF, GetLoadedWTPDFVersion, IsLoadedISO32005 |
| PDF 2.0 developer extensions | /Extensions /ISO_ in the catalog | HasLoadedISODeveloperExtension, 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
What a valid XMP claim looks like
XMP is RDF serialised 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, whilst 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 serialisations, the prefix resolved from its xmlns declaration, and whitespace allowed around the equals sign
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 a PDF/R file loses 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
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 the producer wrote
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 serialisations
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