Skip to main content

Accessibility: tagged PDF and PDF/UA

A tagged PDF carries a structure tree: a description of what each part of the page is, such as a heading, a paragraph, a table cell or a picture, in reading order. Screen readers, reflowing viewers and text extractors read the tree instead of guessing from the position of each glyph. Without it, a page is glyphs at coordinates, and the only order is the order they were drawn in.

PDF/UA-1 (ISO 14289-1) is the standard for accessible PDF. Public-sector buyers and accessibility laws often ask for it. PdfPinata can tag a document for you, and it enforces a PDF/UA-1 claim rather than only writing it into the file.

PinataLayout tags by default

Every document rendered with PdfDocumentRenderer is tagged, because TagContent defaults to true. You do not have to ask for it. The renderer maps the document model to structure elements:

In the documentIn the structure tree
A section/Sect
A paragraph/P
A paragraph whose Format.OutlineLevel is Level1 to Level6/H1 to /H6
A paragraph whose Format.OutlineLevel is Level7 to Level9/H6 (PDF has only six heading levels)
A run of list paragraphsone /L, with an /LI per item and the bullet or number as its /Lbl
A table/Table, /TR, and /TD for each cell
A cell in a row with HeadingFormat = true/TH with /Scope /Column
A hyperlink/Link, with a description on the link annotation
A footnote/Reference where it is cited and /Note for the note, joined by an /ID
An image or chart with AlternativeText/Figure with /Alt
Headers, footers, borders, shading, an image with no AlternativeTextan artifact, which a reader skips

The same OutlineLevel that puts a heading in the bookmarks pane gives it its heading level:

src/SampleApp/Demos/AccessibilityDemo.cs
var heading1 = report.Styles[StyleNames.Heading1];
heading1.Font.Name = "Liberation Sans";
heading1.Font.Size = 17;
heading1.Font.Bold = true;
heading1.ParagraphFormat.SpaceBefore = Unit.FromPoint(16);
heading1.ParagraphFormat.SpaceAfter = Unit.FromPoint(7);

// The same property that puts a heading in the bookmark panel is what gives it its
// structure type: /H1 from Level1, and so on down to /H6. Nothing else has to be said.
heading1.ParagraphFormat.OutlineLevel = OutlineLevel.Level1;

Headers and footers are drawn as artifacts, so a running head is not read aloud on every page:

src/SampleApp/Demos/AccessibilityDemo.cs
// A running head is decoration, not content. The renderer draws it inside an artifact scope
// and nothing inside one is tagged at all - so this line does not appear in the tree, and a
// reader announcing the document does not read it out once per page.
var runningHead = section.Headers.Primary.AddParagraph("Accessible output");
runningHead.Format.Font.Size = 8;
runningHead.Format.Font.Color = Colors.DimGray;
runningHead.Format.Alignment = ParagraphAlignment.Right;

Tables

Mark the header row with HeadingFormat. Its cells become /TH elements, so a screen reader can announce the column header before each value. The same flag repeats the row at the top of each page the table continues onto. Table.Summary describes the shape of the table for a reader who cannot see it:

src/SampleApp/Demos/AccessibilityDemo.cs
// Written to /Summary on the /Table element. A caption describes a table to somebody who
// can see its shape; a summary describes the shape itself, which is what somebody who
// cannot see it is missing.
table.Summary =
"Quarterly revenue and headcount for three regions. Columns: region, revenue in "
+ "thousands of pounds, and headcount.";

table.AddColumn(Unit.FromCentimeter(5));
table.AddColumn(Unit.FromCentimeter(4));
table.AddColumn(Unit.FromCentimeter(3));

var header = table.AddRow();

// The one flag that makes the difference. A heading row's cells are tagged /TH with
// /Scope /Column rather than /TD, and it is also what repeats the row over a page break.
header.HeadingFormat = true;
header.Format.Font.Bold = true;
header.Shading.Color = Colors.WhiteSmoke;
header.Cells[0].AddParagraph("Region");
header.Cells[1].AddParagraph("Revenue (GBP thousand)");
header.Cells[2].AddParagraph("Headcount");

Alternative text for images

AlternativeText is on Shape, so it applies to images and charts. It decides how an image is tagged:

  • Set: the image is a /Figure with /Alt, and a screen reader reads the text.
  • Not set: the image is drawn as an artifact. It is still visible, and a screen reader passes over it as decoration.

PinataLayout never writes a figure with nothing to say, and it never invents a description.

src/SampleApp/Demos/AccessibilityDemo.cs
var described = section.AddParagraph();
described.Format.Alignment = ParagraphAlignment.Center;
var photograph = described.AddImage(ImageSource.FromStream(
"described.jpg", () => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg")));
photograph.Width = Unit.FromCentimeter(6);
photograph.LockAspectRatio = true;

// Set, so this one is a /Figure with an /Alt. Left unset, PinataLayout draws the image as an
// artifact instead - it will not produce a figure with nothing to say, which is why the
// refusal on the last page had to be provoked by reaching past the renderer.
photograph.AlternativeText =
"A brightly coloured paper donkey pinata sitting at a desk, typing the words "
+ "PDF Pinata onto a sheet in an old typewriter.";

Claim PDF/UA-1

Tagging a document and conforming to PDF/UA-1 are different things. To make the claim:

  1. Render the document with TagContent left at true.
  2. Set PdfDocumentRenderer.Language to a language tag such as "en-GB".
  3. After rendering, set Info.Title on the PdfDocument.
  4. Set Options.UAConformance to PdfUAConformance.PdfUA1.
src/SampleApp/Demos/AccessibilityDemo.cs
var renderer = new PdfDocumentRenderer(unicode: true)
{
Document = report,

// The default, written out because this demo is about it. Set it to false and every
// structure element above disappears - and then the claim below is refused.
TagContent = true,

// An RFC 3066 tag, and a rule of its own: a reader that does not know the language
// cannot choose a voice to read the document in.
Language = "en-GB"
};

renderer.RenderDocument();

var document = renderer.PdfDocument;

// A rule rather than a nicety. The title is what a reader announces the document as, and
// the file name standing in for it is the failure the rule exists to stop.
document.Info.Title = "Accessible output";
document.Info.Author = "PdfPinata sample app";
document.Info.Subject = "A tagged document claiming PDF/UA-1";

// The claim itself. Everything above had to be true before this line could be written.
document.Options.UAConformance = PdfUAConformance.PdfUA1;

When you save, the writer checks the document and throws InvalidOperationException on the first rule it breaks. The message names the rule and the fix. It checks that:

  • the document is tagged;
  • it has a title;
  • it declares a language;
  • every page is in the structure tree;
  • every figure has alternative text;
  • every note has an identifier, and no two elements share one;
  • every link annotation has a description and is in the structure tree;
  • headings do not skip a level (an /H3 straight after an /H1 is refused).

Two settings are made for you. On a PDF/UA claim, the save sets ViewerPreferences.DisplayDocTitle, so a reader announces the title rather than the file name. And every tagged page gets /Tabs /S, so the Tab key moves through links and fields in structure order.

To run the same checks at another time, call PdfUaValidator.Validate(document) (namespace PdfPinata.Pdf.Structure). It does not set DisplayDocTitle for you, so set it first.

To make a document archival and accessible under one claim, use a PDF/A A level such as PdfAConformance.PdfA2A. It applies the PDF/A rules and the PDF/UA-1 checks together. See PDF/A archiving.

Tag a page you draw yourself

On a page drawn with XGraphics, you tag the content yourself. Wrap each piece of content in BeginMarkedContent, and each piece of decoration in BeginArtifact. Both return an IDisposable, so a using block closes the scope even if an exception is thrown. In this example, headingFont, bodyFont and smallFont are XFont objects:

using PdfPinata.Pdf.Structure;

PdfDocument document = new PdfDocument();
document.Info.Title = "Invoice 2026-0042";
document.Language = "en-GB";

PdfPage page = document.AddPage();
using (XGraphics gfx = XGraphics.FromPdfPage(page))
{
using (gfx.BeginMarkedContent(PdfTag.H1))
gfx.DrawString("Invoice", headingFont, XBrushes.Black, 40, 60);

using (gfx.BeginMarkedContent(PdfTag.P))
gfx.DrawString("Payable within 30 days.", bodyFont, XBrushes.Black, 40, 90);

using (gfx.BeginArtifact())
gfx.DrawString("Page 1 of 1", smallFont, XBrushes.Gray, 500, 800);
}

document.Options.UAConformance = PdfUAConformance.PdfUA1;

A scope opened inside another becomes its child in the tree. BeginMarkedContent also takes an alternative text as its second argument, for a PdfTag.Figure. When the structure does not match the drawing order, as in a table drawn cell by cell, create the elements first with document.Structure.CreateElement(tag, parent) and pass each element to BeginMarkedContent(element). A page with nothing drawn on it must still be in the tree: call document.Structure.RegisterPage(page).

Things to know

  • A successful save is not a validator's verdict. The writer does not check that all content is inside the structure tree, that the reading order makes sense, or that a page imported from another file is tagged. A page imported from an untagged document can pass every check and conform to nothing. Use veraPDF before you rely on the claim.
  • Tagged documents cannot be resized. PdfPage.Resize refuses a tagged document, because resizing moves the content away from where the tree says it is. If you render with PinataLayout and then resize pages, set TagContent = false. See Page resizing and bleed.
  • Hyphenated words stay whole. When PinataLayout breaks a word at a hyphen, even across a page, it tags the parts as one word, so a screen reader or text extractor gets "demonstrate" and not "demon- strate".
  • PDF/UA-2 is not supported yet. PdfUAConformance.PdfUA2 exists, but PdfPinata cannot yet write a document that meets it in full. PDF/UA-2 requires every link and bookmark inside a document to point to a structure element. PdfPinata points them at pages, because the PDF 2.0 standard does not yet define how a structure destination works. Claim PDF/UA-1 until that is settled.
  • An untagged document cannot make an accessibility claim. With TagContent = false, a PDF/UA-1 or PDF/A A-level claim is refused.

See it in action

The Accessibility demo renders a tagged report with headings, a table, a described image and a link, claims PDF/UA-1, and prints the real refusal messages from documents built to break one rule each.

The full Accessibility demo
src/SampleApp/Demos/AccessibilityDemo.cs
var report = new Document();

var normal = report.Styles[StyleNames.Normal];
normal.Font.Name = "Liberation Serif";
normal.Font.Size = 10.5;
normal.ParagraphFormat.SpaceAfter = Unit.FromPoint(6);

var heading1 = report.Styles[StyleNames.Heading1];
heading1.Font.Name = "Liberation Sans";
heading1.Font.Size = 17;
heading1.Font.Bold = true;
heading1.ParagraphFormat.SpaceBefore = Unit.FromPoint(16);
heading1.ParagraphFormat.SpaceAfter = Unit.FromPoint(7);

// The same property that puts a heading in the bookmark panel is what gives it its
// structure type: /H1 from Level1, and so on down to /H6. Nothing else has to be said.
heading1.ParagraphFormat.OutlineLevel = OutlineLevel.Level1;

var heading2 = report.Styles[StyleNames.Heading2];
heading2.Font.Name = "Liberation Sans";
heading2.Font.Size = 12.5;
heading2.Font.Bold = true;
heading2.ParagraphFormat.SpaceBefore = Unit.FromPoint(12);
heading2.ParagraphFormat.OutlineLevel = OutlineLevel.Level2;

var caption = report.Styles.AddStyle("Caption", StyleNames.Normal);
caption.Font.Size = 8.5;
caption.Font.Italic = true;
caption.Font.Color = Colors.DimGray;

var section = report.AddSection();
section.PageSetup.TopMargin = Unit.FromCentimeter(2.2);
section.PageSetup.BottomMargin = Unit.FromCentimeter(2);

// A running head is decoration, not content. The renderer draws it inside an artifact scope
// and nothing inside one is tagged at all - so this line does not appear in the tree, and a
// reader announcing the document does not read it out once per page.
var runningHead = section.Headers.Primary.AddParagraph("Accessible output");
runningHead.Format.Font.Size = 8;
runningHead.Format.Font.Color = Colors.DimGray;
runningHead.Format.Alignment = ParagraphAlignment.Right;

// ----- what the tree is for -----

section.AddParagraph("A document that says what it is").Style = StyleNames.Heading1;

section.AddParagraph(
"Everything on this page is in a structure tree, and nothing here asked for one. "
+ "PdfDocumentRenderer.TagContent defaults to true, so PinataLayout records what it is "
+ "drawing as it draws it: this paragraph is a /P, the line above it is an /H1, and "
+ "the table further down is a /Table whose first row is headers.");

section.AddParagraph(
"The tree is what a screen reader, a reflowing viewer and a text extractor all read "
+ "instead of guessing from coordinates. Without one, a page is a bag of glyphs at "
+ "positions, and the order they were painted in is the only order there is - which "
+ "is drawing order, not reading order, and on a two-column page those are different.");

section.AddParagraph("What the renderer maps").Style = StyleNames.Heading2;

section.AddParagraph(
"A section becomes a /Sect and a paragraph a /P, with /H1 to /H6 taken from "
+ "Format.OutlineLevel. A run of list paragraphs becomes one /L of /LI, each with its "
+ "bullet as the /Lbl. A hyperlink becomes a /Link that also carries a description on "
+ "the annotation. Headers, footers, borders and shading become artifacts, which is "
+ "the tree's way of saying \"this is decoration, skip it\".");

// ----- the table -----

section.AddParagraph("A table that can be navigated").Style = StyleNames.Heading1;

section.AddParagraph(
"A sighted reader finds the meaning of a cell by looking up its column. A reader "
+ "hearing the document has to be told, which is what /TH and its /Scope are for: a "
+ "header cell announced before the value under it turns a grid of numbers back into "
+ "sentences.");

var table = section.AddTable();
table.Borders.Width = 0.5;
table.Borders.Color = Colors.Gray;
table.LeftPadding = Unit.FromPoint(4);
table.RightPadding = Unit.FromPoint(4);
table.TopPadding = Unit.FromPoint(3);
table.BottomPadding = Unit.FromPoint(3);

// Written to /Summary on the /Table element. A caption describes a table to somebody who
// can see its shape; a summary describes the shape itself, which is what somebody who
// cannot see it is missing.
table.Summary =
"Quarterly revenue and headcount for three regions. Columns: region, revenue in "
+ "thousands of pounds, and headcount.";

table.AddColumn(Unit.FromCentimeter(5));
table.AddColumn(Unit.FromCentimeter(4));
table.AddColumn(Unit.FromCentimeter(3));

var header = table.AddRow();

// The one flag that makes the difference. A heading row's cells are tagged /TH with
// /Scope /Column rather than /TD, and it is also what repeats the row over a page break.
header.HeadingFormat = true;
header.Format.Font.Bold = true;
header.Shading.Color = Colors.WhiteSmoke;
header.Cells[0].AddParagraph("Region");
header.Cells[1].AddParagraph("Revenue (GBP thousand)");
header.Cells[2].AddParagraph("Headcount");

(string Region, string Revenue, string People)[] figures =
{
("North", "1,240", "38"),
("Midlands", "980", "31"),
("South West", "1,505", "44")
};

foreach (var each in figures)
{
var row = table.AddRow();
row.Cells[0].AddParagraph(each.Region);
row.Cells[1].AddParagraph(each.Revenue);
row.Cells[2].AddParagraph(each.People);
}

section.AddParagraph(
"Row.HeadingFormat is the whole of it. It was already there to repeat the row over a "
+ "page break, and it turns out to be exactly the header-versus-data distinction the "
+ "tree needs - which is lucky, because that association is otherwise the hardest part "
+ "of tagging a table.").Style = "Caption";

// ----- figures -----

section.AddParagraph("A picture, described or dismissed").Style = StyleNames.Heading1;

section.AddParagraph(
"There are two honest things to do with an image and no third. Describe it, and it is "
+ "a /Figure with /Alt. Say it is decoration, and it is an artifact a reader skips. "
+ "What conforms to nothing is a /Figure with nothing to say, and that is the one thing "
+ "a document produces by accident.");

var described = section.AddParagraph();
described.Format.Alignment = ParagraphAlignment.Center;
var photograph = described.AddImage(ImageSource.FromStream(
"described.jpg", () => Assets.Open(Assets.ImagePrefix + "pdf-pinata.jpg")));
photograph.Width = Unit.FromCentimeter(6);
photograph.LockAspectRatio = true;

// Set, so this one is a /Figure with an /Alt. Left unset, PinataLayout draws the image as an
// artifact instead - it will not produce a figure with nothing to say, which is why the
// refusal on the last page had to be provoked by reaching past the renderer.
photograph.AlternativeText =
"A brightly coloured paper donkey pinata sitting at a desk, typing the words "
+ "PDF Pinata onto a sheet in an old typewriter.";

section.AddParagraph(
"The image above carries AlternativeText and is therefore a described /Figure. Set it "
+ "to nothing and the same call draws the same picture as an artifact - still visible, "
+ "and honestly marked as saying nothing.").Style = "Caption";

// ----- the claim -----

section.AddParagraph("Claiming PDF/UA-1").Style = StyleNames.Heading1;

section.AddParagraph(
"Tagging a document and conforming to PDF/UA are not the same thing, and the gap is "
+ "mostly rules that are cheap to check and easy to break. Setting "
+ "Options.UAConformance to PdfUA1 makes the claim, and the writer then walks the "
+ "document before writing a byte and throws on the first rule it breaks.");

var link = section.AddParagraph("The rules are listed on PdfUaValidator, and ");
link.AddHyperlink("https://github.com/PinataLabs/PdfPinata", HyperlinkType.Web)
.AddFormattedText("this link", TextFormat.Underline);
link.AddText(
" is itself one of them: a /Link with no description leaves a reader able to say only "
+ "\"link\", so the annotation gets /Contents from the hyperlink's own text and is "
+ "joined to the tree with a /StructParent.");

section.AddParagraph(
"Two things the writer settles rather than demands, because there is exactly one right "
+ "answer and refusing over it would teach nobody anything: DisplayDocTitle is set, so "
+ "a reader announces the title rather than the file name, and /Tabs is set to /S on "
+ "every page, so the tab key walks the structure rather than the order the annotations "
+ "happen to sit in.");

section.AddParagraph("What a successful save is not").Style = StyleNames.Heading2;

section.AddParagraph(
"It is not a validator's verdict. What can be settled by looking at the document is "
+ "checked; what cannot is not. That no content sits outside the tree, that the reading "
+ "order makes sense, that headings do not skip a level - none of those are here, and "
+ "a page imported from an untagged document passes every rule and conforms to nothing. "
+ "veraPDF has the last word.");

// ----- the refusals, provoked on purpose -----

section.AddParagraph("The refusals, in their own words").Style = StyleNames.Heading1;

section.AddParagraph(
"Each line below is a real exception message, caught from a document built to break "
+ "one rule and then asked to save. Nothing here is quoted from documentation - the "
+ "text is whatever the library said when this demo was run.");

foreach (var refusal in Refusals())
{
var what = section.AddParagraph(refusal.Broken);
what.Format.Font.Bold = true;
what.Format.SpaceBefore = Unit.FromPoint(8);
what.Format.SpaceAfter = Unit.FromPoint(1);

var said = section.AddParagraph(refusal.Message);
said.Format.Font.Name = "Source Code Pro";
said.Format.Font.Size = 7.5;
said.Format.LeftIndent = Unit.FromCentimeter(0.5);
}

var renderer = new PdfDocumentRenderer(unicode: true)
{
Document = report,

// The default, written out because this demo is about it. Set it to false and every
// structure element above disappears - and then the claim below is refused.
TagContent = true,

// An RFC 3066 tag, and a rule of its own: a reader that does not know the language
// cannot choose a voice to read the document in.
Language = "en-GB"
};

renderer.RenderDocument();

var document = renderer.PdfDocument;

// A rule rather than a nicety. The title is what a reader announces the document as, and
// the file name standing in for it is the failure the rule exists to stop.
document.Info.Title = "Accessible output";
document.Info.Author = "PdfPinata sample app";
document.Info.Subject = "A tagged document claiming PDF/UA-1";

// The claim itself. Everything above had to be true before this line could be written.
document.Options.UAConformance = PdfUAConformance.PdfUA1;