PDF metadata and accessibility: what the EAA means for your documents

The European Accessibility Act takes effect in June 2025 and it covers PDFs. Here's what developers need to know about tagged PDFs, metadata, and PDF/UA compliance when generating documents programmatically.

pdfaccessibilityeaapdf-uacompliancenode.js

PDF metadata and accessibility: what the EAA means for your documents

If your service generates PDFs for European users — invoices, contracts, tickets, statements — the European Accessibility Act (EAA) is now your problem. It came into force on June 28, 2025, and it treats PDFs as part of the "service" you deliver. A screen-reader-hostile invoice is no longer just a UX issue; it's a compliance issue.

This post covers what actually changes for developers generating PDFs programmatically: metadata, tagging, PDF/UA, and how to check your output.

What the EAA actually requires

The EAA (Directive 2019/882) requires that consumer-facing digital services be "perceivable, operable, understandable, and robust" — the POUR principles from WCAG 2.1 AA. For PDFs, the practical reference standard is PDF/UA-1 (ISO 14289-1).

The scope covers banking, e-commerce, transport ticketing, e-books, and other consumer services. If you email an invoice to a customer in the EU, that PDF is in scope. Business-to-business documents are largely excluded, but "consumer" is interpreted broadly.

Non-compliance penalties vary by member state but range from fines to service suspension.

What makes a PDF accessible

A compliant PDF needs four things:

  1. A logical structure tree (tags) so assistive tech can read content in the right order.
  2. Correct metadata — title, language, and PDF/UA identifier in XMP.
  3. Semantic markup — headings tagged as <H1>, tables with header cells, lists as lists, alt text on images.
  4. A marked-content stream where every piece of visible content is either tagged or explicitly marked as an artifact (page numbers, decorative lines, backgrounds).

If you're generating PDFs by drawing directly to a canvas (pdf-lib, PDFKit, jsPDF), you likely have none of these. Those libraries produce visually correct but structurally empty PDFs.

The metadata layer

Metadata lives in two places in a PDF: the Document Info dictionary (legacy) and the XMP stream (modern). PDF/UA requires XMP.

Here's the minimum XMP block a compliant PDF needs:

xml
<x:xmpmeta xmlns:x="adobe:ns:meta/"> <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> <rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" xmlns:pdfuaid="http://www.aiim.org/pdfua/ns/id/"> <dc:title><rdf:Alt><rdf:li xml:lang="x-default">Invoice INV-2025-001</rdf:li></rdf:Alt></dc:title> <dc:language><rdf:Bag><rdf:li>en-US</rdf:li></rdf:Bag></dc:language> <pdfuaid:part>1</pdfuaid:part> </rdf:Description> </rdf:RDF> </x:xmpmeta>

You also need to set the document catalog flags:

txt
/MarkInfo << /Marked true >> /Lang (en-US) /ViewerPreferences << /DisplayDocTitle true >>

DisplayDocTitle true matters: without it, viewers show the filename instead of the document title, which fails PDF/UA.

Doing this with plain pdf-lib

Here's what tagging looks like at the low level. This is not fun:

ts
import { PDFDocument, PDFName, PDFDict, PDFArray } from "pdf-lib"; const doc = await PDFDocument.create(); const page = doc.addPage(); // Set language and mark as tagged doc.catalog.set(PDFName.of("Lang"), doc.context.obj("en-US")); doc.catalog.set( PDFName.of("MarkInfo"), doc.context.obj({ Marked: true }), ); // You still need to build a StructTreeRoot, // tag every content stream with BDC/EMC markers, // wire up ParentTree entries, add role maps...

pdf-lib does not build the structure tree for you. You're writing PDF internals by hand, and getting it wrong produces a file that claims to be tagged but fails validation.

The HTML-to-PDF approach

The pragmatic route for most teams: generate HTML, render it to a tagged PDF. The renderer maps HTML semantics (<h1>, <table>, <th>, <ul>, <img alt>) to PDF structure elements.

Chrome's --export-tagged-pdf flag does this, but the output is inconsistent and doesn't reliably set XMP metadata or the PDF/UA identifier.

With Kamy, tagged output is on by default when you enable accessibility mode:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-2025-001", customer: "Acme GmbH", }, accessibility: { pdfua: true, title: "Invoice INV-2025-001", language: "en-US", }, });

Your template just needs to use semantic HTML — proper headings, <table> with <thead> and <th scope="col">, alt on images, and `aria-hidden="true