Recursive AI

Create PDF/A in C#

Create PDF/A-2 and PDF/A-3 files in C# from documents you generate or edit, attach XML data to PDF/A-3, and check any PDF. Limits first: it doesn’t convert arbitrary files.

To create PDF/A in C# with OnePDF, generate or edit the document and save it with PdfSaveOptions.PdfA. The save adds the color profile, the XMP metadata and the font embedding the standard asks for, checks the result, and writes PDF/A-2 or PDF/A-3 at level B, U or A. The limit first, because it decides whether this page is for you: OnePDF doesn’t convert arbitrary PDFs to PDF/A. It writes PDF/A for documents it made or edited and for files that already meet the requirements. A file from elsewhere that breaks a rule, say a font that isn’t embedded, is refused, with every violation listed. If your job is “take any PDF users upload and make it PDF/A”, you need a converter (see converting existing PDFs). If you generate the documents you archive, read on. OnePDF is pure C#, licensed MIT OR Apache-2.0. Every sample was compiled and run against OnePDF 1.11.0 on .NET 10, and every output passes the veraPDF validator.

PDF/A-2 or PDF/A-3, level B, U or A

PDF/A (ISO 19005) is the archival profile of PDF: everything needed to show the pages is inside the file, with nothing that depends on the viewer, the machine or a network. OnePDF writes parts 2 and 3, which are both based on PDF 1.7:

ChooseWhen
PDF/A-2bThe pages must look the same everywhere, years from now. The minimum.
PDF/A-2u2b plus a Unicode mapping for all text, so the archive can be searched and copied from. A sensible default for generated documents.
PDF/A-2a2u plus a tagged structure, for accessibility. See PDF/UA in C#, which writes both in one file.
PDF/A-3b, 3u, 3aThe same levels, and the file may carry attachments of any type, such as the XML behind an invoice.

PDF/A-1 (based on PDF 1.4) and PDF/A-4 (based on PDF 2.0) are not written.

Before you start

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

The layout package brings in RecursiveAI.OnePdf and RecursiveAI.OnePdf.Fonts. OnePDF targets .NET 10. The samples use photo.jpg (any JPEG), invoice.xml (any XML file) and report.pdf, a six-page report made with OnePdf.Layout.

Create a PDF/A document

Program.csOnePdf.Layout 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Layout;
 
LayoutDocument minutes = LayoutDocument.Compose(doc =>
{
    doc.Title("Minutes, October meeting").Author("Harbor committee").Language("en-US");
    doc.Page(page =>
    {
        page.Size(PageSize.A4);
        page.Margin(54);
        page.Content().Column(col =>
        {
            col.Spacing(10);
            col.Item().Heading(1).Text("Minutes, October meeting").FontSize(20).Bold();
            col.Item().Text("Present: six members. The committee approved the harbor trial report and set the spring dates.");
            col.Item().Image(File.ReadAllBytes("photo.jpg")).AltText("The north quay at low tide");
        });
    });
});
 
using (FileStream file = File.Create("minutes-pdfa.pdf"))
    minutes.Save(file, new PdfSaveOptions { PdfA = new PdfAOptions { Part = PdfAPart.Part2, Conformance = PdfAConformance.U } });
 
using PdfDocument check = PdfDocument.Open("minutes-pdfa.pdf");
Console.WriteLine($"claims PDF/A-{check.Xmp?.PdfAPart}{check.Xmp?.PdfAConformance?.ToLowerInvariant()}, title \"{check.Xmp?.Title}\"");
OutputConsole
claims PDF/A-2u, title "Minutes, October meeting"

veraPDF passes minutes-pdfa.pdf as PDF/A-2u. The save did four things on top of the layout:

  • Output intent. It added an sRGB output intent with an ICC profile that OnePDF generates from the published sRGB parameters, so colors have a defined meaning.
  • XMP metadata. It wrote the XMP packet with the PDF/A part and level and the title, author and dates from the document information, so the two agree.
  • Fonts. Layout text is already in embedded subsets of real fonts, with a Unicode map for each.
  • The check. It checked the finished file against the requirements OnePDF can verify, and would have thrown PdfAException with the violations instead of writing a file that claims PDF/A falsely.

The photo is embedded byte for byte as the JPEG it was: PDF/A allows JPEG, so nothing is decoded or recompressed.

Create PDF/A-3 with an XML attachment

PDF/A-3 lets a file carry other files, each tied to the document with a relationship such as Data, Source, Alternative or Supplement. Invoices are the usual case: the PDF people read and the XML a system reads, in one archived file. Lay out the invoice, attach the data, save:

Program.csOnePdf.Layout 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Layout;
 
// 1. Lay out the invoice into memory.
LayoutDocument invoice = LayoutDocument.Compose(doc =>
{
    doc.Title("Invoice 2026-0142").Author("Example Co.").Language("en-US");
    doc.Page(page =>
    {
        page.Size(PageSize.A4);
        page.Margin(54);
        page.Content().Column(col =>
        {
            col.Spacing(10);
            col.Item().Heading(1).Text("Invoice 2026-0142").FontSize(20).Bold();
            col.Item().Table(table =>
            {
                table.Columns(c => { c.Relative(); c.Constant(80); });
                table.CellPadding(4);
                table.HeaderRow(r => { r.Cell().Text("Item").Bold(); r.Cell().AlignEnd().Text("Amount").Bold(); });
                table.Row(r => { r.Cell().Text("Dredging survey"); r.Cell().AlignEnd().Text("1,200.00"); });
                table.Row(r => { r.Cell().Text("Quay inspection"); r.Cell().AlignEnd().Text("450.00"); });
            });
        });
    });
});
using var pdf = new MemoryStream();
invoice.Save(pdf);
 
// 2. Attach the machine-readable data and save as PDF/A-3u.
using PdfDocument doc = PdfDocument.Open(pdf.ToArray(), new PdfOpenOptions { AllowEditing = true });
doc.AttachFile("invoice.xml", File.ReadAllBytes("invoice.xml"), new PdfAttachmentOptions
{
    MimeType = "text/xml",
    Relationship = PdfAttachmentRelationship.Data,
    Description = "Invoice data",
    ModificationDate = File.GetLastWriteTimeUtc("invoice.xml"),   // PDF/A-3 needs a date on every attachment
});
doc.Save("invoice-pdfa3.pdf", new PdfSaveOptions { PdfA = new PdfAOptions { Part = PdfAPart.Part3, Conformance = PdfAConformance.U } });
 
// 3. Read it back.
using PdfDocument check = PdfDocument.Open("invoice-pdfa3.pdf");
foreach (PdfEmbeddedFile file in check.EmbeddedFiles)
    Console.WriteLine($"{file.Name}: {file.MimeType}, {file.Relationship}, {file.ReadContent().Length} bytes");
OutputConsole
invoice.xml: text/xml, Data, 114 bytes

veraPDF passes invoice-pdfa3.pdf as PDF/A-3u. The attachment is listed in the document’s embedded files and in the catalog’s /AF array with its relationship, MIME type and modification date, which PDF/A-3 requires. Without ModificationDate, OnePDF uses the document’s own modification or creation date.

ZUGFeRD and Factur-X invoices use this same mechanism, plus their own XMP extension schema that names the invoice profile. OnePDF writes only the XMP properties defined by the XMP specification itself and replaces a packet that doesn’t already claim the requested PDF/A level, so it doesn’t produce those formats as shipped in 1.11.0.

Edit a PDF and save it as PDF/A

The same save option works on a document you opened and changed. Here a report made earlier with OnePdf.Layout gets an “ARCHIVED” stamp in Helvetica, one of the 14 standard fonts that PDF viewers are expected to supply. PDF/A doesn’t allow relying on that, so the font must be embedded. Registering the RecursiveAI.OnePdf.Fonts package lets the save embed Liberation Sans Bold, a metric-compatible face, in its place:

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Fonts;
 
FontsPackage.Register();   // Liberation faces stand in for Helvetica, Times and Courier
 
// report.pdf was made with OnePdf.Layout. Stamp it and archive it.
using PdfDocument doc = PdfDocument.Open("report.pdf", new PdfOpenOptions { AllowEditing = true });
doc.AddTextStamp("ARCHIVED", doc.AddStandardFont("Helvetica-Bold"), 72,
    new PdfStampOptions { Opacity = 0.2, RotationDegrees = 45 });
doc.Save("report-archived.pdf", new PdfSaveOptions { PdfA = new PdfAOptions { Part = PdfAPart.Part2, Conformance = PdfAConformance.B } });
 
Console.WriteLine($"{doc.PageCount} pages stamped and saved as PDF/A-2b");
OutputConsole
6 pages stamped and saved as PDF/A-2b

veraPDF passes report-archived.pdf as PDF/A-2b, and pdffonts lists three fonts, all embedded subsets: the report’s two Liberation Sans faces and the stamp’s LiberationSans-Bold. The substitution applies only to standard fonts that OnePDF itself added, by AddStandardFont or in form-field appearances it generated. Fonts that came with a file are never replaced.

A PDF/A save also sets the print flag on annotations and generates missing form-field appearances. It rewrites the file, so existing signatures don’t survive it: sign after the PDF/A save, as an incremental update.

Check whether a PDF is PDF/A

A file’s PDF/A claim is in its XMP metadata, and a claim isn’t proof. doc.Xmp reads the claim, and CheckPdfA checks the file against a target without changing it, read-only documents included. Here it reads the archived minutes from above and the IRS Form W-9 (fw9.pdf):

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
 
(string Path, PdfAOptions Target)[] files =
[
    ("minutes-pdfa.pdf", new PdfAOptions { Part = PdfAPart.Part2, Conformance = PdfAConformance.U }),
    ("fw9.pdf", new PdfAOptions { Part = PdfAPart.Part2, Conformance = PdfAConformance.B }),
];
 
foreach (var (path, target) in files)
{
    using PdfDocument doc = PdfDocument.Open(path);
 
    // What the file claims, from its XMP metadata, and what a check against the target finds.
    string claim = doc.Xmp?.PdfAPart is { } part ? $"claims PDF/A-{part}{doc.Xmp.PdfAConformance?.ToLowerInvariant()}" : "no PDF/A claim";
    IReadOnlyList<PdfAViolation> problems = doc.CheckPdfA(target);
 
    Console.WriteLine($"{path}: {claim}, {problems.Count} violations of {Name(target)}");
    foreach (PdfAViolation v in problems.DistinctBy(v => v.Clause).Take(4))
        Console.WriteLine($"  {v.Clause}: {v.Message}");
}
 
static string Name(PdfAOptions o) => $"PDF/A-{(o.Part == PdfAPart.Part3 ? 3 : 2)}{o.Conformance.ToString().ToLowerInvariant()}";
OutputConsole
minutes-pdfa.pdf: claims PDF/A-2u, 0 violations of PDF/A-2u
fw9.pdf: no PDF/A claim, 33 violations of PDF/A-2b
  6.3.3: The Widget annotation has no appearance dictionary.
  6.2.11.4.1: The font ZapfDingbats is not embedded.
  6.6.4: The XMP packet does not identify the file as PDF/A-2b (pdfaid:part=-, pdfaid:conformance=-).
  6.4.2: The interactive form has an /XFA entry.

Each violation carries the ISO 19005-2 clause, a message, the object number and the page. A list stops at twenty findings of one kind. OnePDF checks what it can verify from the file’s structure. It is not a validator, and a clean check doesn’t prove conformance. Before you rely on an archive, run veraPDF, the open-source validator, over it. The tests behind OnePDF’s PDF/A writer use it for the same purpose.

Converting existing PDFs to PDF/A

“PDF to PDF/A” searches usually mean any file, from anywhere. PreparePdfA shows how far OnePDF gets with one. It makes every repair that needs no judgement, in memory, and returns what is still wrong:

Program.csOnePDF 1.11.0 · .NET 10
using OnePdf;
using OnePdf.Fonts;
 
FontsPackage.Register();
var target = new PdfAOptions { Part = PdfAPart.Part2, Conformance = PdfAConformance.B };
 
using PdfDocument doc = PdfDocument.Open("fw9.pdf", new PdfOpenOptions { AllowEditing = true });
int before = doc.CheckPdfA(target).Count;
 
// Make every repair that needs no judgement, in memory, and see what is still wrong.
IReadOnlyList<PdfAViolation> left = doc.PreparePdfA(target);
Console.WriteLine($"fw9.pdf: {before} violations before, {left.Count} after the repairs");
foreach (var group in left.GroupBy(v => v.Clause))
    Console.WriteLine($"  {group.Key} ({group.Count()}): {group.First().Message}");
OutputConsole
fw9.pdf: 33 violations before, 7 after the repairs
  6.2.11.4.1 (3): The font ZapfDingbats is not embedded.
  6.4.2 (1): The interactive form has an /XFA entry.
  6.5.1 (1): The document has a JavaScript name tree.
  6.1.13 (1): The integer 202302201302860 is outside the 32-bit range.
  6.8 (1): The file contains an embedded file; PDF/A-2 files written by OnePDF carry none. Use PDF/A-3 for attachments.

The repairs dealt with 26 findings: the output intent, the XMP claim and the missing form-field appearances. What remains needs decisions OnePDF doesn’t make for you. The W-9 refers to ZapfDingbats, a symbol font it doesn’t embed and for which no substitute ships. Its form also carries an XFA description and JavaScript, and it already has an embedded file, which PDF/A-2 written by OnePDF doesn’t carry. A converter would substitute a font, delete the scripts and the XFA, and rewrite whatever else breaks a rule.

That is what conversion products do. Aspose.PDF, for example, documents a Convert call that validates and converts, with a ConvertErrorAction to delete what can’t be converted and a font-substitution class for fonts that are neither embedded nor installed (Aspose.PDF docs). It is commercial software. Whatever you use, validate the result: conversion changes the file, and the archive should hold what you meant to keep.

With OnePDF, the dependable route is to archive the documents you generate: lay them out, or edit files OnePDF made, and save them as PDF/A, as in the samples above.

What makes a PDF/A save fail

  • DeviceCMYK color. The output intent OnePDF writes is RGB, so CMYK content is refused.
  • Fonts that aren’t embedded, other than standard fonts OnePDF added itself.
  • Encryption. PDF/A forbids it. Add Encryption = PdfEncryptionAction.Remove to the same save to write the file decrypted.
  • Attachments in PDF/A-2. Use PDF/A-3.
  • JavaScript, XFA, multimedia annotations and the other features the standard rules out.
  • A part that can’t be read, such as a damaged font program. The save fails instead of quietly leaving the part out and claiming conformance.

A PDF/A save reads and rewrites the whole document, so it holds the file in memory. The full list of what is written, checked and not checked, clause by clause, is in PDF/A.

Where next