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:
- A logical structure tree (tags) so assistive tech can read content in the right order.
- Correct metadata — title, language, and PDF/UA identifier in XMP.
- Semantic markup — headings tagged as
<H1>, tables with header cells, lists as lists, alt text on images. - 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:
tsimport { 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:
tsimport { 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