PDF/A and PDF/UA
Archival (PDF/A), accessible (PDF/UA-1), e-invoice (Factur-X) and digitally signed output, claimed only when the file keeps it, and checked in the engines viewers are built on.
Claiming a level#
At class level or per render.
class InvoicePdf < Stationery::Document
conformance :pdf_a3b # archival: :pdf_a2b or :pdf_a3b
end
class ReportPdf < Stationery::Document
conformance :pdf_a3b, :pdf_ua1 # archival and accessible
metadata title: "Annual report 2026", lang: "en"
end
InvoicePdf.new(invoice).to_pdf(conformance: nil) # one render without the claim
InvoicePdf.new(invoice).to_pdf(conformance: :pdf_a2b) # or with another levelOne PDF/A level and PDF/UA-1 combine; two PDF/A levels or an unknown name raise ArgumentError where they are declared. Subclasses inherit the levels. Without conformance a render is byte for byte what it was before.
What each level guarantees#
| Level | Standard | What is written |
|---|---|---|
:pdf_a2b | ISO 19005-2, level B | An sRGB OutputIntent with the embedded ICC profile, pdfaid:part 2 / pdfaid:conformance B in XMP, the print flag (/F 4) on every annotation |
:pdf_a3b | ISO 19005-3, level B | The same with pdfaid:part 3; embedded files of any type are allowed, each with its /AFRelationship |
:pdf_ua1 | ISO 14289-1 | A tagged PDF, pdfuaid:part 1 in XMP, the title shown by the viewer, tab order by structure (/Tabs /S), a description (/Contents) on every link annotation |
Level B means the visual appearance is reproducible: every font is embedded (they always are, as subsets with a ToUnicode map) and colour is defined through the output intent. The bundled profile is the ICC's sRGB2014.icc. Transparency (opacity:, shadows, PNG alpha) is allowed from PDF/A-2 on.
With both a PDF/A level and PDF/UA-1, the XMP packet also describes the pdfuaid schema to PDF/A (pdfaExtension:schemas), which PDF/A requires of every schema it does not know.
A link's description is its URL, or Page 3 for a link inside the document.
A PDF/UA-1 render is tagged, so its objects are packed into object streams with a cross-reference stream (PDF 1.5), which PDF/A-2, PDF/A-3 and PDF/UA-1 allow: see Accessibility. to_pdf(object_streams: false) writes the classic cross-reference table.
Form fields conform as they are: their appearances draw with the document's embedded fonts and with paths, and every field carries an accessible name (/TU, from tooltip:, its label or its name). Under a level the form leaves out NeedAppearances and the ZapfDingbats entry it otherwise lists for viewers that redraw a button's mark.
What raises#
A file is never mislabelled.
| Situation | Level | Raises |
|---|---|---|
encrypt: | PDF/A | ArgumentError (PDF/A forbids encryption; PDF/UA allows it) |
attach_file / attachments: | PDF/A-2b | ArgumentError: part 2 only embeds PDF/A files, use :pdf_a3b |
No metadata title: or lang: | PDF/UA-1 | Stationery::ConformanceError listing what is missing |
An image or drawing without alt:, or with a blank one ("", whitespace alone) | PDF/UA-1 | ConformanceError naming the page (ISO 14289-1, 7.3); mark decoration with alt: false. Whitespace alone is refused although veraPDF accepts it |
In html, an <img> without an alt attribute; in markdown, an image without a description () | PDF/UA-1 | ConformanceError, as for image without alt:. <img alt=""> is decoration and is written as alt: false is; Markdown has no way to mark decoration, so use html or image for it |
A heading level that is skipped: a first heading that is not heading: 1, or a heading more than one level below the heading before it | PDF/UA-1 | ConformanceError naming the page, the level and the deepest level allowed there (ISO 14289-1, 7.4.2) |
A link annotation outside the structure tree: a link in the header row a table repeats on its next pages, canvas.link without a tag: | PDF/UA-1 | ConformanceError with an issue per link and page, naming the target and what painted it (ISO 14289-1, 7.18.5): link to https://example.com drawn by canvas.link without a tag: on page 1 is outside the structure tree (7.18.5). A link: is tagged and is allowed, in the body and in a header, a footer or a page_template |
A form field made without a font book (Forms::Field.new placed with canvas.widget) | every level | ConformanceError naming the field: it draws with the standard Helvetica, which is not embedded. Fields from text_field, select, checkbox, radio and signature_field draw with the document's embedded fonts and are allowed |
| A character no font has, in body text, a page template or a form field's value | every level | ConformanceError naming the character, its code point and the family: it draws as .notdef, which text may not reference (PDF/A 6.2.11.8, PDF/UA 7.21.8). Add a font or font_fallbacks that covers it, or declare the level with missing_glyphs: :replace (below). Whitespace a font lacks draws as a blank and is accepted |
begin
ReportPdf.new(report).to_pdf
rescue Stationery::ConformanceError => e
e.levels # => [:pdf_a3b, :pdf_ua1]
e.issues # => ["image on page 2 has no alt: text (alt: false marks decoration)"]
endHeading levels are read in the order of the structure tree, as a validator reads them: through sections, lists, table cells, columns and floats, from text(heading:), html and markdown alike. Going back up is free (heading: 3, then heading: 1). The headings of headers, footers and page templates are artifacts and do not count.
Every link annotation has to belong to a Link element of the structure tree. The links of the body do, and so does a link: painted by a header, a footer or a page template: the region is an artifact but for the link, which is a Link read after the content of its page, so a website or an address in the footer of every page keeps the level. A link in the header row a table repeats on its next pages has no element to belong to, and the render raises rather than claim the level. On a canvas, pass the Link element that holds what the link draws: canvas.link(x, y, w, h, url, tag: element) after canvas.tag(element) { … }.
PDF/A alone asks for neither alt texts, heading levels nor tagged links; a tagged render reports all three as warnings.
CMYK colours ([c, m, y, k]) and CMYK JPEGs are not covered by the sRGB output intent. They are reported as a ConformanceIssue warning, so strict refuses them; without strict the file is written and a validator will flag it.
A character no font has#
Raise, or draw a stand-in.
A name in a script the fonts do not cover (a customer in Tokyo on an invoice archived as PDF/A) is where the raise turns up in practice. Applications rescued it and rendered again without the claim:
begin
InvoicePdf.new(invoice).to_pdf
rescue Stationery::ConformanceError => e
logger.warn(e.message)
InvoicePdf.new(invoice).to_pdf(conformance: nil) # mislabelled no more, but no longer PDF/A
endmissing_glyphs: :replace keeps the claim instead: a character no font has is drawn as the first of U+FFFD (�), U+25A1 (□) and ? that the font drawing it has, inside the Span whose ActualText is the character, so the text still extracts, copies and reads aloud as written and nothing references .notdef. The stand-in has its own advance, so lines are measured as they are drawn.
class InvoicePdf < Stationery::Document
conformance :pdf_a3b, missing_glyphs: :replace # :raise is the default
end
InvoicePdf.new(invoice).to_pdf(conformance: :pdf_a3b, missing_glyphs: :replace) # per rendermissing_glyphs: | What a character no font has does |
|---|---|
:raise (default) | ConformanceError naming the character, its code point and the family |
:replace | Drawn as the font's stand-in in a Span with the character as ActualText; reported as a MissingGlyph warning whose stand_in is the character drawn, so strict still raises. A font that has none of the three, which a symbol font may not, still raises and says so |
veraPDF passes 2b, 3b and ua1 with each of the three stand-ins, in body text, headers and page templates, form field values and shaped text; the same files drawn with .notdef fail rules 6.2.11.8-1 and 7.21.8-1. Any other value raises ArgumentError. Without conformance the option changes nothing, and with :raise neither.
Factur-X / ZUGFeRD e-invoices#
One PDF for people and for accounting software.
A Factur-X invoice (ZUGFeRD in Germany) is a PDF/A-3b file with the invoice embedded as Cross Industry Invoice XML. factur_x does the PDF side in one line:
class InvoicePdf < Stationery::Document
metadata title: "Invoice", lang: "en"
factur_x(profile: :en16931) { @invoice.to_cii_xml } # evaluated in the document, per render
# factur_x :invoice_xml # or a method name
# factur_x File.read("factur-x.xml") # or the XML itself
def initialize(invoice) = (super(); @invoice = invoice)
end
InvoicePdf.new(invoice).to_pdf(factur_x: { xml:, profile: :extended }) # per render; nil for none| What | Written |
|---|---|
| Conformance | :pdf_a3b, added to declared levels such as :pdf_ua1; declaring :pdf_a2b raises |
| Embedded file | factur-x.xml, text/xml, description "Factur-X Invoice", /Params with size, checksum and modification date, listed in /EmbeddedFiles and /AF |
| XMP | fx:DocumentType INVOICE, fx:DocumentFileName, fx:Version, fx:ConformanceLevel, and the fx schema described through pdfaExtension:schemas |
profile: | fx:ConformanceLevel | File | /AFRelationship |
|---|---|---|---|
:minimum | MINIMUM | factur-x.xml | Data |
:basic_wl | BASIC WL | factur-x.xml | Data |
:basic | BASIC | factur-x.xml | Alternative |
:en16931 (default) | EN 16931 | factur-x.xml | Alternative |
:extended | EXTENDED | factur-x.xml | Alternative |
:xrechnung | XRECHNUNG | xrechnung.xml | Alternative |
filename:, version: (default "1.0") and relationship: override the defaults; ZUGFeRD 1.0 named its file ZUGFeRD-invoice.xml.
Stationery carries the XML, it does not write or validate it. Anything that does not start with <?xml or <rsm:CrossIndustryInvoice raises ArgumentError; what is inside comes from your invoicing code or a library made for it. The e-invoice example builds a minimal EN 16931 document from its line items in plain Ruby, marked as sample code.
Everything PDF/A-3b asks still applies: encrypt: raises. Form fields and a signature are fine. Without factur_x a render is byte for byte what it was.
Digital signatures#
Who issued the file, and that it has not changed.
sign signs the file with a certificate and its private key, so a reader can tell who issued it and that not a byte changed since. It needs nothing but Ruby's own openssl, loaded when the first signature is made.
class ContractPdf < Stationery::Document
sign certificate: -> { Rails.application.credentials.dig(:signing, :certificate) }, # PEM or OpenSSL object
key: -> { Rails.application.credentials.dig(:signing, :key) }, # read per render
chain: -> { [File.read("config/intermediate.pem")] },
reason: "Approved", location: "Malmö", contact: "[email protected]",
timestamp: "https://tsa.example/tsr" # optional: PAdES B-T
# sign { { certificate: signer.certificate, key: signer.key, field: "approval" } } # or a block for all of it
def view_template
text "Contract"
signature_field "approval", label: "Approved by" # `field: "approval"` signs this one
end
end
ContractPdf.new.to_pdf(sign: { certificate:, key:, passphrase: "…" }) # per render; nil for none- The signature is
/SubFilter /ETSI.CAdES.detached: a detached CMS over every byte of the file except the signature itself, SHA-256 with an RSA (PKCS #1 v1.5) or EC (ECDSA) key, with the signed attributes PAdES baseline B-B asks for (content type, message digest and the ESS signing-certificate-v2 that binds it to the certificate; the signing time is the dictionary's/M). The certificate and itschain:travel in the signature. certificate:andchain:take anOpenSSL::X509::Certificateor PEM,key:anOpenSSL::PKeyor PEM (passphrase:when it is encrypted). Any value may be a block or callable answering it for the document being rendered, andcertificate:,key:,chain:andpassphrase:a method name, so secrets are read when they are needed and never at class load. A key that is not the certificate's raisesArgumentError.field:names thesignature_fieldto sign, which keeps its appearance. Without it the signature is invisible: a field of its own (Signature1) whose widget has no size, on the first page.name:is the signer's name (the certificate's common name by default),at:the signing time (Time.now).timestamp: "https://tsa.example/tsr"asks an RFC 3161 time-stamping authority (TSA) to sign the signature value and the time it saw it, and carries that token in the signature's unsigned attributes: PAdES baseline B-T. The token proves when the signature existed, by a clock other than the signer's. Whether a viewer still accepts the signature once the signer's certificate has expired depends on its trust settings and on revocation data (B-LT), which the file does not carry. It is one HTTP request per render, with Ruby's ownnet/http;timestamp: { url:, username:, password:, hash: :sha256 }adds HTTP basic auth or another digest (:sha384,:sha512), andclient:(a callable given the request DER and answering the response DER) replaces the HTTP client. A TSA that cannot be reached, refuses, or answers for another request or another digest raisesStationery::SignatureError: the document is never written without the timestamp it was asked for.- The file is written once, with
contents_size:bytes kept free for the signature (8192, or 16384 with a timestamp, whose token brings the TSA's certificates along), which is then filled in place. A long certificate chain may need more; a signature that does not fit raises and says so. - A signed form asks viewers not to regenerate appearances (
NeedAppearancesis left out) and sets/SigFlags 3.encrypt:andconformancecombine withsign: the signature is the one string encryption leaves in the clear, and a signed PDF/A-3b or PDF/UA-1 file still validates. - Not covered: long-term validation data (LTV: the revocation answers and document timestamps of PAdES B-LT and B-LTA), a second signature, and signing a file that already exists. All of them need incremental updates; Stationery signs what it renders, once. Whether a viewer trusts the signer, or the TSA, is decided by their certificates and the viewer's trust list, not by the file.
bundle exec rake verify:signature signs examples/invoice.rb with a throwaway certificate and verifies it with openssl cms -verify and, when poppler is installed, pdfsig; with TSA_URL=http://timestamp.digicert.com it also timestamps a PDF/A-3b render at that authority and checks the token (verify:timestamp, not part of CI: it needs the network). verify:conformance validates signed PDF/A-3b and PDF/UA-1 renders with veraPDF. In tests, have_signature(name: "Acme Legal") checks that a signature covers the whole file and verifies against the certificate it carries.
| What | Written |
|---|---|
| Signature dictionary | /Type /Sig, /Filter /Adobe.PPKLite, /SubFilter /ETSI.CAdES.detached, /M, /Name, and /Reason, /Location, /ContactInfo when given |
/ByteRange | The whole file but the /Contents string: [0 a b c] with b + c the file's size |
/Contents | The CMS SignedData in hex, padded with zeros to contents_size: bytes, never encrypted |
| Signed attributes | content type, message digest (SHA-256), ESS signing-certificate-v2; no signing time |
| Unsigned attributes | With timestamp:, the TSA's RFC 3161 token over the signature value (id-aa-signatureTimeStampToken): PAdES baseline B-T |
| Form | /SigFlags 3, no NeedAppearances; the field's /V is the signature dictionary |
| Invisible signature | A field Signature1 whose widget is /Rect [0 0 0 0], /F 132 (print, locked), on the first page |
Inspector#signatures reports the token as timestamp: { time:, tsa:, valid: }: valid when it is over this signature and verifies against the TSA certificates it carries. TSA_URL=http://timestamp.digicert.com bundle exec rake verify:timestamp timestamps a signed PDF/A-3b render of the invoice example and checks it with the inspector, pdfsig and veraPDF; it needs the network, so it is not part of CI.
Check a signed file with poppler's pdfsig, or the signature itself with OpenSSL:
pdfsig -nocert contract.pdf
# - Signature Type: ETSI.CAdES.detached
# - Total document signed
# - Signature Validation: Signature is Valid.
openssl cms -verify -inform DER -in signature.der -content signed-bytes.bin -binary -noverify -out /dev/null
# CMS Verification successfulValidating with veraPDF#
The reference validator, through Docker.
bundle exec rake verify:conformance renders examples/invoice.rb and examples/e_invoice.rb as PDF/A-3b, and examples/report.rb, examples/form.rb, examples/article.rb (floats), examples/newsletter.rb (columns) and examples/accessible_report.rb (figures and a table with header cells) as PDF/A-3b plus PDF/UA-1, the invoice and the form once more with a signature and the report with a link in its footer, and validates them with veraPDF in a container (verapdf/cli); the gem's CI runs it on every push. Validate your own documents the same way:
docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /data/invoice.pdf
# PASS /data/invoice.pdf 3b
docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour ua1 /data/report.pdfFlavours are 2b, 3b and ua1; -v lists the failed rules by clause.
bundle exec rake verify:factur_x validates the e-invoice example with Mustang, which checks the PDF/A-3 file and the embedded XML together. It downloads the pinned Mustang CLI once into tmp/factur_x/ and runs it in a Java container:
docker run --rm -v "$PWD:/data" -w /data eclipse-temurin:21-jre \
java -jar Mustang-CLI-2.26.0.jar --action validate --source invoice.pdf --no-notices
# <summary status="valid"/>Viewers: stationery verify#
Does the file work where people open it?
veraPDF answers whether a file keeps PDF/A or PDF/UA, not whether it works where people open it. A file can pass PDF/A-3b and still trip a viewer, and open everywhere and still fail PDF/A. No validator answers "works in every viewer", so stationery verify reads the file with the engines the viewers are built on and holds what each reads against what the file holds, as pdf-reader reads it (stationery verify needs the pdf-reader gem, as inspect does):
| Engine | Viewers | Checks |
|---|---|---|
| qpdf | (the file's structure) | --check without an error or a warning; the page count |
| Poppler | Evince, Okular, the Linux desktop | pages; text; each page with content paints; every font embedded, with a ToUnicode map; attachments; signatures (pdfsig); a tagged file's structure tree |
| MuPDF | SumatraPDF, mobile viewers | pages; text; each page with content paints |
| PDFium | Chrome, Edge | pages; text; painting; links; outline; form fields and their appearances; signatures |
| pdf.js | Firefox | pages; text; painting operators; links; outline; form fields; attachments; a tagged file's structure tree |
| PDFKit | Preview, Safari (macOS) | pages; text; painting; links; outline; form fields |
Any error or warning from any engine fails the check. Text is found, not compared: both sides lose their whitespace and soft hyphens and are NFKC-normalized, and each text line of a page must be in what the engine reads of it, whole; a list's item may have its marker read apart, as a column of markers before the items, since the page draws them apart. A page drawn blank on purpose passes; one with text or an image that an engine paints all white fails. The few messages that are the check's environment and not the file (pdf.js warns it has no OffscreenCanvas in Node, pdfsig that the machine has no certificate store, Debian's mutool that it was built without colour management) are listed with why in Stationery::Verify::Allowlist.
stationery verify invoice.pdf report.pdf # every engine installed
stationery verify invoice.rb # render it first
stationery verify invoice.pdf --engines qpdf,poppler # these, and fail if one is missing
stationery verify locked.pdf --password 1234 --jsonIt exits 0 when every engine passes every file, 1 otherwise (or when no engine is installed), 2 on a usage error, and prints one line per file and engine, the problems under it, then the engines not installed with the line that installs each. The engines are tools you install; the gem depends on none of them:
brew install qpdf poppler mupdf # apt-get install qpdf poppler-utils mupdf-tools
pip install pypdfium2 # Python 3.11+; STATIONERY_PYTHON=/path/to/python names another
npm i pdfjs-dist # STATIONERY_PDFJS=/path/to/node_modules/pdfjs-distPDFKit needs macOS and Swift (xcode-select --install). Poppler and MuPDF take --password on their command lines, where other processes of the machine can see it; qpdf reads it from a file and the scripted engines from the environment. pdfsig takes no password, so an encrypted file's signatures are not read by Poppler. A Docker image has every engine but PDFKit:
docker run --rm -v "$PWD:/data:ro" ghcr.io/zoolutions/stationery-verify /data/invoice.pdfbundle exec rake verify:readers renders every example, the renders verify:conformance validates, an encrypted invoice, an invoice packed with object streams and a tagged report without them to tmp/readers/, and checks them (ENGINES=qpdf,pdfkit to choose). CI checks them with the image on Linux and with qpdf, Poppler, MuPDF and PDFKit on macOS. Adobe Acrobat cannot be driven in CI: before a release, the maintainer opens a fixed set of them in Acrobat Reader on macOS and Windows (see AGENTS.md).
In tests#
it { is_expected.to have_conformance(:pdf_a3b) }
it { is_expected.to have_conformance(:pdf_a3b, :pdf_ua1) }
it { is_expected.to have_factur_x(profile: :en16931) }
it { is_expected.to have_signature(name: "Acme Legal") }
assert_pdf_conformance pdf, :pdf_a3b
assert_factur_x pdf, profile: :en16931
assert_pdf_signature pdf, name: "Acme Legal"
Stationery::Testing::Inspector.new(pdf).conformance # => [:pdf_a3b, :pdf_ua1]
Stationery::Testing::Inspector.new(pdf).factur_x # => { profile: :en16931, filename: "factur-x.xml", … }
Stationery::Testing::Inspector.new(pdf).signatures # => [{ field: "approval", signer: "CN=…", valid: true, … }]These read the claim from the XMP packet, and for an e-invoice the embedded file it names. They do not validate the file: that is veraPDF's and Mustang's job. A signature is different: have_signature recomputes the digest over the signed bytes and verifies the CMS against the certificate it carries (not the certificate's trust).