StructElem when tagging is * active (HasTextOutput::text(), confirmed by reading the method body). * The genuinely real content — headings, prose, lists, and the two data * tables — flows through writeHtml() so the HTML→StructureTree pipeline * tags it properly (H1 > H2 > H3, L/LI, TABLE/TR/TH/TD). * * CURRENT IMAGE-TAGGING PATH: drawing calls remain intentionally independent * of document semantics, but callers can now wrap imageSvg() in a tagged * Figure through Document::beginTag()/endTag(). That public path allocates the * Figure MCID through StructureTree, encloses every SVG painting operator in * the Figure's marked-content span, and serializes the supplied altText as * /Alt. The page-1 mark below uses the same Figure contract as the tagged * barcode integration sample; its aria-hidden HTML spacer controls layout * only and paints no substitute shape. * * @see NextPDF\Samples\SampleTemplate * @see NextPDF\Core\Concerns\HasSecurity::enableTaggedPdf() * @see NextPDF\Core\Concerns\HasPages (header/footer suppression under tagging) * @see NextPDF\Accessibility\TaggedContentEmitter::openTag() (aria-hidden routing) * @see NextPDF\Core\Concerns\HasTextOutput::text() (auto
wrapping)
* @see NextPDF\Core\Concerns\HasSecurity::beginTag() (tagged Figure wrapper)
*/
require __DIR__ . '/../lib/bootstrap.php';
use NextPDF\Samples\Edition;
use NextPDF\Samples\PrintPalette;
use NextPDF\Samples\SampleSpec;
use NextPDF\Samples\SampleTemplate;
use NextPDF\Samples\Typography;
use NextPDF\ValueObjects\Margin;
$spec = new SampleSpec(
id: 'accessible-report',
title: 'Accessible Tagged Report',
description: 'A screen-reader-navigable report demonstrating NextPDF\'s PDF/UA-2 (ISO 14289-2:2024) tagged-PDF pipeline: a logical H1-H2-H3 heading tree, semantic lists, and header-cell data tables.',
capability: 'Tagged PDF / structure tree',
edition: Edition::Core,
standards: ['ISO 32000-2', 'ISO 14289-2 (PDF/UA-2)'],
);
$template = new SampleTemplate($spec);
$doc = SampleTemplate::createDocument($spec);
// enableTaggedPdf() MUST run before apply()/addPage(): the HTML pipeline
// only wires the TaggedContentEmitter when a StructureTree root already
// exists at writeHtml() time.
$doc->enableTaggedPdf('en');
$doc->setLanguage('en');
$template->apply($doc);
// This sample's template VARIANT ("body-furniture"): the band needs to reach
// the physical page edges as real body content (the template's stock 26mm
// top margin, and its left/right margins, exist to clear a header CALLBACK
// band that never fires once tagged — see file docblock). ALL four margins
// are pulled to 0: an empirically confirmed engine constraint is that a
// block's CSS width/position is clamped to the page's margin box regardless
// of an explicit `width`/negative `margin`, so true full-bleed decoration is
// only reachable with zero document margins. Every real (non-decorative)
// element is then wrapped in a `padding-left/right` container below that
// reproduces the template's usual 15mm reading margin — bottom stays at the
// template's own auto-page-break constant for footer clearance.
$doc->setMargins(new Margin(
top: 0.0,
right: 0.0,
bottom: SampleTemplate::mm(SampleTemplate::PAGE_BREAK_MM),
left: 0.0,
));
$doc->addPage();
// emphasis paints Inter-B; the real This report is generated with Document::enableTaggedPdf()
— NextPDF's opt-in path to ISO 14289-2:2024 (PDF/UA-2). Every heading, paragraph, list, and
table below is a real StructElem in the file's logical structure
tree, wired with stable MCIDs, so a screen reader, a refreshable braille display, or any other
assistive technology can traverse the document in its true reading order — independent of
the visual column, table, or page layout on screen. A page is not a grid of pixels to an assistive technology — it is a tree. Sighted readers
skim a page by its visual hierarchy: a bold headline, an indented list, a ruled table. A screen
reader has no visual hierarchy to skim — it has only the structure tree the file declares.
Without one, a PDF is, in practice, an opaque image of text. Tagged structure is what makes a PDF usable with a screen reader (JAWS, NVDA, VoiceOver), a
refreshable braille display, or a text-to-speech tool — and it is what lets a sighted reader
reflow the same content to a phone-sized column without losing the outline. It is also, in most
jurisdictions that require it, the difference between a document that satisfies a legal
accessibility obligation (the EU's EN 301 549, the US Section 508, WCAG 2.2) and one that only
looks like it does. Beyond the visible content, enableTaggedPdf() writes the
machine-readable conformance markers a validator inspects first — each row below is
present in this very file: The table below is itself a demonstration: its header row is tagged with real
TH elements, so an assistive technology announces each column's
label — Criterion, Requirement, Status — before reading a row's value, rather than
leaving the reader to guess what a bare number or word refers to. A vendor's own checklist is a claim, not proof. Point an independent validator at the file
rather than taking this page's word for it: This sample is generated end to end by NextPDF Core — no post-processing accessibility
remediation pass was applied. What an independent validator sees in the structure tree is exactly
what Document::writeHtml() produced from the markup on this page. StructElem whenever
* tagging is active (confirmed by reading the method), so the brand mark,
* site URL, and page marker are real tagged content rather than untagged
* chrome. The total page count is a literal 2 (both pages() build with an
* explicit addPage() below, so this is a stated fact about the document
* being produced, not a guess) — the template's dynamic pageTotalPlaceholder()
* mechanism exists for the header/footer CALLBACK path, which is unreachable
* once tagging suppresses callbacks (see file docblock).
*/
function writeFooter(\NextPDF\Core\Document $doc, int $pageNumber, int $totalPages): void
{
$fontFamily = Typography::FAMILY_TEXT;
$size = Typography::SIZE_FOOTER;
$fontKey = Typography::fontKey($fontFamily);
$doc->setFont($fontFamily, '', $size);
$doc->setTextColor(...PrintPalette::rgb(PrintPalette::MUTED));
$pageWidth = $doc->getPageWidth();
$pageHeight = $doc->getPageHeight();
$left = \NextPDF\Samples\SampleTemplate::mm(\NextPDF\Samples\SampleTemplate::MARGIN_LEFT_MM);
$right = $pageWidth - \NextPDF\Samples\SampleTemplate::mm(\NextPDF\Samples\SampleTemplate::MARGIN_RIGHT_MM);
$y = $pageHeight - \NextPDF\Samples\SampleTemplate::mm(\NextPDF\Samples\SampleTemplate::FOOTER_BASELINE_Y_MM);
$metrics = $doc->fontMetrics();
$brand = \NextPDF\Samples\SampleTemplate::FOOTER_BRAND;
$doc->text($left, $y, $brand);
$url = \NextPDF\Samples\SampleTemplate::FOOTER_URL;
$urlWidth = $metrics->getStringWidth($url, $fontKey, $size);
$urlX = $left + (($right - $left) - $urlWidth) / 2;
$doc->text($urlX, $y, $url);
$marker = "{$pageNumber} / {$totalPages}";
$markerWidth = $metrics->getStringWidth($marker, $fontKey, $size);
$doc->text($right - $markerWidth, $y, $marker);
$doc->setTextColor(...PrintPalette::rgb(PrintPalette::INK));
$doc->setFont(Typography::FAMILY_TEXT, '', Typography::SIZE_BODY);
}
/
/
headings paint
// Barlow-B (sharedCss() maps heading elements to the Barlow family); inline
// `.np-mono2` spans (StructElem/H1/TH/etc. and the verapdf command line)
// paint JetBrainsMono regular. A face only referenced from CSS, never
// touched by setFont(), silently falls back to a substitute serif (FONT
// RULES), so all three are pre-registered here even though the masthead
// title in this sample is real markup, not a raw Document::text() call.
$template->registerFaces($doc, [['Inter', 'B'], ['Barlow', 'B'], ['JetBrainsMono', '']]);
// ---- Geometry shared by both pages (points) ---------------------------
//
// ENGINE CONSTRAINT (empirically confirmed, not fixed by editing lib/engine
// code): HtmlBlockHandler has no support for an explicit CSS `height` on a
// block — a declared `height` is silently ignored and the box shrinks to its
// content's line-box. Padding IS honoured, but a background paints only the
// box's content + padding-BOTTOM, not padding-TOP (its top edge sits at the
// content's own top, one padding-top's-worth below the box's outer edge).
// Every fixed-height decorative fill below is therefore built the same way:
// a near-zero content line (`font-size: 1pt; line-height: 0`) plus
// `padding-top: 0` and `padding-bottom:
'
. \strtoupper(\htmlspecialchars($label, ENT_QUOTES)) . ' ';
}
$specTableValues = '';
foreach ($cells['values'] as $value) {
$specTableValues .= ''
. \htmlspecialchars($value, ENT_QUOTES) . ' ';
}
$band1 = $fillBar($bandFirstPt, $prism);
// The template family's page-1 signature: a 1 mm amber rule directly below
// the prism band (SampleTemplate::BAND_RULE_MM). The fillBar residual line
// box adds ~1.2 pt, so the target height is pre-compensated to keep the
// painted rule at the template's true 1 mm weight (single amber accent,
// use 1 of 2 on this page).
$rule1 = $fillBar(
\max(0.0, SampleTemplate::mm(SampleTemplate::BAND_RULE_MM) - 1.2),
PrintPalette::ACCENT,
);
$logoSpacer = \sprintf(
'',
$logoSizePt,
$logoSizePt,
);
$page1 = <<
{$logoSpacer}
Accessible Tagged Report
{$specTableRows} {$specTableValues} Why Structure Matters
What NextPDF Emits for This Page
Who This Helps
What This File Declares
HTML;
$css = <<
Marker
Value in this file
What it tells a validator
/MarkInfo
/Marked true
The document claims a complete structure tree
/StructTreeRoot
present
Root of the logical structure an assistive technology walks
Catalog /Lang
en
Validated BCP 47 language for speech synthesis
XMP pdfuaid:part / :rev
2 / 2024
The file self-identifies as PDF/UA-2 (ISO 14289-2:2024)
"
. ' ';
}
$band2 = $fillBar($bandContPt, $prism);
$page2 = <<
' . \htmlspecialchars($row[0], ENT_QUOTES) . ' '
. '' . $row[1] . ' '
. '' . $row[2] . ' '
. 'PDF/UA-2 Conformance Checklist
{$checklistBody}
Criterion
Requirement
Status
Verifying These Claims Yourself