---
title: "Create PDF/UA documents in C#"
url: https://recursiveai.org/onepdf/articles/pdf-ua-csharp/
description: "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."
date: 2026-10-26
author: OnePDF team
---

# 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.

October 26, 2026 · OnePDF team

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

*Terminal · NuGet*

```
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.cs · OnePdf.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}");
```

*Output · Console*

```
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 code | In the file |
|---|---|
| `Heading(1)`, `Heading(2)` | `H1`, `H2` |
| `Text(...)` | `P` |
| `Table` with `HeaderRow` | `Table`, 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 borders | Artifacts, 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#](https://recursiveai.org/onepdf/articles/create-pdf-a-csharp/).

## 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.cs · OnePdf.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")}");
```

*Output · Console*

```
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.cs · OnePdf.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}");
```

*Output · Console*

```
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.cs · OnePdf.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.cs · OnePDF 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}");
```

*Output · Console*

```
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](https://recursiveai.org/onepdf/docs/tagging/).

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](https://www.irs.gov/pub/irs-pdf/fw9.pdf)), which is tagged:

*Program.cs · OnePDF 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}");
```

*Output · Console*

```
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](https://recursiveai.org/onepdf/docs/pdfua/). 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](https://verapdf.org/software/) or the free [PAC](https://pac.pdf-accessibility.org/en) 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 license | PDF/UA as documented | How structure gets into the file |
|---|---|---|
| OnePDF 1.11.0: MIT OR Apache-2.0 | PDF/UA-1, PDF/UA-2, PDF/A-2a and 3a ([docs](https://recursiveai.org/onepdf/docs/pdfua/)) | Layout tags everything by default. Existing files are tagged with roles you supply. |
| iText Core (9.8.0 on NuGet): AGPL-3.0 or commercial | PDF/UA-1 and PDF/UA-2 ([iText](https://itextpdf.com/solutions/universal-accessibility-pdfua)) | 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](https://docs.pdfsharp.net/PDFsharp/Topics/PDF-Features/Accessibility.html)) | You open and close each element around your drawing calls through a `UAManager` and its `StructureBuilder` |
| QuestPDF 2026.9.1: QuestPDF licenses | PDF/UA-1, with PDF/A-3a in the same file in its example ([QuestPDF docs](https://www.questpdf.com/concepts/accessibility.html)) | 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](https://recursiveai.org/onepdf/articles/itext-license/), [PDFsharp license](https://recursiveai.org/onepdf/articles/pdfsharp-license/) and [choosing a C# PDF library](https://recursiveai.org/onepdf/articles/csharp-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](https://www.w3.org/WAI/WCAG22/Techniques/#pdf) 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

- [Create PDF/UA](https://recursiveai.org/onepdf/cookbook/create-pdf-ua/), the short cookbook recipe.
- [PDF/UA and PDF/A level A](https://recursiveai.org/onepdf/docs/pdfua/): every repair and every check.
- [Layout](https://recursiveai.org/onepdf/docs/layout/): the full layout API and its tagging rules.
- [Create a PDF in C#](https://recursiveai.org/onepdf/articles/create-pdf-csharp/) for tables that break across pages, page numbers and ASP.NET Core.
- [Create PDF/A in C#](https://recursiveai.org/onepdf/articles/create-pdf-a-csharp/) for archival files and PDF/A-3 attachments.
