Recursive AI

Create PDF/UA documents in C#

Create PDF/UA-1 and PDF/UA-2 files in C#: tagged headings, tables, lists and alt text, PDF/A-2a in the same file, tagging existing PDFs, and checks that list every violation.

To create a PDF/UA document in C#, lay it out with OnePdf.Layout, which tags headings, paragraphs, tables, lists, images and links as it writes them. Give it a title, a language and alternate text for each image, then save with PdfSaveOptions.PdfUA. The save makes the clerical fixes itself. When something only you can supply is missing, it refuses and lists every problem by its Matterhorn Protocol checkpoint. OnePDF writes PDF/UA-1, PDF/UA-2 on PDF 2.0, and PDF/UA-1 with PDF/A-2a or 3a in one file. It is pure C#, licensed MIT OR Apache-2.0. Every sample below was compiled and run against OnePDF 1.11.0 on .NET 10, and each output file was checked with the veraPDF validator.

What PDF/UA asks of a file

PDF/UA (ISO 14289) is the standard for PDFs that people can read with assistive technology such as screen readers and braille displays. PDF/UA-1 builds on PDF 1.7, and PDF/UA-2 (ISO 14289-2:2024) on PDF 2.0. In practice it comes down to a handful of things a generator has to get right:

  • Tags. Every piece of real content belongs to a structure element (H1, P, Table, Figure and so on), and everything decorative, such as running headers, page numbers, rules and backgrounds, is marked as an artifact: the “tagged or artifacted” rule, Matterhorn checkpoint 01.
  • Alternate text on every figure that carries meaning.
  • Headings that start at level 1 and don’t skip levels.
  • Tables with header cells (TH), so a screen reader can say which header a value belongs to.
  • A title and a language, with the viewer told to show the title instead of the file name.
  • Embedded fonts with a Unicode mapping, so the text can be read out.

The Matterhorn Protocol, published by the PDF Association, numbers the checks a file must pass. OnePDF reports each problem it finds with that checkpoint number and the ISO 14289-1 clause, so a refused save reads like a validator report.

Before you start

TerminalNuGet
dotnet new console -o AccessiblePdf
cd AccessiblePdf
dotnet add package RecursiveAI.OnePdf.Layout --version 1.11.0

RecursiveAI.OnePdf.Layout brings in RecursiveAI.OnePdf and the embedded font package, so text is set in Liberation Sans, embedded as a subset, unless you register your own fonts. OnePDF targets .NET 10 (net10.0). The samples use logo.png, any small PNG.

Create a PDF/UA-1 document

This one-page summary has a logo, two heading levels, a table with column and row headers, a numbered list, a link, a running header and a “Page 1 of 1” footer. Nothing in it is PDF/UA-specific except the save options. The layout tags as it goes, and the save adds the rest.

Program.csOnePdf.Layout 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Layout;
 
byte[] logo = File.ReadAllBytes("logo.png");
 
LayoutDocument report = LayoutDocument.Compose(doc =>
{
    doc.Title("Harbor trial summary").Author("Port office").Language("en-US");
    doc.Page(page =>
    {
        page.Size(PageSize.Letter);
        page.Margin(54);
        page.Header().Text("Port office · Harbor trial").FontSize(9).FontColor(Colors.Grey);   // tagged as an artifact
        page.Footer().AlignCenter().Text(t =>
        {
            t.FontSize(9);
            t.Span("Page ");
            t.CurrentPageNumber();
            t.Span(" of ");
            t.TotalPages();
        });
        page.Content().Column(col =>
        {
            col.Spacing(10);
            col.Item().Width(48).Image(logo).AltText("Port office logo");
            col.Item().Heading(1).Text("Harbor trial summary").FontSize(22).Bold();
            col.Item().Text("Six crews fished the harbor for six weeks. This summary lists the catch by quay and the next steps.");
 
            col.Item().Heading(2).Text("Catch by quay").FontSize(15).Bold();
            col.Item().Table(table =>
            {
                table.Columns(c => { c.Relative(); c.Constant(90); c.Constant(90); });
                table.CellPadding(4);
                table.CellBorder(0.5, Colors.Grey);
                table.HeaderRow(r =>
                {
                    r.Cell().Text("Quay").Bold();
                    r.Cell().AlignEnd().Text("Crews").Bold();
                    r.Cell().AlignEnd().Text("Catch (kg)").Bold();
                });
                table.Row(r => { r.Cell().RowHeader().Text("North"); r.Cell().AlignEnd().Text("3"); r.Cell().AlignEnd().Text("1,240"); });
                table.Row(r => { r.Cell().RowHeader().Text("South"); r.Cell().AlignEnd().Text("3"); r.Cell().AlignEnd().Text("980"); });
            });
 
            col.Item().Heading(2).Text("Next steps").FontSize(15).Bold();
            col.Item().List(list =>
            {
                list.Numbered();
                list.Item().Text("Repeat the trial in spring.");
                list.Item().Text("Publish the crew logs.");
            });
            col.Item().Hyperlink("https://www.w3.org/WAI/").Text("Accessibility guidance from the W3C").FontColor(Colors.Blue);
        });
    });
});
 
using (FileStream file = File.Create("summary-ua.pdf"))
{
    report.Save(file, new PdfSaveOptions
    {
        PdfUA = new PdfUAOptions(),                                                        // PDF/UA-1
        PdfA = new PdfAOptions { Part = PdfAPart.Part2, Conformance = PdfAConformance.A }, // and PDF/A-2a, in the same file
    });
}
 
using PdfDocument check = PdfDocument.Open("summary-ua.pdf");
Console.WriteLine($"tagged: {check.IsTagged}, language: {check.Language}, violations: {check.CheckPdfUA().Count}");
OutputConsole
tagged: True, language: en-US, violations: 0

veraPDF 1.30.3 passes summary-ua.pdf under both its PDF/UA-1 and PDF/A-2a profiles. This is what the layout wrote into the structure tree:

In the codeIn the file
Heading(1), Heading(2)H1, H2
Text(...)P
Table with HeaderRowTable, with the header row as TH cells with /Scope /Column
Cell().RowHeader()TH with /Scope /Row
List with Numbered()L, LI, with the number as Lbl and the text as LBody
Image(...).AltText("Port office logo")Figure with /Alt (Port office logo)
Hyperlink(...)Link, tied to the link annotation, which gets a description
Header(), Footer(), cell bordersArtifacts, so a screen reader skips them

On top of the tags, the PDF/UA save repairs what needs no judgement: it sets /MarkInfo, writes the XMP metadata with the PDF/UA identifier and the title, sets DisplayDocTitle so viewers show “Harbor trial summary” rather than the file name, sets the tab order on pages with annotations, and gives links a description. PdfUAOptions.Language supplies a language when the document has none.

Adding PdfA with PdfAConformance.A to the same save makes the file PDF/A-2a as well, the archival format at its tagged level. Use PdfAPart.Part3 for PDF/A-3a. More on archival files in create PDF/A in C#.

What happens when something is missing

OnePDF never makes up a title, a language, alternate text or the role of a piece of content. When the document lacks one, the PDF/UA save throws PdfUAException with every violation and writes nothing. Here the title is missing, the image has no alternate text, the first heading is level 2 and the table has no header cells:

Program.csOnePdf.Layout 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Layout;
 
byte[] logo = File.ReadAllBytes("logo.png");
 
LayoutDocument draft = LayoutDocument.Compose(doc =>
{
    doc.Language("en-US");                                              // no Title(...)
    doc.Page(page => page.Content().Column(col =>
    {
        col.Item().Width(48).Image(logo);                               // no AltText(...) and not Artifact()
        col.Item().Heading(2).Text("Catch by quay");                    // the first heading is level 2
        col.Item().Table(table =>
        {
            table.Columns(c => { c.Relative(); c.Relative(); });
            table.Row(r => { r.Cell().Text("North"); r.Cell().Text("1,240"); });   // no HeaderRow, no RowHeader
        });
    }));
});
 
try
{
    using var buffer = new MemoryStream();
    draft.Save(buffer, new PdfSaveOptions { PdfUA = new PdfUAOptions() });
    File.WriteAllBytes("draft-ua.pdf", buffer.ToArray());          // only reached when every check passes
}
catch (PdfUAException e)
{
    foreach (PdfUAViolation v in e.Violations)
        Console.WriteLine($"Matterhorn {v.Checkpoint} ({v.Clause}): {v.Message}");
}
Console.WriteLine($"draft-ua.pdf written: {File.Exists("draft-ua.pdf")}");
OutputConsole
Matterhorn 06 (7.1): The XMP packet has no dc:title: a PDF/UA file needs a title.
Matterhorn 07 (7.1): The catalog's /ViewerPreferences does not set /DisplayDocTitle true, so viewers would show the file name instead of the title.
Matterhorn 13 (7.3): A Figure structure element has neither alternate text (/Alt) nor replacement text (/ActualText).
Matterhorn 14 (7.4.2): The first heading is H2; the heading hierarchy has to start at H1.
Matterhorn 15 (7.5): A table has no header cells (TH).
draft-ua.pdf written: False

Each line maps to a fix in the layout code:

  • Checkpoints 06 and 07 (title): call doc.Title(...). The save then writes the title to the XMP metadata and sets DisplayDocTitle.
  • Checkpoint 13 (figure has no alternate text): add .AltText("..."), or .Artifact() when the image is decoration, which leaves it out of the structure tree.
  • Checkpoint 14 (headings): start at Heading(1) and go down one level at a time.
  • Checkpoint 15 (table has no header cells): add a HeaderRow, or mark the first cell of each row with RowHeader().

The violations come back as data (Checkpoint, Clause, Message, ObjectNumber, PageIndex), so a build or a report service can fail with a readable reason instead of shipping a file that fails an audit later. Because the save writes to a MemoryStream first, a refused document leaves no half-written file behind.

PDF/UA-2 on PDF 2.0

Set Part = PdfUAPart.Part2 for PDF/UA-2. The file is written as PDF 2.0, structure elements get the PDF 2.0 namespaces, and bookmarks and links point at structure elements instead of pages, as part 2 requires. The PDF 2.0 types Aside, FENote (footnotes) and Title become available through Aside(), Footnote() and Title():

Program.csOnePdf.Layout 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Layout;
 
LayoutDocument notice = LayoutDocument.Compose(doc =>
{
    doc.Title("Harbor closure notice").Language("en-US");
    doc.Page(page =>
    {
        page.Size(PageSize.A4);
        page.Margin(54);
        page.Content().Column(col =>
        {
            col.Spacing(10);
            col.Item().Bookmark("Closure").Heading(1).Text("Harbor closure notice").FontSize(20).Bold();
            col.Item().Text("The north quay closes on 3 November for dredging.");
            col.Item().Aside().Text("Crews on the north quay can moor at the south quay.").Italic();   // PDF 2.0 Aside
        });
    });
});
 
using (FileStream file = File.Create("notice-ua2.pdf"))
    notice.Save(file, new PdfSaveOptions { PdfUA = new PdfUAOptions { Part = PdfUAPart.Part2 } });
 
using PdfDocument check = PdfDocument.Open("notice-ua2.pdf");
Console.WriteLine($"PDF {check.Version}, PDF/UA-2 violations: {check.CheckPdfUA(PdfUAPart.Part2).Count}");
OutputConsole
PDF 2.0, PDF/UA-2 violations: 0

veraPDF passes notice-ua2.pdf under its PDF/UA-2 profile, and the file starts with %PDF-2.0. Two things to know before choosing part 2:

  • PDF/A-2 and PDF/A-3 are PDF 1.7 formats, so they can’t be combined with PDF/UA-2. If you need an archival file that is also accessible, use PDF/UA-1 with PDF/A-2a or 3a, as in the first sample.
  • Part 2 doesn’t replace part 1: they are the accessibility standards for PDF 2.0 and PDF 1.7 files. When the people receiving your files name a part, follow that.

Make an existing PDF accessible: tag it

An untagged PDF can’t be made PDF/UA by a save option: something has to decide what each block is. OnePDF’s tagging API leaves that decision to you, then writes the structure. You pass a function that returns a role for each text block of an extraction (heading, paragraph, list item, table, figure or artifact), and areas that hold images with their alternate text. OnePDF builds the structure tree, marks every glyph and graphic as tagged content or an artifact, and leaves the pages looking and extracting exactly as before.

To try it, make an untagged file. This is the same kind of content as above, written with tagging switched off, as a reporting tool that writes no tags would:

MakeUntagged.csOnePdf.Layout 1.11.0
using OnePdf.Layout;
 
// A stand-in for a PDF from a tool that writes no tags: same content, Tagged(false).
LayoutDocument.Compose(doc =>
{
    doc.Tagged(false);
    doc.Page(page =>
    {
        page.Size(PageSize.Letter);
        page.Margin(54);
        page.Content().Column(col =>
        {
            col.Spacing(12);
            col.Item().Text("Harbor trial summary").FontSize(22).Bold();
            col.Item().Text("Six crews fished the harbor for six weeks. The catch was weighed at the quay every evening.");
            col.Item().Text("Next steps").FontSize(15).Bold();
            col.Item().Text("1. Repeat the trial in spring.");
            col.Item().Text("2. Publish the crew logs.");
            col.Item().Width(48).Image(File.ReadAllBytes("logo.png"));
        });
    });
}).Save("untagged.pdf");

Then tag it. Here the rule is simple, because the file is known: the 22-point line is the title heading, the 15-point line is a level 2 heading, lines starting with a digit are list items, and everything else is a paragraph.

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Text;
 
using PdfDocument doc = PdfDocument.Open("untagged.pdf", new PdfOpenOptions { AllowEditing = true });
Console.WriteLine($"tagged before: {doc.IsTagged}");
 
// The tagging works on an extraction that keeps the glyphs.
IReadOnlyList<PdfPageText> text = doc.ExtractText(new PdfTextOptions { RetainGlyphs = true, UseStructureTree = false });
 
var options = new PdfTagOptions { Title = "Harbor trial summary", Language = "en-US" };
options.Figures.Add(new PdfTagFigure(0, new PdfRectangle(50, 0, 110, 792), "Port office logo"));   // a strip at the left margin that holds the logo
 
// You decide every role. Here: the two bold sizes are headings, numbered lines are list items.
PdfTaggingResult result = doc.Tag(text, block =>
{
    double height = block.Block.Lines[0].Bounds.Height;
    if (height > 20) return PdfBlockRole.Heading(1);
    if (height > 14) return PdfBlockRole.Heading(2);
    if (char.IsDigit(block.Text[0])) return PdfBlockRole.ListItem;
    return PdfBlockRole.Paragraph;
}, options);
Console.WriteLine($"{result.ElementsWritten} structure elements on {result.PagesChanged} page");
 
doc.Save("tagged-ua.pdf", new PdfSaveOptions { PdfUA = new PdfUAOptions() });
 
using PdfDocument check = PdfDocument.Open("tagged-ua.pdf");
Console.WriteLine($"tagged after: {check.IsTagged}, PDF/UA-1 violations: {check.CheckPdfUA().Count}");
OutputConsole
tagged before: False
12 structure elements on 1 page
tagged after: True, PDF/UA-1 violations: 0

veraPDF passes tagged-ua.pdf as PDF/UA-1. Its structure tree holds a Document with an H1, a P, an H2, a list of two LI items, each split into its number (Lbl) and text (LBody), and the Figure with its alternate text. pdftotext gives byte-for-byte the same text before and after.

Real documents need a better rule than line height: fonts, positions, what the extraction already flags as a table (block.Block.IsTable) or a running header, or a person choosing in a review screen. The happy path shown here takes one role per block. For finer control, such as a heading and a paragraph in the same block or a table with spans, build the plan element by element with CreateTaggingPlan and ApplyTagging. Both are in tagging existing PDFs.

Tagging doesn’t repair other defects. A file whose fonts aren’t embedded, which is common in older PDFs, gets its structure but is still refused by the PDF/UA save, with the fonts listed.

Check a PDF against PDF/UA

CheckPdfUA() reports the violations of any document as it stands. It changes nothing, so it works on read-only documents too. Here it reads the IRS Form W-9 (fw9.pdf), which is tagged:

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
// Report what stands between a file and PDF/UA-1. Nothing is changed, and read-only documents work.
using PdfDocument doc = PdfDocument.Open("fw9.pdf");
IReadOnlyList<PdfUAViolation> violations = doc.CheckPdfUA();
 
Console.WriteLine($"fw9.pdf: tagged={doc.IsTagged}, {violations.Count} violations");
foreach (var group in violations.GroupBy(v => v.Checkpoint).OrderBy(g => g.Key))
    Console.WriteLine($"  Matterhorn {group.Key}: {group.Count()} × {group.First().Message}");
OutputConsole
fw9.pdf: tagged=True, 41 violations
  Matterhorn 01: 20 × Marked content with MCID 18 is not part of the structure tree.
  Matterhorn 14: 1 × A H4 follows an H2: heading levels are skipped.
  Matterhorn 28: 20 × A form field has no /TU description for assistive technology.

veraPDF fails the same file under its PDF/UA-1 profile. The list is capped at twenty findings of one kind, so read “20 ×” as “at least 20”. PdfUAViolation.Part and CheckPdfUA(PdfUAPart.Part2) do the same for PDF/UA-2.

What a clean result does and doesn’t prove

OnePDF checks the rules a program can decide from the file: tagging, structure types, metadata, language, figures, heading order, table and list structure, annotations, fonts and encryption permissions. The full list is in PDF/UA and PDF/A level A. It is not a validator, and a clean save is not a certificate. PDF/UA also asks for things only a person can judge:

  • whether the reading order makes sense,
  • whether the alternate text describes what the image shows,
  • whether headings mark the real sections,
  • contrast and the use of color,
  • how complex tables relate their header cells to data cells (Headers and ID). Simple tables get TH cells with a scope, as above.

Before you claim conformance, run a validator such as veraPDF or the free PAC checker, and listen to a page with a screen reader.

A PDF/UA save checks the finished file, so the whole document is held in memory while it is written. A plain Save(path) of a layout document streams page by page. For very large documents, stream the file out and run CheckPdfUA on the result instead.

PDF/UA with iText, PDFsharp or QuestPDF

The pages that rank for this search are mostly vendor documentation. What each documents, as of 2026-10-26:

Library and licensePDF/UA as documentedHow structure gets into the file
OnePDF 1.11.0: MIT OR Apache-2.0PDF/UA-1, PDF/UA-2, PDF/A-2a and 3a (docs)Layout tags everything by default. Existing files are tagged with roles you supply.
iText Core (9.8.0 on NuGet): AGPL-3.0 or commercialPDF/UA-1 and PDF/UA-2 (iText)Tagged PDF created and edited through iText Core
PDFsharp 6.5.0: AGPL-3.0 or commercial (6.2.4 and earlier: MIT)PDF/UA, in a page for version 6.1.1 (PDFsharp docs)You open and close each element around your drawing calls through a UAManager and its StructureBuilder
QuestPDF 2026.9.1: QuestPDF licensesPDF/UA-1, with PDF/A-3a in the same file in its example (QuestPDF docs)You wrap content in Semantic* methods and set PDFUA_Conformance

PDFsharp’s page also notes that when one structure element holds several DrawString calls, you add trailing spaces yourself, “otherwise screen readers would read ‘Line oneLine two’”. On licenses: iText under the AGPL requires releasing your application’s source, PDFsharp 6.5.0 needs a commercial license for closed-source use, and QuestPDF is free only under its Community License terms. Details are in iText license cost, PDFsharp license and choosing a C# PDF library.

Common questions

What is the difference between a tagged PDF and PDF/UA?

A tagged PDF has a structure tree. PDF/UA is a set of rules on top of that: every mark tagged or an artifact, alternate text, heading order, table headers, a title and language, embedded fonts, and an identifier in the metadata that claims conformance. Every PDF/UA file is tagged, but most tagged files aren’t PDF/UA.

PDF/UA or WCAG?

WCAG is the W3C’s set of accessibility guidelines for web content. The W3C publishes PDF techniques for meeting it in PDF files. PDF/UA is the ISO standard for the PDF file itself. A PDF/UA file covers the technical side of WCAG for PDF. Judgement calls such as meaningful alternate text and contrast still need a person, under either standard.

Can OnePDF make any PDF accessible automatically?

No. It writes PDF/UA for documents it lays out, and for existing files once you have said what each block is through the tagging API. It doesn’t guess roles or write alternate text for you, and an untagged file saved with PdfUA is refused with the reasons.

Where next