Guide

# 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.

```ruby
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 level
```

One 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](https://stationery.zoolutions.llc/docs/pages#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](https://stationery.zoolutions.llc/docs/pages#accessibility-tagged-pdf). `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 (`![](photo.png)`) | 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 |

```ruby
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)"]
end
```

Heading 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](https://stationery.zoolutions.llc/docs/pages#accessibility-tagged-pdf) render reports all three as [warnings](https://stationery.zoolutions.llc/docs/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](https://stationery.zoolutions.llc/docs/warnings), so `strict` refuses them; without `strict` the file is written and a validator will flag it.

> **What a machine can check:** conformance :pdf_ua1 checks what a machine can check. Whether the alt texts describe the images, the headings say what follows them and the reading order makes sense is still yours to review.

## 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:

```ruby
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
end
```

`missing_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.

```ruby
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 render
```

| `missing_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](https://stationery.zoolutions.llc/docs/warnings) 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:

```ruby
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](https://stationery.zoolutions.llc/docs/examples#factur-x-e-invoice) 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.

> **Validate the XML too:** veraPDF checks the PDF/A-3 container. The invoice itself is checked by a Factur-X validator: rake verify:factur_x runs Mustang on the example, against the EN 16931 schema and business rules.

## 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.

```ruby
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: "legal@acme.test",
       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 its `chain:` travel in the signature.
- `certificate:` and `chain:` take an `OpenSSL::X509::Certificate` or PEM, `key:` an `OpenSSL::PKey` or PEM (`passphrase:` when it is encrypted). Any value may be a block or callable answering it for the document being rendered, and `certificate:`, `key:`, `chain:` and `passphrase:` a method name, so secrets are read when they are needed and never at class load. A key that is not the certificate's raises `ArgumentError`.
- `field:` names the `signature_field` to 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 own `net/http`; `timestamp: { url:, username:, password:, hash: :sha256 }` adds HTTP basic auth or another digest (`:sha384`, `:sha512`), and `client:` (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 raises `Stationery::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 (`NeedAppearances` is left out) and sets `/SigFlags 3`. `encrypt:` and `conformance` combine with `sign`: 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:

```shell
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 successful
```

> **Valid is not trusted:** A valid signature says the file is what the certificate's holder signed. Whether a viewer shows a green mark depends on the certificate: one issued by an authority on the viewer's trust list (the EU trusted lists, Adobe's AATL) is trusted, a self-signed one is valid but unknown.

## Validating 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](https://verapdf.org) in a container (`verapdf/cli`); the gem's CI runs it on every push. Validate your own documents the same way:

```shell
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.pdf
```

Flavours 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](https://www.mustangproject.org), 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:

```shell
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`.

```shell
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 --json
```

It 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:

```shell
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-dist
```

PDFKit 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:

```shell
docker run --rm -v "$PWD:/data:ro" ghcr.io/zoolutions/stationery-verify /data/invoice.pdf
```

`bundle 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

```ruby
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).