Skip to content
Lamina PDF

Archival PDFs

This guide builds PDF/A documents, which you need when a file must stay readable and look the same for decades: contracts, records kept by law, e-invoices. Lamina PDF writes PDF/A as it generates a document; it doesn’t convert existing files to PDF/A.

PDF/A is the archival subset of PDF. A PDF/A file carries everything it needs to be displayed: its fonts are embedded, its colours are defined, and nothing depends on the viewer, the network or a password. Lamina PDF writes three PDF/A profiles:

  • PDF/A-2b (“basic”): the document looks the same everywhere.
  • PDF/A-2u (“Unicode”): PDF/A-2b, and every glyph maps to Unicode, so the text can be searched and copied.
  • PDF/A-3b: PDF/A-2b that may carry attached files of any kind.

Choose one with the profiles option (BuildOptions::with_profile in Rust). The builder then writes two things the standard asks for. It writes the PDF/A identification in the document’s XMP metadata, the XML packet in the file that describes it. That packet always agrees with the Info dictionary, the older list of the same facts (title, author, dates) that PDF readers also show.

It also writes an output intent: a declaration of what device colours mean. The intent embeds an ICC profile, the standard file format that describes a colour space. Lamina PDF’s output intent is sRGB, so RGB and gray colour are defined. DeviceCMYK is CMYK colour given as plain numbers, with no profile to say which inks they mean (Color::cmyk in Rust, four numbers in TypeScript), so the sRGB intent leaves it undefined.

finish() fails with a coded error for each rule the document breaks:

Rule Error
Every font is embedded (no standard 14 font) font-not-embedded
No DeviceCMYK colour or CMYK image (the output intent is sRGB) device-color-without-output-intent
PDF/A-2u: every glyph maps to Unicode glyph-without-unicode
PDF/A-2b, PDF/A-2u: no attached files embedded-file-forbidden
Every attached file has a MIME type embedded-file-without-mime-type
The PDF version is 1.7 or lower profile-version-conflict

For CMYK colour under PDF/A, give the colour an ICC profile instead (Color::icc in Rust): an ICC-based colour defines itself. Transparency is allowed in PDF/A-2 and PDF/A-3.

The letter below is written twice, as PDF/A-2b and as PDF/A-2u. It embeds its font, places a photo and draws a translucent band across the top. The PDF/A-2u copy is also PDF/UA-1, the accessibility standard (see Accessible PDFs): a document can be both archival and accessible.

packages/sdk/examples/pdf-a-archive.ts
// An archival letter, written as PDF/A-2b and as PDF/A-2u that is also PDF/UA-1.
import { Builder, Font, Image, PAGE_SIZES, type Profile, type Surface } from "@laminapdf/sdk";
const MARGIN = 56;
const BAND_HEIGHT = 70;
const TITLE_BASELINE = 110;
/** The top of the letter's text and of the photo beside it, and the height of both. */
const BLOCK_TOP = 130;
const BLOCK_HEIGHT = 120;
const LETTER_WIDTH = 300;
const PHOTO_LEFT = 380;
const PHOTO_WIDTH = 160;
const FOOTER_BASELINE = 300;
const TITLE = 22;
const BODY = 11;
const BAND_OPACITY = 0.35;
const PHOTO_ALT = "A photograph of the sprockets, packed in a box";
const LETTER =
"Thank you for your order of 12 sprockets. This letter confirms the delivery date and the price we agreed. " +
"Keep it: PDF/A files look the same in any viewer, now and in the future.";
/** PDF/A-2b, or PDF/A-2u (whose text maps to Unicode) with PDF/UA-1. */
const PROFILES: Record<"2b" | "2u", Profile[]> = {
"2b": ["PDF/A-2b"],
"2u": ["PDF/A-2u", "PDF/UA-1"],
};
/**
* The letter's bytes. Needs `init({ text: true })`.
*
* @param level - `2b` or `2u`.
* @param notoSans - the bytes of the Noto Sans font file.
* @param photo - the bytes of a JPEG photo.
*/
export function pdfAArchive(level: "2b" | "2u", notoSans: Uint8Array, photo: Uint8Array): Uint8Array<ArrayBuffer> {
using b = new Builder({ profiles: PROFILES[level], tagged: true });
b.metadata({ title: "Order confirmation 42", author: "Acme Widgets Ltd" });
b.lang("en-GB");
const font = Font.fromBytes(notoSans);
b.font(font);
const page = b.page("A4");
draw(page.surface(), font, Image.jpeg(photo));
page.finish();
using doc = b.finish();
return doc.save();
}
function draw(s: Surface, font: Font, photo: Image): void {
// A translucent band across the top: decoration, and transparency, which PDF/A-2 allows.
const [width] = PAGE_SIZES.A4;
s.artifact("layout", (s) => {
s.save().opacity(BAND_OPACITY, 1).fillColor([0.12, 0.47, 0.78]);
s.path((p) => p.rect([0, 0, width, BAND_HEIGHT])).fill();
s.restore();
});
s.tag("H1", (s) => s.text("Order confirmation 42", { font, size: TITLE, at: [MARGIN, TITLE_BASELINE] }));
s.tag("P", (s) => s.textBox(LETTER, { font, size: BODY, box: [MARGIN, BLOCK_TOP, LETTER_WIDTH, BLOCK_HEIGHT] }));
s.image(photo, [PHOTO_LEFT, BLOCK_TOP, PHOTO_WIDTH, BLOCK_HEIGHT], PHOTO_ALT);
s.tag("P", (s) =>
s.text("Acme Widgets Ltd, 1 Example Street, London", { font, size: BODY, at: [MARGIN, FOOTER_BASELINE] }),
);
}

See the output.

PDF/A-3 lets a document carry other files, provided each one is associated with the document and says how it relates to it: its source, the data it was made from, or another representation of the same content. E-invoicing formats such as Factur-X and ZUGFeRD use this to put the machine-readable invoice (XML) inside the human-readable one (PDF).

attach(name, bytes, mime, { relationship }) adds a file, its MIME type and its relationship, and lists it in the document’s attachments. The example uses a made-up XML schema; a real e-invoice follows the EN 16931 standard and adds its own XMP metadata.

packages/sdk/examples/pdf-a3-invoice-with-xml.ts
// A PDF/A-3b invoice that carries the same invoice as XML, attached for software to read.
import { Builder, Font, PAGE_SIZES, type Surface } from "@laminapdf/sdk";
const MARGIN = 56;
/** The page's right edge, less the margin: where the rule under the items ends. */
const RIGHT = PAGE_SIZES.A4[0] - MARGIN;
const AMOUNTS = 460;
const ROW = 22;
const TITLE_BASELINE = 80;
const FIRST_ROW = 130;
/**
* How far above the next row's baseline the rule sits, and how far below it the total and
* the note are.
*/
const RULE_RISE = 7;
const TOTAL_DROP = 18;
const NOTE_DROP = 68;
/** How far left of the amounts column the total starts, to fit its "Total" label. */
const TOTAL_INDENT = 40;
const TITLE = 24;
const TOTAL = 14;
const BODY = 11;
const ITEMS = [
["Sprockets", 12, 1.25],
["Gadget", 1, 79.9],
["Delivery", 1, 4.5],
] as const;
/** The invoice's total. */
function total(): number {
return ITEMS.reduce((sum, [, qty, price]) => sum + qty * price, 0);
}
/** The invoice as XML, in a made-up schema (a real e-invoice follows EN 16931, as Factur-X does). */
function invoiceXml(): string {
const lines = ITEMS.map(
([item, qty, price]) => ` <line item="${item}" quantity="${qty}" price="${price.toFixed(2)}"/>\n`,
);
return `<?xml version="1.0" encoding="UTF-8"?>\n<invoice number="42" currency="EUR">\n${lines.join("")} <total>${total().toFixed(2)}</total>\n</invoice>\n`;
}
/**
* The invoice's bytes. Needs `init({ text: true })`.
*
* @param notoSans - the bytes of the Noto Sans font file.
*/
export function pdfA3InvoiceWithXml(notoSans: Uint8Array): Uint8Array<ArrayBuffer> {
using b = new Builder({ profiles: ["PDF/A-3b"] });
b.metadata({ title: "Invoice 42", author: "Acme Widgets Ltd" });
const font = Font.fromBytes(notoSans);
b.font(font);
const page = b.page("A4");
draw(page.surface(), font);
page.finish();
b.attach("invoice-42.xml", new TextEncoder().encode(invoiceXml()), "text/xml", {
relationship: "alternative",
description: "Invoice 42 as XML",
});
using doc = b.finish();
return doc.save();
}
function draw(s: Surface, font: Font): void {
s.text("Invoice 42", { font, size: TITLE, at: [MARGIN, TITLE_BASELINE] });
let y = FIRST_ROW;
for (const [item, qty, price] of ITEMS) {
s.text(`${qty} × ${item}`, { font, size: BODY, at: [MARGIN, y] });
s.text(`€ ${(qty * price).toFixed(2)}`, { font, size: BODY, at: [AMOUNTS, y] });
y += ROW;
}
const rule = y - RULE_RISE;
s.strokeColor([0.5])
.path((p) => p.moveTo(MARGIN, rule).lineTo(RIGHT, rule))
.stroke();
s.text(`Total € ${total().toFixed(2)}`, { font, size: TOTAL, at: [AMOUNTS - TOTAL_INDENT, y + TOTAL_DROP] });
s.text("The invoice is attached as invoice-42.xml.", {
font,
size: BODY,
at: [MARGIN, y + NOTE_DROP],
link: { target: { type: "uri", uri: "https://example.com/invoices/42" }, contents: "Invoice 42 online" },
});
}

See the output.

Every example on this site that claims a PDF/A profile is validated each night with veraPDF, the open-source PDF/A and PDF/UA validator the PDF Association backs.

Next: open a PDF and change it, in Edit a PDF.