The complete script constructs a page whose logical structure is independent of its drawing operations. It combines PDF 2.0 roles, the table-of-contents types unique to PDF 1.7 and a custom paragraph. An embedded font, reciprocal ParentTree links and XMP metadata make these relationships inspectable in the actual file.

The preview shows five lines. Their positions do not establish their meaning: the script explicitly constructs the H1, P, TOC, TOCI and Reference elements that describe them.

Run the example

From a core 0.7.1 checkout with its locked development dependencies:

php examples/pdf20-tagged-namespaces.php namespaces.pdf --audit-json

The first argument selects the output PDF. --audit-json writes a separate report to standard error; it does not mix JSON into the PDF bytes. --xref-stream and --hybrid select a cross-reference stream and a hybrid section respectively. Without these options, the writer produces a classic cross-reference table.

The optional pdftoolkit/pdf-fonts module reads the demonstration Noto Sans font. The core receives normalized PDF objects: embedded program, descriptor, CID metrics, CID/GID mapping and ToUnicode CMap. Installing the core alone does not install this converter.

Qualify meaning with a namespace

In PDF 2.0, a type name alone does not always identify its semantics. StructureRole combines that name with a StructureNamespace object:

$pdf20 = new StructureNamespace(StandardStructureNamespace::PDF20);
$pdf17 = new StructureNamespace(StandardStructureNamespace::PDF17);
$document = new StructureElement(
    new StructureRole(new NameType('Document'), $pdf20),
);

Each element writes /S for its type and an indirect /NS reference to its namespace dictionary. StructureNamespaces supplies the document's canonical instances to StructureTreeRoot. Two objects carrying the same namespace identifier are not interchangeable in this model: a namespace set owns one instance for each identifier.

Types shared by PDF 1.7 and PDF 2.0 use the PDF 2.0 namespace in a mixed tree. Certain types, including TOC, TOCI and Reference, remain defined in the PDF 1.7 namespace. ISO/TS 32005 specifies their containment relationships with other elements. The script therefore keeps these namespaces distinct instead of renaming every type.

Define a custom role without a global registry

The demonstration namespace urn:pdftoolkit:example:report contains ReportParagraph. Its mapping explicitly targets P in the PDF 2.0 namespace:

$report = new StructureNamespace(
    'urn:pdftoolkit:example:report',
    new NamespaceRoleMap(new NamespaceRoleMapping(
        new NameType('ReportParagraph'),
        new StructureRole(new NameType('P'), $pdf20),
    )),
);

The /RoleMapNS dictionary contains a target of the form [/P namespace-reference]. A name alone would identify the default PDF 1.7 namespace, even when the mapping belongs to another namespace. Transitive resolution preserves this distinction, detects cycles and has an explicit operation budget.

The writer checks that used and targeted namespaces belong to the document's set. Effective-graph validation also checks generic dictionaries and frozen revision facts: constructing /NS entries manually does not bypass the PDF 2.0 minimum version.

Connect drawing operations to logical elements

Each line is enclosed by a marked-content sequence with an MCID. These numbers are local to the content container; they are not global structure-element identifiers. The page carries /StructParents 0, which identifies its ParentTree key.

PageMarkedContent connects each MCID to its StructureElement. StructureTreeRoot derives the reciprocal ParentTree: MCID 0 points to the heading, 1 to the custom paragraph, 2 to the table-of-contents reference, and 3 and 4 to the remaining paragraphs. The same tree provides /P links between parents and children.

The table of contents adds another relationship. Its Reference element has a /Ref array targeting H1. This structural reference does not create a clickable link area; the script constructs no Link annotation. addReference() also supports reciprocal citations between a citing element and an FENote when a document needs them.

Select inclusion rules and corrections

StructureInclusionRules applies all 53 rows of ISO/TS 32005 table 5 to resolved roles. It checks permitted children, occurrence bounds, inherited constraints through Part/Div/NonStruct and Ruby/Warichu assemblies. The exemption for a standalone PDF 1.7 tree remains distinct from the rules for a mixed tree.

The default Published2023 mode follows the supplied source. The example explicitly selects StructureInclusionCorrections::Resolved336And440: the PDF Association resolutions permit Sub inside Code and replace the undefined WP/Figure c cell with 0..n. This selection is not presented as a new ISO edition. Other errata discussions are not silently incorporated.

Separate declarations from verification

The XMP packet contains a dc:title with x-default and fr values. It also contains two explicit author declarations for this small example: ISO/TS 32005 and the WTPDF reuse level. A declaration is an assertion; its presence does not prove that assertion true.

The WTPDF identifiers supplied by the specification are exactly:

http://pdfa.org/declarations/wtpdf/#reuse1.0
http://pdfa.org/declarations/wtpdf/#accessibility1.0

The script then requests a separate audit:

$audit = (new WtPdfAudit())->inspect(
    $catalog,
    new EffectivePdfVersion($header, $catalog),
    WtPdfConformanceLevel::Reuse,
);
$audit->assertNoDetectedFailures();

The audit inserts no declarations. It distinguishes Failed, HumanReviewRequired and AdditionalValidationRequired. The example has no detected failures but retains pending reviews. assertNoDetectedFailures() therefore does not mean “certified accessible document”.

Mechanical checks cover namespaces, regular tables with spanning cells, certain list/note relationships, annotations, metadata, embedded-file descriptions and actual font usage. They distinguish rendered fonts, whose programs must be embedded, from fonts used exclusively in text rendering mode 3. They also check certain CID/GID and ToUnicode relationships.

Semantic type selection, suitable descriptions, intended reading order and form context require human review. Font programs, embedding rights, certain metrics, MathML grammar and other exhaustive rules need additional validation. The detailed coverage record separates these limits from executed checks. This partial audit is not WTPDF or PDF/UA-2 certification.

Verify generated bytes

tools/validate_pdf20_tagging.py inspects indirect references, custom mapping, ParentTree, MCIDs, Ref and XMP XML using pypdf and qpdf. It also checks every glyph shown in this example: GID presence, Unicode mapping and agreement between PDF widths and metrics in the actual embedded TrueType program. Poppler supplies independent text extraction. All three cross-reference formats are exercised.

You can change the text, introduce a second custom namespace or replace the table of contents with a list. Every change must preserve associations between operators, MCIDs, containers, parents and resolved types. The visible file and audit report are complementary results to inspect.

References

The retained bundle sources come from the PDF Association. Relevant physical pages: ISO 32000-2, 739–744 for dictionaries and namespaces, 809–810 for standard namespaces; ISO/TS 32005, 9–13 for mixed-namespace rules and 14–46 for the full table; WTPDF, 9–10 for conformance levels, 14–30 for structure and 32–49 for other constraints. The bundle catalogue records each original file's hash and identity.

Tutorial path · PDF 2.0 guide · Coverage

View the PDF result

This file was generated with the code in this documentation. All pages appear below; open the PDF to use its links and bookmarks.

PDF result pdf20-tagged-namespaces: page 1 of 1
Page 1 of 1
Use the browser PDF viewer

This viewer depends on your browser. If the document does not appear, use “Open the PDF” or “Download”.

Complete source code

Code from roukmoute/pdf · View the source at the documented commit ↗

Read the commented script: pdf20-tagged-namespaces.php
<?php

declare(strict_types=1);

use PDF\Document\Fonts\CIDFontType2;
use PDF\Document\Fonts\CIDToGIDMap;
use PDF\Document\Fonts\CIDWidths;
use PDF\Document\Fonts\FontDescriptorFlags;
use PDF\Document\Fonts\FontFile2;
use PDF\Document\Fonts\ToUnicodeCMap;
use PDF\Document\Fonts\Type0Font;
use PDF\Document\Metadata\Declarations\Declaration;
use PDF\Document\Metadata\Declarations\Declarations;
use PDF\Document\Metadata\MetadataEntry;
use PDF\Document\Metadata\MetadataStream;
use PDF\Document\Metadata\MetadataUri;
use PDF\Document\Metadata\XmlText;
use PDF\Document\Metadata\XmpLanguageAlternative;
use PDF\Document\Metadata\XmpLocalizedText;
use PDF\Document\Metadata\XmpName;
use PDF\Document\Metadata\XmpPacket;
use PDF\Document\ResourceDictionary;
use PDF\Document\Resources\Font;
use PDF\Document\Structure\MarkInformation;
use PDF\Document\Structure\MarkedContentContainer;
use PDF\Document\Structure\MarkedContentIdentifier;
use PDF\Document\Structure\NamespaceRoleMap;
use PDF\Document\Structure\NamespaceRoleMapping;
use PDF\Document\Structure\PageMarkedContent;
use PDF\Document\Structure\StandardStructureNamespace;
use PDF\Document\Structure\StructureElement;
use PDF\Document\Structure\StructureInclusionCorrections;
use PDF\Document\Structure\StructureInclusionRules;
use PDF\Document\Structure\StructureNamespace;
use PDF\Document\Structure\StructureNamespaces;
use PDF\Document\Structure\StructureRole;
use PDF\Document\Structure\StructureTreeRoot;
use PDF\Document\Structure\WtPdfAudit;
use PDF\Document\Structure\WtPdfConformanceLevel;
use PDF\FileStructure;
use PDF\Fonts\TrueType\CharacterMapFactory;
use PDF\Fonts\TrueType\CIDFontProgram;
use PDF\Fonts\TrueType\FontDescriptorFactory;
use PDF\Fonts\TrueType\FontProgram;
use PDF\Fonts\TrueType\TextEncoder;
use PDF\Fonts\TrueType\UnicodeText;
use PDF\Syntax\ContentStreamsResources\ContentStreams;
use PDF\Syntax\ContentStreamsResources\Operators as Op;
use PDF\Syntax\DataStructure\Rectangle;
use PDF\Syntax\DataStructure\TextString;
use PDF\Syntax\DocumentStructure\DocumentCatalog;
use PDF\Syntax\DocumentStructure\PageTree\PageObject;
use PDF\Syntax\DocumentStructure\PageTree\PageTreeNode;
use PDF\Syntax\FileStructure\EffectivePdfVersion;
use PDF\Syntax\FileStructure\CrossReferenceFormat;
use PDF\Syntax\FileStructure\FileBody;
use PDF\Syntax\FileStructure\FileHeader;
use PDF\Syntax\Filters\FlateDecode;
use PDF\Syntax\Object\BooleanType;
use PDF\Syntax\Object\DictionaryObject\Entry;
use PDF\Syntax\Object\DictionaryType;
use PDF\Syntax\Object\IndirectReference;
use PDF\Syntax\Object\IntegerType;
use PDF\Syntax\Object\NameType;
use PDF\Syntax\Object\NumericType;
use PDF\Syntax\Object\StreamObject;

require dirname(__DIR__) . '/vendor/autoload.php';

// PDF 2.0 identifies meaning by the pair (namespace, type), not by a bare name.
// TS 32005 keeps TOC/TOCI/Reference in PDF 1.7 while shared roles use PDF 2.0.
$pdf20 = new StructureNamespace(StandardStructureNamespace::PDF20);
$pdf17 = new StructureNamespace(StandardStructureNamespace::PDF17);
$report = new StructureNamespace('urn:pdftoolkit:example:report', new NamespaceRoleMap(
    new NamespaceRoleMapping(new NameType('ReportParagraph'), new StructureRole(new NameType('P'), $pdf20)),
));

$lines = [
    '1. Explicit structure namespaces',
    'A custom report paragraph resolves to the PDF 2.0 P role.',
    'Contents: explicit structure namespaces',
    'The table of contents uses TOC, TOCI and Reference from PDF 1.7.',
    'Mechanical checks support review; they do not certify accessibility.',
];
// Font conversion is an optional module. The core receives explicit normalized
// font program, CID widths, glyph mapping and ToUnicode PDF objects.
$fontBytes = file_get_contents(dirname(__DIR__) . '/vendor/pdftoolkit/pdf-fonts/examples/assets/fonts/NotoSans-Regular.ttf');
if ($fontBytes === false) {
    throw new RuntimeException('Unable to read the demonstration font.');
}
$program = new CIDFontProgram(new FontProgram($fontBytes));
$fontFile = new FontFile2($program->bytes, new FlateDecode());
$map = (new CharacterMapFactory($program))->create($fontFile, new UnicodeText(implode('', $lines)));
$encoder = new TextEncoder($map);
$descriptor = (new FontDescriptorFactory($program))->create($fontFile, new FontDescriptorFlags(4), new NumericType(80));
$font = new Type0Font(new CIDFontType2($descriptor, new CIDToGIDMap($map), new CIDWidths($map)), new ToUnicodeCMap($map));
$resources = new ResourceDictionary(new Font(new DictionaryType(new Entry(new NameType('F1'), new IndirectReference($font)))));
$operators = [];
foreach ($lines as $index => $line) {
    $identifier = new MarkedContentIdentifier(new IntegerType($index));
    $operators[] = $identifier->begin(new NameType($index === 0 ? 'H1' : 'P'));
    $operators[] = new Op\BeginTextObject();
    $operators[] = new Op\SetTextFontAndSize(new NameType('F1'), new IntegerType($index === 0 ? 19 : 11));
    $operators[] = new Op\MoveTextPosition(new IntegerType(40), new IntegerType(770 - 48 * $index));
    $operators[] = new Op\ShowText($encoder->encode(new UnicodeText($line)));
    $operators[] = new Op\EndTextObject();
    $operators[] = new Op\EndMarkedContent();
}
$page = new PageObject(new Rectangle(new IntegerType(0), new IntegerType(0), new IntegerType(595), new IntegerType(842)), $resources, new StreamObject(new ContentStreams(...$operators)), new Entry(new NameType('StructParents'), new IntegerType(0)));
$pages = (new PageTreeNode())->addKid($page);
$root = new StructureTreeRoot($pages, namespaces: new StructureNamespaces($pdf20, $pdf17, $report), inclusionRules: new StructureInclusionRules(StructureInclusionCorrections::Resolved336And440));
$document = new StructureElement(new StructureRole(new NameType('Document'), $pdf20));
$container = new MarkedContentContainer($page, new IntegerType(0));
$types = [new StructureRole(new NameType('H1'), $pdf20), new StructureRole(new NameType('ReportParagraph'), $report), new StructureRole(new NameType('Reference'), $pdf17), new StructureRole(new NameType('P'), $pdf20), new StructureRole(new NameType('P'), $pdf20)];
$elements = [];
foreach ($types as $index => $type) {
    $element = new StructureElement($type, $page);
    $element->addKid(new PageMarkedContent($container, new MarkedContentIdentifier(new IntegerType($index))));
    $elements[] = $element;
}
$toc = new StructureElement(new StructureRole(new NameType('TOC'), $pdf17));
$item = new StructureElement(new StructureRole(new NameType('TOCI'), $pdf17));
$elements[2]->addReference($elements[0]);
$item->addKid($elements[2]);
$toc->addKid($item);
$document->addKid($elements[0])->addKid($elements[1])->addKid($toc)->addKid($elements[3])->addKid($elements[4]);
$root->addKid($document);

// These are explicit author assertions for this small, manually reviewed sample.
// The audit below never inserts a declaration or turns a successful check into
// certification. Font program/metric and semantic reviews remain separate evidence.
$level = WtPdfConformanceLevel::Reuse;
$metadata = new MetadataStream(new XmpPacket(
    new XmpLanguageAlternative(new XmpName('dc', new MetadataUri('http://purl.org/dc/elements/1.1/'), 'title'), new XmlText('PDF 2.0: explicit structure namespaces'), new XmpLocalizedText('fr', new XmlText('PDF 2.0 : espaces de noms de structure explicites'))),
    new Declarations(new Declaration(new MetadataUri('https://pdfa.org/declarations#iso32005')), new Declaration(new MetadataUri($level->declarationIdentifier()))),
));
$catalog = new DocumentCatalog($pages, ...[...$root->catalogEntries(new MarkInformation(new BooleanType(true))), new MetadataEntry($metadata), new Entry(new NameType('Lang'), new TextString('en-US'))]);
$header = new FileHeader('2.0');
$audit = (new WtPdfAudit())->inspect($catalog, new EffectivePdfVersion($header, $catalog), $level);
$audit->assertNoDetectedFailures();

// stdout stays binary PDF; the optional --audit-json writes a separate review report.
if (in_array('--audit-json', $argv ?? [], true)) {
    fwrite(STDERR, json_encode(['detectedFailures' => count($audit->failures()), 'pendingReviews' => array_map(static fn ($finding): array => ['clause' => $finding->clause, 'page' => $finding->pdfPage, 'status' => $finding->status->name, 'message' => $finding->message], $audit->pending())], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) . PHP_EOL);
}
$format = in_array('--xref-stream', $argv ?? [], true) ? CrossReferenceFormat::Stream : (in_array('--hybrid', $argv ?? [], true) ? CrossReferenceFormat::Hybrid : CrossReferenceFormat::Table);
$file = new FileStructure($header, new FileBody($catalog), new IndirectReference($catalog), crossReferenceFormat: $format);
if (isset($argv[1]) && !str_starts_with($argv[1], '--')) {
    $file->writeTo($argv[1]);
} else {
    echo $file->toPdf();
}

Guide source at the documented commit ↗