Skip to main content

PDF/A archiving

PDF/A (ISO 19005) is the form of PDF for documents that must stay readable for decades. It forbids anything whose meaning depends on something outside the file: a font installed on the machine, a colour profile named but not embedded, encryption, JavaScript. Archives, courts, public bodies and the e-invoicing formats ask for it.

PdfPinata does more than write the claim into the file. When a document claims a PDF/A level, the writer checks the document before it writes a byte, and throws if the document breaks a rule it can check. Everything on this page is in the core PdfPinata package.

Choose a level

Set PdfDocumentOptions.Conformance to a value of PdfAConformance:

ValueStandardUse it when
PdfA1BISO 19005-1A reader requires PDF/A-1. It is the strictest: PDF 1.4 only, no transparency, no JPEG 2000 images, no attachments.
PdfA2BISO 19005-2You have no reason to choose another level. Transparency and JPEG 2000 are allowed.
PdfA3BISO 19005-3The document carries attachments. PDF/A-3 is the only level that allows files of any kind inside the document, which is why e-invoices use it.
PdfA1A, PdfA2A, PdfA3Athe same three partsThe document must also be accessible. The A levels add the tagging rules of PDF/UA-1, so the document must be tagged. See Accessibility.

The B ("basic") levels promise that the document will look the same in the future. The A ("accessible") levels also promise that its content can be read by a screen reader and extracted in reading order.

Claim a level

A PDF/A document needs a title. Set Info.Title first, then make the claim:

src/SampleApp/Demos/ArchiveDemo.cs
// The output intent every PDF/A document needs. Written out rather than left to the
// default it now has, because this is the demo of the thing: an RGB document that sets
// nothing gets exactly these bytes anyway, which is what the FacturX demo shows by
// setting nothing.
var profile = PdfOutputIntents.SrgbProfile;

var document = new PdfDocument();

// A rule, not a nicety: a PDF/A document has to have a title, in the information dictionary
// and in the XMP packet alike, and the writer refuses without one.
document.Info.Title = "Archive";
document.Info.Author = "PdfPinata sample app";
document.Info.Subject = "A document claiming PDF/A-3b";
document.Info.Creator = "SampleApp";

// The claim. Everything else on this page follows from this one line.
document.Options.Conformance = PdfAConformance.PdfA3B;
document.Options.OutputIntentIccProfile = profile;
document.Options.OutputIntentIdentifier = "sRGB IEC61966-2.1";

The Archive demo sets the output intent by hand to show where it goes. You do not have to: an RGB document that names no profile gets the same sRGB profile automatically (see below).

Options.Conformance is checked when you save. To be told about a problem at the point where you make the claim, call ClaimConformance instead:

document.Info.Title = "Annual report 2026";
document.ClaimConformance(PdfAConformance.PdfA2B); // throws now if the title is missing

ClaimConformance checks what it can at once: the title, encryption, the colour mode, the PDF version under PDF/A-1, and, for an A level, whether the document is tagged. It then sets Options.Conformance. The save still checks everything again, because you can change the document after the claim.

Colour and the output intent

A PDF/A document that uses device colours (plain RGB or CMYK numbers) must embed an ICC profile that says what those numbers mean. This is the output intent.

  • RGB documents get one automatically. PdfColorMode.Rgb is the default colour mode. If you set no profile, PdfPinata embeds PdfOutputIntents.SrgbProfile, a small sRGB profile that ships in the core package, and names it sRGB IEC61966-2.1. Colours written as RGB with no other instruction are sRGB, so this describes your document accurately.
  • CMYK documents are refused without one. The same four CMYK numbers print as different colours on different presses and papers, so there is no correct default. If ColorMode is Cmyk, set Options.OutputIntentIccProfile to the profile your print work was made for.
  • PdfColorMode.Undefined is refused without one. In this mode each colour is written the way it was created, so one document can hold RGB and CMYK together, and no single profile describes both. Set ColorMode to Rgb, or supply a profile yourself.

A profile you set always wins. Options.OutputIntentIdentifier sets the name the profile is known by. If your pages paint colours that the profile cannot describe, for example CMYK content under an RGB profile, the save is refused and the message says which kinds of colour it found.

The XMP metadata

A document that makes a claim carries an XMP metadata packet. PdfPinata builds it at save time from document.Info (title, author, subject, keywords, dates), so the packet and the information dictionary always agree. A validator checks that they agree. The packet also holds the PDF/A identifier, which has no other place in the file.

To add your own properties, use document.AddMetadataContributor, or assign document.CustomizeMetadata. Both receive the XmpMetadata object just before it is written. CustomizeMetadata holds one delegate, so assigning it replaces any delegate already there. AddMetadataContributor adds to the list and never replaces anything, so prefer it in library code.

PDF/A accepts only properties from schemas it knows or that the file declares. A property in a namespace of your own must be declared in an extension schema. XmpMetadata.DeclareSchema declares the schema and writes its properties in one step:

src/SampleApp/Demos/ArchiveDemo.cs
document.CustomizeMetadata = metadata =>
{
metadata.Keywords = "archival, conformance, sample";

// Declared before it is used, and that order is the whole lesson. Clause 6.6.2.3.1
// holds every property in the packet to a schema the file either predefines or
// describes, so a document writing sample:demo without declaring it first opens
// perfectly in every reader and fails validation - for its metadata, not for anything a
// reader would notice. This demo did exactly that until veraPDF was pointed at its own
// output; DeclareSchema is what makes that mistake unrepresentable rather than merely
// fixed once.
metadata.DeclareSchema(new XmpExtensionSchema(
"PdfPinata sample app",
"http://example.invalid/sample/1.0/",
"sample",
new[]
{
// "internal" says the value is derived from the document's own content, which a
// note about which demo wrote the file is.
new XmpSchemaProperty("demo", "The demo that wrote this document",
XmpPropertyCategory.Internal, "Archive")
}));
};

If you write your own XML into XmpMetadata.AdditionalDescriptions instead, you must also write the pdfaExtension:schemas declaration yourself. Without it the file opens normally in every reader and fails validation.

To write an XMP packet for a document that claims nothing, set Options.WriteXmpMetadata = true.

What the writer refuses

Each refusal is an InvalidOperationException thrown from Save (or from ClaimConformance where the rule can be checked early). The message names the rule and tells you what to change.

RuleLevels
The document has a titleall
The document is not encryptedall
A CMYK or Undefined document has an output-intent profileall
The output intent describes every device colour the pages paintall
No image is set to interpolate (XImage.Interpolate)all
No transparency, including transparency reached through a form or a soft maskPDF/A-1
No JPEG 2000 imagePDF/A-1
The PDF version is no higher than 1.4, and CrossReferenceFormat is ClassicPDF/A-1
No embedded filesPDF/A-1, PDF/A-2
Every attachment is associated with the document, has a relationship and a media typePDF/A-3
The document is taggedA levels, plus the PDF/UA-1 checks

PDF/A-2 allows an attachment that is itself a PDF/A file. PdfPinata cannot prove that an attached file is PDF/A, so it refuses all attachments under PDF/A-2. Claim PDF/A-3 if you need to attach files. document.Attachments.Add writes the association, the relationship and the media type for you, so a document built through it passes the PDF/A-3 checks.

Validate with veraPDF

A successful save is not a validator's verdict. PdfPinata checks the rules it can check by looking at the document, and there are rules it does not check. Use veraPDF, the industry reference validator, before you rely on a claim. veraPDF reads the claim from the file's own metadata, so it holds each file to the level it claims. With Docker installed:

docker run --rm -v "$PWD:/data" verapdf/cli:v1.30.2 --format text /data/report.pdf

PdfPinata's own build runs veraPDF on one document for each level it can claim, and the build fails if any of them stops conforming.

Things to know

  • Fonts are always embedded. PdfPinata has no setting to turn font embedding off, so the rule that catches most PDF producers cannot be broken here. TrueType fonts are subset. Fonts with PostScript (CFF) outlines are embedded whole.
  • Setting a password raises the security level, and PDF/A forbids encryption. A document that has both a PDF/A claim and a password will not save. See Encryption.
  • The message names the page. A refusal for transparency, JPEG 2000 or an interpolated image says which page carries it.
  • Converting an existing PDF to PDF/A is not supported. The claim is for documents you build. PdfPinata does not re-embed missing fonts, convert colours or flatten transparency in someone else's file.
  • PDF/A-4 is not supported.
  • A PDF/A document can also claim PDF/UA-1. The two claims are independent: set both Options.Conformance and Options.UAConformance, or use an A level.
  • The packet is not compressed. Tools find it by scanning the file for its markers, so it is left readable on purpose.

See it in action

The Archive demo claims PDF/A-3b, prints the XMP packet it carries, and prints the real messages from five documents built to break one rule each.

The full Archive demo
src/SampleApp/Demos/ArchiveDemo.cs
var heading = new XFont(BundledFontResolver.SansFamily, 16, XFontStyle.Bold);
var label = new XFont(BundledFontResolver.SansFamily, 9.5, XFontStyle.Bold);
var body = new XFont(BundledFontResolver.SansFamily, 9);
var mono = new XFont(BundledFontResolver.MonoFamily, 7);

// The output intent every PDF/A document needs. Written out rather than left to the
// default it now has, because this is the demo of the thing: an RGB document that sets
// nothing gets exactly these bytes anyway, which is what the FacturX demo shows by
// setting nothing.
var profile = PdfOutputIntents.SrgbProfile;

var document = new PdfDocument();

// A rule, not a nicety: a PDF/A document has to have a title, in the information dictionary
// and in the XMP packet alike, and the writer refuses without one.
document.Info.Title = "Archive";
document.Info.Author = "PdfPinata sample app";
document.Info.Subject = "A document claiming PDF/A-3b";
document.Info.Creator = "SampleApp";

// The claim. Everything else on this page follows from this one line.
document.Options.Conformance = PdfAConformance.PdfA3B;
document.Options.OutputIntentIccProfile = profile;
document.Options.OutputIntentIdentifier = "sRGB IEC61966-2.1";

// Written verbatim after the descriptions this library builds, which is the seam a hybrid
// e-invoice goes through: ZUGFeRD and Factur-X are a PDF/A-3 file with an XML attachment
// and an extension schema saying what the attachment is. The FacturX demo is that, built
// through PdfPinata.EInvoice rather than by hand.
document.CustomizeMetadata = metadata =>
{
metadata.Keywords = "archival, conformance, sample";

// Declared before it is used, and that order is the whole lesson. Clause 6.6.2.3.1
// holds every property in the packet to a schema the file either predefines or
// describes, so a document writing sample:demo without declaring it first opens
// perfectly in every reader and fails validation - for its metadata, not for anything a
// reader would notice. This demo did exactly that until veraPDF was pointed at its own
// output; DeclareSchema is what makes that mistake unrepresentable rather than merely
// fixed once.
metadata.DeclareSchema(new XmpExtensionSchema(
"PdfPinata sample app",
"http://example.invalid/sample/1.0/",
"sample",
new[]
{
// "internal" says the value is derived from the document's own content, which a
// note about which demo wrote the file is.
new XmpSchemaProperty("demo", "The demo that wrote this document",
XmpPropertyCategory.Internal, "Archive")
}));
};

// ----- page one: what the claim means ------------------------------------------------------

var first = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(first))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("A document that has to last", heading, XBrushes.Black, 50, 60);

prose.DrawString(
"PDF/A is the profile of PDF for keeping things. It removes everything whose meaning "
+ "depends on something outside the file - a font installed on the machine, a colour "
+ "profile named rather than embedded, a JavaScript action, an external stream - so "
+ "that the bytes are the whole document. This file claims PDF/A-3b, which is why it "
+ "carries an ICC profile and an XMP packet it would otherwise have no use for.",
body, XBrushes.Black, new XRect(50, 80, 495, 70));

gfx.DrawString("The three profiles", label, XBrushes.Black, 50, 165);

(string Name, string Says)[] profiles =
{
("PdfA1B (ISO 19005-1)",
"The strictest. PDF 1.4 constructs only, so no transparency, no JPXDecode, no "
+ "cross-reference stream, and no embedded files at all."),
("PdfA2B (ISO 19005-2)",
"Defined against PDF 1.7. Transparency and JPXDecode are allowed. Still no "
+ "embedded file unless that file is itself PDF/A."),
("PdfA3B (ISO 19005-3)",
"As PDF/A-2b, and the only profile that may carry an attachment of any kind - "
+ "which is what hybrid e-invoices such as ZUGFeRD and Factur-X are built on.")
};

double y = 185;
foreach (var each in profiles)
{
gfx.DrawString(each.Name, label, XBrushes.MidnightBlue, 50, y);
prose.DrawString(each.Says, body, XBrushes.Black, new XRect(50, y + 6, 495, 34));
y += 52;
}

gfx.DrawString("Only the B levels are here", label, XBrushes.Firebrick, 50, y + 6);

prose.DrawString(
"The A levels - PDF/A-1a and its successors - additionally require a full tagged "
+ "structure tree. That is a different piece of work with a different point to it, "
+ "and it is the Accessibility demo. A document may claim both: PDF/A-3 says it will "
+ "still open in fifty years, PDF/UA-1 says it can be read aloud, and neither implies "
+ "the other.",
body, XBrushes.Black, new XRect(50, y + 20, 495, 60));

gfx.DrawString("The claim is checked, not stamped", label, XBrushes.Black, 50, y + 95);

prose.DrawString(
"Setting Options.Conformance does more than label the file. Before a byte is written "
+ "the writer walks the document and throws on the first rule it can settle by "
+ "looking - naming the rule and what to do about it. A library that writes "
+ "pdfaid:part 3 onto a file and leaves the caller to hear from a validator, or from "
+ "their customer, that it does not conform has made things worse rather than better. "
+ "The third page of this demo is those refusals, in their own words.",
body, XBrushes.Black, new XRect(50, y + 109, 495, 80));

gfx.DrawString("And what a successful save is still not", label, XBrushes.Black, 50, y + 200);

prose.DrawString(
"A validator's verdict. Some real rules are not checked here and are said plainly "
+ "rather than implied by silence: that a PDF/A-1 document uses no transparency means "
+ "walking every page's resources, and that no image is JPXDecode means walking every "
+ "image. Neither is done. What is checked is checked properly; veraPDF has the last "
+ "word.",
body, XBrushes.Black, new XRect(50, y + 214, 495, 60));
}

// ----- page two: the packet the file carries -----------------------------------------------

var second = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(second))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("The metadata packet", heading, XBrushes.Black, 50, 60);

prose.DrawString(
"Every fact in the packet also lives in the document information dictionary, and a "
+ "validator compares the two and complains when they disagree - so the packet is "
+ "built from the dictionary at save time rather than kept beside it and hoped about. "
+ "The conformance identifier is the one thing that has no dictionary entry at all, "
+ "which is why XMP is not optional for a document making a claim.",
body, XBrushes.Black, new XRect(50, 80, 495, 60));

gfx.DrawString("A namespace of your own needs declaring", label, XBrushes.Firebrick, 50, 148);

prose.DrawString(
"AdditionalDescriptions writes what it is given, verbatim, and PDF/A accepts no "
+ "property whose schema the file has not either predefined or described. So a "
+ "namespace nobody has heard of - this demo's own, or an invoice format's - is "
+ "declared in a pdfaExtension:schemas block naming every property before any of "
+ "them is written. The FacturX demo is that done for real, by "
+ "PdfPinata.EInvoice rather than by hand.",
body, XBrushes.Black, new XRect(50, 162, 495, 62));

gfx.DrawString("Written by a document just like this one", label, XBrushes.Black, 50, 236);

prose.DrawString(
"Read back out of a probe document built with the same options, saved to memory and "
+ "reopened. It is the bytes, not a description of them. Note that the packet is left "
+ "uncompressed: it carries those xpacket markers so a tool can find it by scanning "
+ "for them without parsing the PDF around it, and a compressed packet is invisible "
+ "to one. The probe claims conformance and adds nothing of its own, so what follows "
+ "is the packet a document gets for free.",
body, XBrushes.Black, new XRect(50, 250, 495, 48));

double y = 312;
foreach (var line in PacketOfAProbe(profile))
{
if (y > 780)
break;

gfx.DrawString(line, mono, XBrushes.Black, 50, y);
y += 8.6;
}
}

// ----- page three: the refusals ------------------------------------------------------------

var third = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(third))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("The refusals, in their own words", heading, XBrushes.Black, 50, 60);

prose.DrawString(
"Each block below is a real exception message, caught from a document built to break "
+ "one rule and then asked to save. Nothing here is quoted - the text is whatever the "
+ "library said when this demo was run, so a reworded message reaches this page by "
+ "itself.",
body, XBrushes.Black, new XRect(50, 80, 495, 44));

double y = 140;
foreach (var refusal in Refusals(profile))
{
gfx.DrawString(refusal.Broken, label, XBrushes.Firebrick, 50, y);

var measured = gfx.MeasureString(refusal.Message, mono);
var height = Math.Ceiling(measured.Width / 470.0) * 9.5 + 12;

prose.DrawString(refusal.Message, mono, XBrushes.Black,
new XRect(62, y + 8, 470, height));

y += height + 22;
}
}

// ----- page four: the output intent --------------------------------------------------------

var fourth = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(fourth))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("The output intent", heading, XBrushes.Black, 50, 60);

prose.DrawString(
"A PDF/A document using a device colour space has to embed an ICC profile saying what "
+ "its colours mean, and RGB - the default - is a device colour space. The profile is "
+ "embedded rather than referenced, and that is the whole point of the rule: naming a "
+ "well-known profile is exactly what PDF/A exists to stop, since the name means "
+ "nothing once the machine that understood it is gone.",
body, XBrushes.Black, new XRect(50, 80, 495, 62));

gfx.DrawString("An RGB document is given one, and that is new", label, XBrushes.Black, 50, 158);

prose.DrawString(
"Colours written as RGB by a library nobody told otherwise are sRGB - that is what "
+ "every reader assumes of them - so a PDF/A document whose ColorMode is Rgb and "
+ "which names no profile of its own is given PdfOutputIntents.SrgbProfile, and the "
+ "sRGB condition to name it by. That is a description rather than a guess, which is "
+ "why it can be done at all. Whatever you set yourself always wins.",
body, XBrushes.Black, new XRect(50, 172, 495, 62));

gfx.DrawString("CMYK is still refused, and so is Undefined", label, XBrushes.Firebrick, 50, 244);

prose.DrawString(
"The same four CMYK numbers are a different colour on every press, so there is "
+ "nothing true to supply and the writer says so instead - the last refusal on the "
+ "previous page is that one. ColorMode.Undefined writes each colour as the XColor "
+ "gave it, so a document may hold RGB and CMYK together and no one profile describes "
+ "it. Both name what to set.",
body, XBrushes.Black, new XRect(50, 258, 495, 62));

gfx.DrawString("What that profile is", label, XBrushes.Black, 50, 330);

prose.DrawString(
"456 bytes from the Compact ICC Profiles collection, released to the public domain "
+ "under CC0 - which is what makes it shippable at all. It states the true sRGB "
+ "primaries and samples the transfer curve at 42 points rather than stating it "
+ "parametrically, which is where the size goes. ICC version 2 rather than 4, because "
+ "PDF/A-1 predates version 4 and will not take one, so a v2 profile is the one that "
+ "serves every part. A document that matters should still embed the profile its "
+ "colours were actually made in.",
body, XBrushes.Black, new XRect(50, 344, 495, 76));

(string Field, string Value)[] facts =
{
("Profile size", profile.Length.ToString("N0") + " bytes"),
("Device class", "mntr (display), ICC version 2.1"),
("Colour space", "RGB, PCS XYZ"),
("Tags", "desc, cprt, wtpt, rXYZ, gXYZ, bXYZ, rTRC, gTRC, bTRC"),
("Licence", "CC0 1.0, public domain - the cprt tag says so itself"),
("/OutputConditionIdentifier", document.Options.OutputIntentIdentifier),
("/S", "/GTS_PDFA1, for every part of PDF/A and not only the first")
};

double y = 432;
foreach (var fact in facts)
{
gfx.DrawString(fact.Field, label, XBrushes.Black, 50, y);
gfx.DrawString(fact.Value, body, XBrushes.Black, 230, y);
y += 17;
}

gfx.DrawString("/GTS_PDFA1 for all three parts", label, XBrushes.Firebrick, 50, y + 16);

prose.DrawString(
"The output intent's subtype names the family rather than the part, so a PDF/A-3 file "
+ "carries /GTS_PDFA1 too. It reads like a mistake and is not; writing /GTS_PDFA3 is "
+ "the mistake.",
body, XBrushes.Black, new XRect(50, y + 30, 495, 34));

gfx.DrawString("Fonts were never optional", label, XBrushes.Black, 50, y + 78);

prose.DrawString(
"PDF/A requires every font to be embedded, and this library embeds every font with no "
+ "setting to disable it - so the rule that catches most producers out cannot be "
+ "broken here. TrueType outlines are subsetted; PostScript (CFF) outlines cannot be "
+ "and go in whole.",
body, XBrushes.Black, new XRect(50, y + 92, 495, 48));
}