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

/

/

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: `, which an empirical // probe (get_drawings() on a smoke render) confirmed paints a rect starting // at the box's own top edge with the declared height (+/- ~1.2pt from the // residual near-zero line box — comfortably inside every ST1/ST2 tolerance // used below). $bandFirstPt = SampleTemplate::mm(SampleTemplate::BAND_FIRST_MM); $bandContPt = SampleTemplate::mm(SampleTemplate::BAND_CONTINUATION_MM); $logoSizePt = SampleTemplate::mm(SampleTemplate::MASTHEAD_LOGO_MM); $logoTopPt = SampleTemplate::mm(SampleTemplate::MARGIN_TOP_MM); $leftPt = SampleTemplate::mm(SampleTemplate::MARGIN_LEFT_MM); $rightPt = SampleTemplate::mm(SampleTemplate::MARGIN_RIGHT_MM); $prism = PrintPalette::PRISM_900; $muted = PrintPalette::MUTED; $ink = PrintPalette::INK; $fillBar = static fn (float $heightPt, string $hex, string $extra = ''): string => \sprintf( '', $hex, $heightPt, $extra, ); $cells = $spec->specTableCells(); // Inline mono style for text inside table cells: the engine does not apply // class rules to elements nested in table cells (sharedCss() engine notes), // so .np-mono2 cannot be used there — the same JetBrainsMono face is stated // inline instead. $monoCell = "font-family: 'JetBrainsMono'; font-size: 8pt;"; // ---- Page 1 ------------------------------------------------------------- $specTableRows = ''; foreach ($cells['labels'] as $i => $label) { $specTableRows .= '' . \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}

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.

Why Structure Matters

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.

What NextPDF Emits for This Page

  • Headings (H1H3) with no level skipped, so the outline a screen reader announces matches the one printed here.
  • Paragraphs (P) carrying the reading order shown on the page.
  • This list itself, as L with LI children — announced as a list of three items, not three stray sentences.
  • Data tables with real header cells (TH), demonstrated by the spec panel above and the page-2 checklist.

Who This Helps

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.

What This File Declares

Beyond the visible content, enableTaggedPdf() writes the machine-readable conformance markers a validator inspects first — each row below is present in this very file:

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)
HTML; $css = << p { font-weight: normal; color: {$ink}; } li { font-weight: normal; color: {$ink}; } .np-mono2 { font-family: 'JetBrainsMono'; font-size: 8pt; } CSS; // imageSvg() and SvgParser each flip the Y axis, so preserve the drawing // contract used by SampleTemplate::masthead(): pass pageHeight - desiredTop. // The Figure wrapper gives the complete vector mark one MCID and /Alt entry; // the layout-only HTML spacer remains an Artifact in the subsequent flow. $doc->beginTag('Figure', altText: 'NextPDF logo'); try { $doc->imageSvg( SampleTemplate::printMarkPath(), $leftPt, $doc->getPageHeight() - $logoTopPt, $logoSizePt, $logoSizePt, ); } finally { $doc->endTag(); } $doc->writeHtml(SampleTemplate::sharedCss() . $css . $page1); writeFooter($doc, 1, 2); // ---- Page 2 (continuation) ---------------------------------------------- $doc->addPage(); $checklistRows = [ ['Document title & language', 'Catalog /Lang and the Info dictionary Title resolve to a real, validated BCP 47 tag', 'Met'], ['Logical structure tree', 'Every heading, paragraph, list, and table maps to a StructElem with a stable MCID', 'Met'], ['Heading order', 'H1 precedes H2 precedes H3 on page 1, with no level skipped', 'Met'], ['Data table headers', 'Column headers are real TH structure elements, not styled TD cells', 'Met'], ['Reading order', 'The content stream is ordered exactly as an assistive technology will announce it', 'Met'], ['Image alternative text', 'Every meaningful image carries a text alternative', 'Met — the NextPDF logo is a Figure with /Alt'], ]; $checklistBody = ''; foreach ($checklistRows as $i => $row) { $rowClass = $i % 2 === 1 ? ' class="np-zebra"' : ''; $checklistBody .= "" . '' . \htmlspecialchars($row[0], ENT_QUOTES) . '' . '' . $row[1] . '' . '' . $row[2] . '' . ''; } $band2 = $fillBar($bandContPt, $prism); $page2 = <<

PDF/UA-2 Conformance Checklist

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.

{$checklistBody}
Criterion Requirement Status

Verifying These Claims Yourself

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:

  • verapdf --flavour ua2 accessible-report.pdf — the open-source veraPDF reference implementation of the ISO 14289-2 Machine-Checkable Requirements.
  • The PDF Association's PAC tool, which walks the same structure tree visually, node by node.
  • Adobe Acrobat's built-in Accessibility Checker, or any screen reader's own reading-order inspector.

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.

HTML; $doc->writeHtml(SampleTemplate::sharedCss() . $css . $page2); writeFooter($doc, 2, 2); $output = \getenv('NEXTPDF_SAMPLE_OUTPUT') ?: __DIR__ . '/../output/accessible-report.pdf'; $doc->save($output); echo "Created: {$output}\n"; /** * Paint the footer as real body content: three Document::text() calls, * which HasTextOutput::text() auto-wraps in a fresh

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