Technical Article

Tagged PDF Structure Trees in Delphi with PDFlibPas

Run a screen reader across a typical generated invoice and listen to what it finds: the grand total announced before the line items, the page footer interrupting a paragraph, the items table flattened into an undifferentiated stream of words. The page looks perfect and is semantically empty, because the content stream records drawing order, not meaning. PDF's answer is the structure tree defined in ISO 32000-1 §14.7 — a logical hierarchy of headings, paragraphs, tables, and figures laid over the painted content — and the economics are lopsided: emitting structure whilst generating costs minutes of code, whilst retrofitting it into finished documents is a remediation project. losLab PDF Library (PDFlibPas) exposes this machinery to Delphi and C++Builder applications through a small set of calls that wrap each drawing operation in its logical role.

UK teams should align this pdflibpas tagged pdf accessibility structure workflow with local governance, audit, and data quality requirements before production release

For UK environments, align the pdflibpas tagged pdf accessibility structure implementation with local quality gates that include governance approval, versioned fixture baselines, and evidence retention for every publication candidate. Keep an explicit review log for accessibility, redaction policy, and data residency checks so your deployment audit is repeatable without changing output binaries

UK technical governance addendum

How marked content binds to the structure tree

Two layers cooperate. In the content stream, drawing operations are bracketed into marked-content sequences, each carrying an integer MCID. In the document catalogueue, the structure tree maps those MCIDs into a hierarchy of typed elements — H1, P, Table, Figure — with attributes such as alternate text and language. Custom element types are legal but must resolve to standard roles through the role map (ISO 32000-1 §14.8.4), and content with no meaning at all — rules, backgrounds, repeated page furniture — is marked as an artefact so assistive technology skips it instead of reading it mid-sentence.

PDFlibPas maintains both layers behind one bracket pair: BeginTag opens a structure element and starts the marked-content sequence, drawing calls land within it, and EndTag closes both. The bookkeeping — MCIDs, the parent tree, page references — happens internally, which removes the most error-prone part of hand-rolled tagging.

Two document-level switches frame the work before any tag opens. SetMarkInfo writes the catalogueue flag that declares the document tagged, and IsTaggedPDF reads it back — the cheap first probe when deciding whether an inbound file has any structure worth preserving. For language, SetDocumentLanguage sets the document default on its own, whilst SetPDFUAMode sets it as part of enabling full PDF/UA output; a file can be usefully tagged without claiming PDF/UA conformance, and a phased rollout often starts exactly there.

Tagging whilst drawing, not afterwards

The generation pattern that works is to treat the tag bracket as part of every draw call's signature, never as a later pass:

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // top-left origin
    Lib.SetPDFUAMode('en-US');                 // bumps the save version to PDF 1.7
    Lib.SetInformation(1, 'Service Manual');   // /Title is mandatory for PDF/UA
    Lib.AddRoleMap('ManualTitle', 'H1');       // custom type -> standard role
    Lib.AddStandardFont(4);
    Lib.SetTextSize(18);
    Lib.BeginTagEx2('ManualTitle', '', '', 'en-US', '', 'h1-cover', '');
    Lib.DrawText(72, 96, 'Service Manual');
    Lib.EndTag;
    Lib.BeginTag('Figure', 'Exploded view of the gearbox assembly', '');
    Lib.AddImageFromFile('gearbox.png', 0);
    Lib.EndTag;
    Lib.BeginArtifact('Layout');               // page decoration: excluded from reading
    // ... draw rules and background tint ...
    Lib.EndArtifact;
    Lib.SaveToFile('manual.pdf');
  finally
    Lib.Free;
  end;
end;

Three calls in that sequence carry compliance weight. SetPDFUAMode enables PDF/UA output and silently bumps the document version to PDF 1.7 — which collides with version pinning: a document locked to PDF 1.4 with LockSaveVersion refuses to save with error code 602 once UA mode is active, a combination that surfaces when archival profiles and accessibility requirements are configured by different teams. SetInformation(1, ...) writes the document title, which ISO 14289 expects viewers to display instead of the filename; its absence is one of the most common PDF/UA findings in the wild. And AddRoleMap registers the custom ManualTitle type as an H1 — skip it, and the diagnostics described below flag the unmapped role.

Heading discipline deserves a deliberate policy instead of ad-hoc levels. Screen-reader users navigate by heading shortcuts, so a template that jumps from H1 to H3 because the intermediate level looked too large in the visual design breaks navigation in a way no visual review catches — and it is precisely the defect the HEADING-LEVEL-SKIP diagnostic exists to name. Mapping every template's visual styles to a fixed heading ladder once, in one place, prevents the drift.

Tables a screen reader can actually navigate

Drawn grid lines mean nothing off screen. What screen readers navigate are structural relationships: which cells are headers, what each header governs, and how data cells bind to headers in irregular layouts. The structure-element attribute calls handle all three:

Lib.BeginTag('Table', '', '');
Lib.BeginTag('TR', '', '');
Lib.BeginTagEx2('TH', '', '', '', '', 'col-part', '');
Lib.SetStructElemScope('Column');          // valid only while this TH is open
Lib.DrawText(72, 120, 'Part');
Lib.EndTag;
Lib.BeginTagEx2('TH', '', '', '', '', 'col-torque', '');
Lib.SetStructElemScope('Column');
Lib.SetStructElemColSpan(2);               // header spans the value and unit columns
Lib.DrawText(200, 120, 'Tightening torque');
Lib.EndTag;
Lib.EndTag;
Lib.BeginTag('TR', '', '');
Lib.BeginTag('TD', '', '');
Lib.SetStructElemHeaders('col-part');      // explicit binding for irregular tables
Lib.DrawText(72, 140, 'M8 flange bolt');
Lib.EndTag;
Lib.EndTag;
Lib.EndTag; // Table

The ordering rule is strict and silently enforced: every SetStructElem* call applies to the currently open tag — between its BeginTag and EndTag — and returns 0 without raising anything when no tag is open or the attribute does not apply. A misplaced call simply vanishes. Wrapping the return values in assertions during development catches the drift early; in the field, a missing scope appears only when an accessibility audit runs a real screen reader across the table. The element IDs passed through BeginTagEx2 feed the ID tree (ISO 32000-1 §14.7.4), which is what makes the SetStructElemHeaders binding resolvable.

The same attribute family covers the remaining structures assistive technology relies on. SetStructElemListNumbering declares how list items are labeled, so a screen reader can announce position within the list instead of reciting bullet glyphs; SetStructElemBBox records the bounding box of figures and tables, which reflow views use to place content; SetStructElemActualText supplies replacement text for runs whose glyphs do not correspond to readable characters, such as drop caps assembled from vector art. Each follows the same rule: the call binds to the open tag, or it vanishes.

Artefacts, language, and the pre-save diagnostics gate

Repeated page furniture — running headers, fold marks, watermarks, background tints — belongs within BeginArtifact and EndArtifact brackets so it never enters the reading stream. Language is inheritable: the document default comes from the SetPDFUAMode argument, and runs in another language override it per element via BeginTagEx or SetStructElemLang, which is what keeps a French quotation within an English manual pronounceable.

Before saving, GetPDFUADiagnostics runs the library's structural checks over the in-memory document and returns findings as text — an empty string means nothing was found. The diagnostics name the classic authoring mistakes directly: FIGURE-NO-ALT for images without alternate text, HEADING-LEVEL-SKIP for an H3 following an H1, ROLEMAP-UNMAPPED for custom types never registered. Wiring this into the build — generate the document set, fail on non-empty diagnostics — turns accessibility regressions into compile-time-style failures instead of audit findings. The full conformance verdict still belongs to preflight on the saved file, covered in PDF/A and PDF/UA preflight in Delphi, since some normalisations are applied during serialisation.

Annotation navigation has its own knob. PDF/UA expects keyboard traversal of form fields and links to follow the structure order, and SetTabOrderMode writes the page-level tab-order entry that viewers honor, with GetTabOrderMode available for auditing inbound files. It is the kind of requirement nobody notices until a keyboard-only user files the bug, and it costs one call per document to get right.

Structure trees do not survive every merge

Tagged documents stay tagged only if every later processing step preserves the tree. Within PDFlibPas the sharp edge is the merge-list family: MergeFileListFast trades structure-tree preservation for speed, which is the right trade for scanned image batches and exactly the wrong one for tagged reports — the output opens fine, renders identically, and has lost its accessibility layer entirely. Use the default MergeFileList or the strict variant whenever any input is tagged, and make IsTaggedPDF part of the post-assembly assertions so a silently flattened batch cannot ship. Assembly pipelines for large document sets carry more trade-offs of this kind, explored in large PDF merge, split, and direct access.

The verification loop closes outside the library: open the output in Acrobat, inspect the tags panel, and read at least one document per template family with a real screen reader. Diagnostics catch structural mistakes; only a human ear catches a reading order that is technically valid and practically baffling. Evaluation builds and the complete tagging API reference are on the losLab PDF Library for Delphi product page.