A PDF/A-3 checklist usually says the XMP packet must carry two identification entries, pdfaid:part and pdfaid:conformance. A correctly written PDF/A-4 file carries pdfaid:part 4 and pdfaid:rev 2020, and for the base profile there is no conformance entry at all. Adding a letter to "fix" it makes the file wrong: ISO 19005-4 identifies the base part by number and revision year, and only the two extensions keep a letter
PDF/UA-2 has a similar trap one level down, inside the structure tree. Declaring a custom role in its own namespace looks like the whole job, since namespaces are the feature part 2 added. It is half of it. A role such as WidgetGroup in https://example.com/ns/widgets still has to map to a standard role before a conforming checker accepts it, and nothing in the rendered page hints that the mapping is missing
Selecting PDF/A-4 with PDFACompliance
In HotPDF the PDF/A-4 profiles are three values of the existing PDFACompliance property: '4' for the base part, '4E' for engineering documents and '4F' for files that carry embedded files. With '4' selected, the XMP packet gets pdfaid:part 4 and pdfaid:rev 2020 and nothing else from the pdfaid namespace. '4E' and '4F' add pdfaid:conformance with E or F. The decoder functions report the same split: PDFAPart returns 4 for all three values, and PDFAConformanceLevel returns E, F, or #0 for the base profile
All three force a PDF 2.0 header. The upgrade happens inside EndDoc, so a document that started at PDF 1.4 comes out as 2.0 without a separate call. The one case that stops it is StrictVersionLock. That flag forbids automatic upgrades, so EndDoc raises "PDF/A-4 requires PDF 2.0; StrictVersionLock prevents the version upgrade" rather than writing a 1.7 file that claims part 4 in its metadata. A codebase that pins the version for an old print workflow will see exactly that message the first time someone flips the profile
Two PDF/A-4 rules change what lands in the trailer and the Catalog. ISO 19005-4 §6.1.3 permits a document information dictionary only when the Catalog has a PieceInfo entry, and even then limits it to ModDate, so HotPDF writes no Info dictionary for part 4 and the title travels as dc:title in XMP. Title and Lang are still mandatory. Leave either empty and EndDoc raises. It raises as well when no output intent was added with AddPDFAOutputIntent, and EnableEncrypt refuses to run while any PDF/A profile is selected, because an encrypted archival file is a contradiction the standard does not allow
Associated files under PDF/A-4f and 4e
Associated files are the main reason to pick PDF/A-4f: an XML invoice behind the rendered page, a JSON payload that a downstream system parses. AddPDFAssociatedFile takes a file name, a MIME type, a description, an AFRelationship value and the bytes, and returns the indirect FileSpec dictionary so you can attach optional keys of your own
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'; // written to XMP dc:title only
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 is your own function returning TBytes
Pdf.AddPDFAssociatedFile('invoice.xml', 'text/xml',
'Structured invoice data', 'Data', LoadInvoiceBytes);
Pdf.EndDoc;
finally
Icc.Free;
Pdf.Free;
end;
end;
Each call does four things. It writes an EmbeddedFile stream whose /Subtype holds the MIME type as a PDF name, with a /Params dictionary carrying the size, a modification date and an MD5 checksum. It builds a FileSpec with both /F and /UF, the description as /Desc, and the relationship name. Then it registers that FileSpec twice, in the Catalog /AF array and in the EmbeddedFiles name tree. Both registrations are required. A file registered in only one place may still open from a viewer's attachment list, which is why a visual spot check proves very little about an archive
The relationship string must be one of Source, Data, Alternative, Supplement or Unspecified; anything else raises before a byte is written, and so does an empty MIME type. The profile is checked too. AddPDFAssociatedFile accepts PDF/A-3, PDF/A-4e and PDF/A-4f, and the base '4' profile raises because it does not admit associated files. In the opposite direction, AddDocumentAttachment refuses to run under PDF/A-3, 4e and 4f, since a plain attachment lacks the /AF registration those profiles need. Code written for PDF/A-3 against AddPDFA3AssociatedFile keeps compiling and behaves the same; AddPDFAssociatedFile is the profile-neutral name for that method
The two extensions split the remaining permissions between them. Under '4F', EndDoc makes sure the Catalog carries an EmbeddedFiles name tree, and AddFileAttachmentAnnotation is accepted, while the base profile and 4e reject it. Under '4E', Add3DAnnotation and AddRichMediaAnnotation are permitted, along with SetOCGState and GoTo3DView actions. Every other PDF/A profile refuses all four
Why does a namespaced custom role still fail PDF/UA-2?
A custom role passes PDF/UA-2 only when two conditions hold: its /NS entry points at a namespace dictionary listed in the structure tree root, and the role resolves through that namespace's RoleMapNS to a role in a standard namespace. HotPDF spreads the job across three methods. RegisterStructureNamespace creates an indirect /Type /Namespace dictionary, adds it to StructTreeRoot /Namespaces and returns it; a second call with the same URI returns the existing dictionary instead of adding another. AddStructureElementNS creates the element and sets its /NS, registering the namespace on the way. AddStructRoleMapNS writes the mapping
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 requires PDF 2.0
Pdf.Title := 'Widget catalog'; // empty Title raises at EndDoc
Pdf.Lang := 'en-US';
Pdf.BeginDoc;
DocElem := Pdf.AddStructureElement('Document', nil); // bound to PDF2NS
Group := Pdf.AddStructureElementNS('WidgetGroup', WidgetNS, DocElem);
// without this mapping EndDoc raises 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); // page index 0, MCID 0
Pdf.EndDoc;
end;
Some of that listing happens without being asked for. With PDFUAPart set to 2, AddStructureElement('Document', nil) binds the element to the PDF 2.0 standard namespace, http://iso.org/pdf2/ssn, because the root of a PDF/UA-2 tree has to be a Document in that namespace. The same binding happens for every role in the PDF 2.0 vocabulary, so H1 and P need no namespace argument. Roles that exist only in the PDF 1.7 set, such as Note, Code or BlockQuote, stay in the 1.7 namespace
The mapping target is a real choice. Passing an empty target namespace to AddStructRoleMapNS writes the short form, a bare role name resolved against the PDF 1.7 set. Passing the PDF 2.0 URI writes the two-item [role namespace] form. In a PDF/UA-2 document, map to the PDF 2.0 namespace: the whole tree then resolves into one vocabulary, and targets such as Aside or FENote, which have no 1.7 counterpart, become available. Two mapping shapes are rejected outright at EndDoc, a chain that loops back on itself and a role in an explicit namespace mapped to another role in that same namespace
Call order is looser than it looks. The mapping can be added before or after the element exists, because nothing checks it until the tree is finished. What does fail immediately is AddStructureElementNS while PDFUAPart is 1. Part 1 has no namespaces, and the call raises "structure namespaces require PDFUAPart=2" on the spot
What EndDoc checks before the file is written
With PDFUACompliance on and PDFUAPart at 2, EndDoc runs a complete PDF/UA-2 structure validation before serializing anything, and the first error becomes an exception. Four of its checks guard the skeleton of the tree. The root must hold exactly one top-level Document element. Every namespace dictionary must be indirect, typed Namespace, and carry a non-empty URI that appears only once. Every /NS reference must resolve to a dictionary listed in the root /Namespaces array. And every role must resolve to a standard namespace, either directly or through RoleMap and RoleMapNS. Elements that cannot be reached from the Document element are reported too
The same pass covers document-level requirements: a non-empty, valid Lang; MarkInfo /Marked set to true; DisplayDocTitle on; and an XMP packet with a non-empty dc:title and pdfuaid:part 2. HotPDF sets the marked flag, the viewer preference and the XMP identification itself once PDF/UA is on, and writes pdfuaid:rev 2024 next to the part number. Title and language are yours to supply, and an empty value for either raises before the validator even starts. Headings, lists, tables, annotations and forms are checked in the same run
Waiting until EndDoc is deliberate. Halfway through generation a tree is legitimately incomplete: the Document element may have no children yet, a namespace may be registered before any element uses it, a mapping may arrive after its element. Checking on every call would reject those states. At EndDoc the tree is finished and still in memory, and the exception names a rule code and the path of the element that broke it
try
Pdf.EndDoc;
except
on E: Exception do
begin
// E.Message has the shape
// PDF/UA-2 validation failed [ua2.heading.skipped at ...]:
// Heading level skips from H2 to H4
LogBuildFailure(Pdf.FileName, E.Message); // your own logger
raise;
end;
end;
PDF/A-4 output gets the same treatment from its own validator. When PDFACompliance holds a part 4 value, EndDoc checks the generated graph against ISO 19005-4 and raises with the rule code and the clause, for example pdfa4.filespec.relationship for a FileSpec without a valid relationship or pdfa4.trailer.info_entries for a stray Info key. The Enterprise edition exposes both validators as ValidatePDFUA2Structure and ValidatePDFA4, which fill an issue array with every retained diagnostic instead of stopping at the first error. For a CI step that should print a complete list, that is the better entry point
PDF 2.0 roles and headings past H6
The typed THPDFStandardStructureType enum gains sstDocumentFragment, sstAside, sstTitle, sstFENote, sstSub, sstEm, sstStrong and sstArtifact. Three of them change how ordinary business documents are tagged. Aside gives sidebars and pull quotes a home that is not a misused Sect. FENote marks footnotes and endnotes as notes, so a screen reader can offer them on request instead of reading them out mid-sentence. Em and Strong carry emphasis as structure, where a PDF 1.7 tree could only wrap it in a Span
Headings are open-ended in PDF 2.0. The enum stops at sstH6, but the string overload of AddStructureElement accepts H7, H8 and beyond, because the PDF 2.0 set defines Hn for any n from 1 up. PDF 1.7 stopped at H6, which forced deep technical documents to flatten their outline or reuse levels. For standards documents, legal codes and parts catalogs, that alone can justify moving output to PDF 2.0
Deeper headings come with a stricter rule. The PDF/UA-2 pass inside EndDoc requires heading levels to be sequential: the first heading must be H1, and a jump from H2 to H4 raises with ua2.heading.skipped. A hierarchy may start lower only inside a DocumentFragment, the role meant for content excerpted from a larger document. A generator that maps a Markdown or HTML outline straight onto Hn roles inherits every level the author skipped for visual size, so normalize the outline before tagging it
Before switching production output to PDF 2.0
PDF 2.0 begins with a header change, and the header is what older consumers read first. An ingest service, print workflow or line-of-business viewer that only accepts up to PDF 1.7 will stop at %PDF-2.0 no matter how clean the rest of the file is. Selecting any PDF/A-4 profile or PDF/UA part 2 selects PDF 2.0 whether you asked for it or not, so confirm each consuming system before the switch, not after it
The lowest-risk rollout keeps PDF/A-3 for documents that go out to readers you do not control, uses PDF/A-4f for internal archives where you own the ingest side, and adopts PDF/UA-2 where an accessibility policy names it. StrictVersionLock doubles as a guard during the transition: set Version to pdf17 and the lock in the code paths that must stay at 1.7, and an accidental PDF/A-4 selection fails at EndDoc with a message instead of reaching a system that cannot open the file. The guides to PDF/A, PDF/X and PDF/UA validation and to ZUGFeRD and Factur-X hybrid invoices on PDF/A-3 cover the profile decisions that come before the version number, and the notes on automated preflight reporting show how to turn the verdict into a build step
All of this runs as native VCL code inside the component for Delphi and C++Builder, so PDF/A-4 and PDF/UA-2 output ship with your executable and need no external engine. Supported profiles and RAD Studio versions are listed on the HotPDF Delphi component page