<?php
declare(strict_types=1);
/**
* Sample: Accessible Tagged Report (NextPDF Core, PDF/UA-2).
*
* Rebuilt onto the shared SampleTemplate, but with a template variant this
* one sample family member needs: ISO 14289-2:2024 (PDF/UA-2) tagged PDF is
* enabled via Document::enableTaggedPdf() BEFORE apply()/addPage(). Once a
* structure tree exists, NextPDF\Core\Concerns\HasPages suppresses the
* template's header/footer CALLBACKS outright — raw content-stream chrome
* painted outside the structure tree would be untagged content, a PDF/UA-2
* §8.2.2 violation — so the band, brand mark, spec table, and footer are
* repainted here as real BODY content through paths the engine itself
* proves safe under tagging:
* - decorative bars/chips: a plain HTML block with `aria-hidden="true"`,
* which NextPDF\Accessibility\TaggedContentEmitter routes to a real
* `/Artifact` marked-content span (WAI-ARIA 1.2 §6.6.6 / ISO 14289-2 §7.8)
* — confirmed by reading HtmlToStructureMap::isAriaHidden() and its call
* site in TaggedContentEmitter::openTag().
* - the footer brand/URL/page-marker text: Document::text(), which
* auto-wraps its payload in a fresh <P> 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).
*
* KNOWN ENGINE GAP (reported, not worked around by editing lib/engine code):
* there is no path in this engine release that BOTH paints a real image AND
* carries a screen-reader alternative. Document::image()/imageSvg() paint
* pixels but expose no /Alt (image() is explicitly documented as always
* decorative-Artifact); the HTML `<img alt="...">` path tags a /Figure with
* /Alt correctly but Document::writeHtml() never wires an image-resource
* resolver for plain `<img>` (only `background-image`/`mask-image` get one —
* confirmed by reading HasTextOutput's bindHtmlParser*Resolver methods and by
* an empirical smoke render: an `<img>` with a valid relative src reserves
* layout space but paints no pixels). Shipping a `<Figure>` that is
* deliberately a blank box would misrepresent the sample, so this report
* demonstrates every OTHER accessibility feature (heading hierarchy, lists,
* header-cell tables) and is honest in its own checklist (page 2) that it
* carries no images.
*
* @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 <P> wrapping)
*/
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();
// <strong> emphasis paints Inter-B; the real <h1>/<h2>/<h3> 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: <target height>`, 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);
$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(
'<div aria-hidden="true" style="width: 100%%; background-color: %s; font-size: 1pt; '
. 'line-height: 0; padding-top: 0; padding-bottom: %.3Fpt; %s"> </div>',
$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 .= '<th style="text-align:left; color: ' . $muted . '; font-size: 6.5pt; '
. 'letter-spacing: 0.3pt; font-weight: normal; padding: 0 10pt 3pt 0; border-bottom: 0.6pt solid ' . PrintPalette::HAIRLINE . ';">'
. \strtoupper(\htmlspecialchars($label, ENT_QUOTES)) . '</th>';
}
$specTableValues = '';
foreach ($cells['values'] as $value) {
$specTableValues .= '<td style="color: ' . $ink . '; font-size: 8.5pt; padding: 4pt 10pt 0 0;">'
. \htmlspecialchars($value, ENT_QUOTES) . '</td>';
}
$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,
);
$logoChip = $fillBar($logoSizePt, $prism, 'width: ' . \sprintf('%.3F', $logoSizePt) . 'pt; margin-top: 24pt; border-radius: 3pt;');
$page1 = <<<HTML
{$band1}
{$rule1}
<div style="padding-left: {$leftPt}pt; padding-right: {$rightPt}pt;">
{$logoChip}
<h1 style="margin-top: 14pt;">Accessible Tagged Report</h1>
<table style="width: 100%; border-collapse: collapse; margin-top: 10pt;"><tr>{$specTableRows}</tr><tr>{$specTableValues}</tr></table>
<p style="margin-top: 18pt;">This report is generated with <strong>Document::enableTaggedPdf()</strong>
— NextPDF's opt-in path to ISO 14289-2:2024 (PDF/UA-2). Every heading, paragraph, list, and
table below is a real <span class="np-mono2">StructElem</span> 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.</p>
<h2>Why Structure Matters</h2>
<p>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.</p>
<h3>What NextPDF Emits for This Page</h3>
<ul>
<li>Headings (<span class="np-mono2">H1</span>–<span class="np-mono2">H3</span>) with no
level skipped, so the outline a screen reader announces matches the one printed here.</li>
<li>Paragraphs (<span class="np-mono2">P</span>) carrying the reading order shown on the page.</li>
<li>This list itself, as <span class="np-mono2">L</span> with <span class="np-mono2">LI</span>
children — announced as a list of three items, not three stray sentences.</li>
<li>Data tables with real header cells (<span class="np-mono2">TH</span>), demonstrated by
the spec panel above and the page-2 checklist.</li>
</ul>
<h2>Who This Helps</h2>
<p>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.</p>
<h2>What This File Declares</h2>
<p>Beyond the visible content, <span class="np-mono2">enableTaggedPdf()</span> writes the
machine-readable conformance markers a validator inspects first — each row below is
present in this very file:</p>
<table class="np-table" style="margin-top: 8pt;">
<thead>
<tr>
<th style="width: 30%;">Marker</th>
<th style="width: 26%;">Value in this file</th>
<th style="width: 44%;">What it tells a validator</th>
</tr>
</thead>
<tbody>
<tr>
<td><span style="{$monoCell}">/MarkInfo</span></td>
<td><span style="{$monoCell}">/Marked true</span></td>
<td>The document claims a complete structure tree</td>
</tr>
<tr class="np-zebra">
<td><span style="{$monoCell}">/StructTreeRoot</span></td>
<td>present</td>
<td>Root of the logical structure an assistive technology walks</td>
</tr>
<tr>
<td><span style="{$monoCell}">Catalog /Lang</span></td>
<td><span style="{$monoCell}">en</span></td>
<td>Validated BCP 47 language for speech synthesis</td>
</tr>
<tr class="np-zebra">
<td><span style="{$monoCell}">XMP pdfuaid:part / :rev</span></td>
<td><span style="{$monoCell}">2 / 2024</span></td>
<td>The file self-identifies as PDF/UA-2 (ISO 14289-2:2024)</td>
</tr>
</tbody>
</table>
</div>
HTML;
$css = <<<CSS
<style>
p { font-weight: normal; color: {$ink}; }
li { font-weight: normal; color: {$ink}; }
.np-mono2 { font-family: 'JetBrainsMono'; font-size: 8pt; }
</style>
CSS;
$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', 'N/A — no images in this report'],
];
$checklistBody = '';
foreach ($checklistRows as $i => $row) {
$rowClass = $i % 2 === 1 ? ' class="np-zebra"' : '';
$checklistBody .= "<tr{$rowClass}>"
. '<td>' . \htmlspecialchars($row[0], ENT_QUOTES) . '</td>'
. '<td>' . $row[1] . '</td>'
. '<td>' . $row[2] . '</td>'
. '</tr>';
}
$band2 = $fillBar($bandContPt, $prism);
$page2 = <<<HTML
{$band2}
<div style="padding-left: {$leftPt}pt; padding-right: {$rightPt}pt;">
<h2 style="margin-top: 24pt;">PDF/UA-2 Conformance Checklist</h2>
<p>The table below is itself a demonstration: its header row is tagged with real
<span class="np-mono2">TH</span> 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.</p>
<table class="np-table" style="margin-top: 10pt;">
<thead>
<tr>
<th style="width: 26%;">Criterion</th>
<th style="width: 54%;">Requirement</th>
<th style="width: 20%;">Status</th>
</tr>
</thead>
<tbody>
{$checklistBody}
</tbody>
</table>
<div aria-hidden="true" style="font-size: 1pt; line-height: 0; padding-top: 0; padding-bottom: 10pt;"> </div>
<h3>Verifying These Claims Yourself</h3>
<p>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:</p>
<ul>
<li><span class="np-mono2">verapdf --flavour ua2 accessible-report.pdf</span> — the
open-source veraPDF reference implementation of the ISO 14289-2 Machine-Checkable
Requirements.</li>
<li>The PDF Association's PAC tool, which walks the same structure tree visually, node by
node.</li>
<li>Adobe Acrobat's built-in Accessibility Checker, or any screen reader's own reading-order
inspector.</li>
</ul>
<p>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 <span class="np-mono2">Document::writeHtml()</span> produced from the markup on this page.</p>
</div>
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 <P> 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);
}