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,Figureand 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
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.
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}");
tagged: True, language: en-US, violations: 0veraPDF 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#.
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:
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")}");
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 setsDisplayDocTitle. - 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 withRowHeader().
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():
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}");
PDF 2.0, PDF/UA-2 violations: 0veraPDF 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:
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.
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}");
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:
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}");
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 (
HeadersandID). Simple tables getTHcells 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 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) | 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) | 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 licenses | PDF/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
- Create PDF/UA, the short cookbook recipe.
- PDF/UA and PDF/A level A: every repair and every check.
- Layout: the full layout API and its tagging rules.
- Create a PDF in C# for tables that break across pages, page numbers and ASP.NET Core.
- Create PDF/A in C# for archival files and PDF/A-3 attachments.