Skip to content
Lamina PDF
Unreleased

These docs describe changes that aren’t released yet. For the newest release, see the released docs.

Accessible PDFs

This guide builds a report that screen-reader users can read, which you need whenever a document has to meet accessibility law or a client’s accessibility policy.

PDF/UA-1 (“Universal Accessibility”) is the standard for accessible PDF. A PDF/UA file is tagged: its structure tree says which content is a heading, a paragraph, a list, a table or a figure, and in what order to read it. Everything else, such as running headers and decorative rules, is marked as an artifact, which assistive technology skips. The file also needs a title, a language, alternate text for every figure, a description for every link and embedded fonts that map each glyph to characters.

Turn the profile on with one option: profiles: ["PDF/UA-1"] in TypeScript, BuildOptions::default().with_profile(Profile::PdfUa1) in Rust. Then finish() checks the rules a program can check and fails with a coded error for the first one the document breaks:

Rule Error
The metadata has a title missing-title
The document has a language (lang) missing-lang
Everything drawn is tagged or an artifact untagged-content
Every figure and formula has alternate text missing-alt-text
Headings start at H1 and skip no level going down heading-level-skipped
One unnumbered H per section, never beside H1–H6 unnumbered-heading-misused
Table cells sit in rows, rows in tables, list items in lists invalid-structure-nesting
Every link has a Link element and a description link-without-structure, link-without-contents
Every font is embedded (no standard 14 font) font-not-embedded
Every glyph maps to Unicode glyph-without-unicode
The PDF version is 1.7 or lower profile-version-conflict

The builder writes the rest for you. It puts the PDF/UA identifier in the document’s XMP metadata, the XML packet in the file that describes it. It also asks viewers to show the title instead of the file name, and orders links by the structure.

The report below draws its running header as an artifact. Then come a heading, a paragraph, a table with header cells, a bar chart as a figure with alternate text, and a link with a description. A small line helper gives each line of text its structure element.

packages/sdk/examples/pdf-ua-report.ts
// An accessible report that screen readers can follow, checked against PDF/UA-1 when it is built.
import { Builder, Font, type Surface, type Tag, type TagName } from "@laminapdf/sdk";
const MARGIN = 56;
const ROW = 18;
const COLUMN = 144;
const HEADER_BASELINE = 40;
const TITLE_BASELINE = 90;
const TABLE_TOP = 150;
const LINK_BASELINE = 400;
const CHART_LEFT = 320;
const CHART_BASE = 360;
const BAR_WIDTH = 36;
const BAR_PITCH = 50;
const BODY = 11;
/** The font sizes of the title and the headings; other text is `BODY`. */
const SIZES: Partial<Record<TagName, number>> = { H1: 22, H2: 15 };
const BLUE = [0.12, 0.47, 0.78] as const;
const QUARTERS = [
["Q1", 120],
["Q2", 135],
["Q3", 160],
["Q4", 150],
] as const;
const CHART_ALT = "Bar chart of sales by quarter: 120, 135, 160 and 150 thousand euros.";
/**
* The report's bytes. Needs `init({ text: true })`.
*
* @param notoSans - the bytes of the Noto Sans font file.
*/
export function pdfUaReport(notoSans: Uint8Array): Uint8Array<ArrayBuffer> {
using b = new Builder({ profiles: ["PDF/UA-1"] });
b.metadata({ title: "Annual sales report 2026" });
b.lang("en-GB");
const font = Font.fromBytes(notoSans);
b.font(font);
const page = b.page("A4");
draw(page.surface(), font);
page.finish();
using doc = b.finish();
return doc.save();
}
/** One line of text with its baseline at `y`, as the structure element `tag`, sized by it. */
function line(s: Surface, font: Font, tag: Tag, text: string, x: number, y: number): void {
const size = (typeof tag === "string" ? SIZES[tag] : undefined) ?? BODY;
s.tag(tag, (s) => s.text(text, { font, size, at: [x, y] }));
}
function draw(s: Surface, font: Font): void {
// The running header is decoration: an artifact, which screen readers skip.
s.artifact("pagination", (s) => s.text("Acme Widgets", { font, size: BODY, at: [MARGIN, HEADER_BASELINE] }));
line(s, font, "H1", "Annual sales report 2026", MARGIN, TITLE_BASELINE);
line(s, font, "P", "Sales grew in three quarters of four.", MARGIN, TITLE_BASELINE + 2 * ROW);
table(s, font);
chart(s);
const link = {
target: { type: "uri", uri: "https://example.com/accounts" },
contents: "The full 2026 accounts",
} as const;
s.tag("P", (s) =>
s.text("The full accounts", { font, size: BODY, at: [MARGIN, LINK_BASELINE], fill: [...BLUE], link }),
);
}
/** A heading and a table of the quarters, whose first row holds the columns' header cells. */
function table(s: Surface, font: Font): void {
line(s, font, "H2", "Sales by quarter", MARGIN, TABLE_TOP);
const header: Tag = { type: "TH", scope: "column" };
s.tag("Table", (s) => {
s.tag("TR", (s) => {
line(s, font, header, "Quarter", MARGIN, TABLE_TOP + ROW);
line(s, font, header, "Sales (k€)", MARGIN + COLUMN, TABLE_TOP + ROW);
});
QUARTERS.forEach(([quarter, sales], i) => {
const y = TABLE_TOP + ROW * (i + 2);
s.tag("TR", (s) => {
line(s, font, "TD", quarter, MARGIN, y);
line(s, font, "TD", sales.toFixed(0), MARGIN + COLUMN, y);
});
});
});
}
/** The quarters as bars standing on the chart's base: one figure with alternate text. */
function chart(s: Surface): void {
s.tag({ type: "Figure", alt: CHART_ALT }, (s) => {
s.fillColor([...BLUE]);
QUARTERS.forEach(([, sales], i) => {
s.path((p) => p.rect([CHART_LEFT + BAR_PITCH * i, CHART_BASE - sales, BAR_WIDTH, sales])).fill();
});
});
}

See the output.

About half of PDF/UA’s requirements need a person. Lamina PDF can check that a figure has alternate text, but not that the text describes the figure. It can check that headings go down one level at a time. It can’t check that the right text is a heading, that the reading order makes sense or that colour isn’t the only way the document conveys meaning. Read the document with a screen reader before you publish it.

Every example on this site that claims PDF/UA-1 is validated each night with veraPDF, the open-source validator the PDF Association backs.

Next: to make a document both accessible and archival, add a PDF/A profile next to PDF/UA-1, as Archival PDFs shows.