stationery 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +251 -23
  4. data/lib/stationery/canvas.rb +22 -1
  5. data/lib/stationery/cli/render.rb +19 -4
  6. data/lib/stationery/document.rb +69 -9
  7. data/lib/stationery/elements/forms.rb +11 -6
  8. data/lib/stationery/elements/rich.rb +13 -6
  9. data/lib/stationery/elements.rb +11 -3
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/font.rb +28 -18
  12. data/lib/stationery/forms/acro_form.rb +95 -25
  13. data/lib/stationery/forms/appearance.rb +92 -30
  14. data/lib/stationery/forms/field.rb +20 -11
  15. data/lib/stationery/forms/metrics.rb +3 -17
  16. data/lib/stationery/forms/typeface.rb +113 -0
  17. data/lib/stationery/html/document.rb +8 -2
  18. data/lib/stationery/html/tree_builder.rb +34 -4
  19. data/lib/stationery/images/jpeg.rb +2 -1
  20. data/lib/stationery/layout/image.rb +9 -1
  21. data/lib/stationery/layout/table/cell.rb +14 -4
  22. data/lib/stationery/layout/table.rb +72 -14
  23. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  24. data/lib/stationery/markdown/block_parser.rb +9 -3
  25. data/lib/stationery/markdown/document.rb +8 -2
  26. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  27. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  28. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  29. data/lib/stationery/markdown/inline_parser.rb +9 -3
  30. data/lib/stationery/minitest.rb +16 -0
  31. data/lib/stationery/pdf/assembler.rb +51 -11
  32. data/lib/stationery/pdf/attachments.rb +76 -0
  33. data/lib/stationery/pdf/conformance.rb +141 -0
  34. data/lib/stationery/pdf/factur_x.rb +117 -0
  35. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  36. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  37. data/lib/stationery/pdf/serializer.rb +3 -1
  38. data/lib/stationery/pdf/signature/cms.rb +87 -0
  39. data/lib/stationery/pdf/signature.rb +167 -0
  40. data/lib/stationery/pdf/stream.rb +5 -3
  41. data/lib/stationery/pdf/types.rb +4 -0
  42. data/lib/stationery/pdf/xmp.rb +149 -0
  43. data/lib/stationery/resources.rb +3 -0
  44. data/lib/stationery/rich/nesting.rb +30 -0
  45. data/lib/stationery/rich/renderer/indents.rb +42 -0
  46. data/lib/stationery/rich/renderer.rb +22 -6
  47. data/lib/stationery/svg/clip_path.rb +69 -0
  48. data/lib/stationery/svg/document.rb +34 -26
  49. data/lib/stationery/svg/painter.rb +11 -0
  50. data/lib/stationery/svg/parser.rb +51 -11
  51. data/lib/stationery/svg/region.rb +25 -0
  52. data/lib/stationery/svg/shapes.rb +2 -0
  53. data/lib/stationery/svg/style.rb +25 -14
  54. data/lib/stationery/svg/viewport.rb +78 -0
  55. data/lib/stationery/svg/walker.rb +144 -0
  56. data/lib/stationery/tagging/element.rb +1 -1
  57. data/lib/stationery/testing/inspector.rb +123 -0
  58. data/lib/stationery/testing/matchers.rb +92 -0
  59. data/lib/stationery/text/wrapper.rb +12 -3
  60. data/lib/stationery/version.rb +1 -1
  61. data/lib/stationery/warnings.rb +8 -0
  62. data/lib/stationery.rb +11 -0
  63. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f9e83f23437589a026e0a9a2fde163a39a2c4151c434afc5bdbea38a98891dd
4
- data.tar.gz: 67e42695ae205e05049441490b89b908f6468ba94e00c095c1de8eaeae986d65
3
+ metadata.gz: '04892e3a1ad9e6da0174cb033f2a57a048c83c77e57e72aff4a98ccfa48e1ba7'
4
+ data.tar.gz: f9ee80ed8edd77108f3d6ae4f19f5a9d537000e6cf0e1efd8707e2682e89faac
5
5
  SHA512:
6
- metadata.gz: 40756fe30e4c2d400fab7402e490609cf313e8ae06c5317489f6b0337f96fc5ad00d8f2c8b05b348812955b109cc0f9685f9c1668b8f307d1fd16ac9492facad
7
- data.tar.gz: 1a7938f3b608b68df057d3385d5824dc6a57338978a76ad39bed42701838ee2a408db1c2d30daccf3a834b03bea9f6d1441ef6405691dff0090611d26cbe05a0
6
+ metadata.gz: d5c07272f2ff4480d35caa3decebc77336e12b3e8b7aea4f8ddd06f47529c12438a86606fb41350bf9d0eb59915e1cee13505acf75ef9e7544135ddd6878d5e6
7
+ data.tar.gz: 4901d1100e09107496cf9d83b6ac0c2a767aacc43090b1c5b87137c35667c3bd009adc8a5c627f09d50bfe1d46dc6172abaddb9dfe0cdfc9fe7426425471814b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 (2026-09-28)
4
+
5
+ Forms in the document's fonts, digital signatures, a much faster text and table pipeline, and hardening for untrusted input.
6
+
7
+ - Digital signatures: `sign certificate:, key:, chain:, passphrase:, reason:, location:, contact:, name:, field:, at:` at class level (values may be callables or method names, resolved per render) or `to_pdf(sign:)`. Fills a named `signature_field` or adds an invisible one, writes `/ETSI.CAdES.detached` CMS with the ESS signing-certificate-v2 attribute (PAdES baseline B-B shape) over the whole file, RSA or ECDSA with SHA-256, built on the stdlib `openssl` (required only when signing). Works with encryption and under PDF/A-3b and PDF/UA-1; `/SigFlags 3` on the form. Verified with `openssl cms -verify`, `pdfsig` and veraPDF; `rake verify:signature`. `Inspector#signatures` (validates the digest and the CMS), `have_signature`, `assert_pdf_signature`. No timestamps, LTV or multiple signatures yet.
8
+ - Forms: field appearances use the document's fonts, embedded and Identity-H encoded with per-character fallback, so values in any script the fonts cover render; check marks and radio dots are vector paths, so no standard-14 font is referenced. An editable field keeps printable ASCII and Latin-1 (plus a select's options) in its font, a `read_only:` field only its value. Every field gets `/TU` from `tooltip:`, `label:` or its name; `NeedAppearances` is dropped under a conformance level. Forms are now allowed under PDF/A-3b and PDF/UA-1 (veraPDF passes `examples/form.rb` as both). Documents without fields are byte-identical.
9
+ - Performance: the wrapper measures each segment once and `Font#width_of` memoises advance and kerning together, keyed by feature-tag identity, undoing the 0.7.0 regression (an 8-page text document 163 → 45 ms, byte-identical); tables carry column widths and row heights across page breaks (also fixing column widths drifting between pages), stroke each cell border as one path (the 1,500-row table's PDF 236 → 134 KB) and place rows on a grid only when a page paints them: 913 → 399 ms. A deterministic metrics gate (`rake metrics`, `benchmark/baseline.json`: allocations, pages, bytes) runs in CI; README performance figures now reproduce.
10
+ - Untrusted input: `html` and `markdown` take `max_depth:` (64); deeper elements, block quotes and lists are flattened into the deepest kept with a `Warnings::NestingLimit`, never a `SystemStackError`. Markdown inline parsing is bounded (brackets, labels, emphasis), so pathological input finishes in milliseconds instead of minutes. SVG keeps 128 levels and caps `use` chains at 32.
11
+ - `image` with an IO source no longer raises `NameError` for `Pathname` in plain Ruby; a spec now renders every feature in a fresh interpreter without the test bundle so a missing stdlib require cannot hide again.
12
+ - SVG: `use`, `symbol`, nested `svg` and `clipPath` shipped in 0.8.0; the docs Forms page and the docs image now include the example fonts, so every `/examples/<name>.pdf` renders on the site.
13
+
14
+ ## 0.8.0 (2026-09-28)
15
+
16
+ Compliance: PDF/A-3b, PDF/UA-1 and Factur-X e-invoices, verified with veraPDF and Mustang in CI.
17
+
18
+ - Factur-X / ZUGFeRD: `factur_x(profile: :en16931) { @invoice.to_cii_xml }` (a block, a method name or the XML itself; also `to_pdf(factur_x:)`) embeds the invoice XML as `factur-x.xml` (`xrechnung.xml` for `:xrechnung`) with the relationship the profile requires, writes the `fx:` XMP properties with their extension-schema description and turns on PDF/A-3b. Profiles `:minimum`, `:basic_wl`, `:basic`, `:en16931`, `:extended`, `:xrechnung`. The gem does not build the XML; `examples/e_invoice.rb` shows a minimal EN 16931 document. `Inspector#factur_x`, `have_factur_x`, `assert_factur_x`; `rake verify:factur_x` validates with Mustang.
19
+ - PDF/A and PDF/UA: `conformance :pdf_a3b` (or `:pdf_a2b`) and `conformance :pdf_ua1`, combinable, at class level or `to_pdf(conformance:)`. PDF/A writes an sRGB output intent (the ICC's `sRGB2014.icc`, bundled with its licence), `pdfaid` identification and printable link annotations, and refuses encryption and, under A-2b, attachments. PDF/UA turns `tagged` on, requires `metadata title:` and `lang:`, writes `pdfuaid:part 1`, tab order and link descriptions, and raises `ConformanceError` for a figure without `alt:`. CMYK colour under PDF/A is reported as `Warnings::ConformanceIssue`. Form fields are refused under every level until their appearances embed their fonts. `Inspector#conformance`, `have_conformance`, `assert_pdf_conformance`; `rake verify:conformance` runs veraPDF through Docker, and CI runs it on the examples.
20
+ - XMP metadata: every document carries an XMP packet mirroring the Info dictionary (`dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, `xmp:CreateDate`/`ModifyDate`/`MetadataDate`, `xmp:CreatorTool`, `pdf:Producer`, `pdf:Keywords`) as an uncompressed `/Metadata` stream; `metadata xmp: false` or `to_pdf(xmp: false)` opts out. `Inspector#xmp` and `#xmp_values`.
21
+ - Embedded files: `attach_file "terms.pdf", data, mime:, description:, relationship:, modified_at:` at class level, `to_pdf(attachments: [...])` per render. Each file is a `/Filespec` in the catalog's `/EmbeddedFiles` name tree and `/AF`, with `/AFRelationship`, size, checksum and date; streams are compressed and encrypted with the document. `Inspector#attachments`, `have_attachment`, `assert_pdf_attachment`.
22
+ - SVG: `use` (`href` or `xlink:href`, onto shapes, groups, text, symbols and other `use` elements, with cycle detection), `symbol` and nested `svg` viewports (`viewBox`, `preserveAspectRatio` with `meet`/`slice`/`none`, clipped unless `overflow="visible"`), and `clipPath` (`clip-path="url(#id)"` on shapes, groups, text and `use`; `clip-rule`, `clipPathUnits="objectBoundingBox"`, nested clip paths). The `color` property feeds `currentColor`, so one sprite symbol can be drawn in several colours. `image`, `mask`, `pattern`, `filter` and `textPath` stay unsupported and reported.
23
+ - CLI: `stationery render file.rb` renders the document the file itself defines, so a file that subclasses a document from another file no longer asks for `--class`.
24
+
3
25
  ## 0.7.0 (2026-09-28)
4
26
 
5
27
  Typography and images: hyphenation, OpenType features, oversized-image warnings, page labels.
data/README.md CHANGED
@@ -70,7 +70,7 @@ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
70
70
  InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
71
71
  ```
72
72
 
73
- `examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage and event flyer
73
+ `examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage, event flyer and Factur-X e-invoice
74
74
  ([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
75
75
  renders them all, or render one with `stationery render examples/report.rb`.
76
76
 
@@ -85,7 +85,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
85
85
  | `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. |
86
86
  | `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. |
87
87
  | `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:)` | JPEG or PNG, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. Drawn at more than twice `max_ppi:` (300) it is reported as oversized; `downscale: true` resamples a PNG to that resolution instead. |
88
- | `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:`. Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors). |
88
+ | `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:` (or the `color` an element sets). Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors); `use`, `symbol` sprites and nested `svg` viewports (`viewBox`, `preserveAspectRatio`); `clipPath` (both `clipPathUnits`). |
89
89
  | `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
90
90
  | `stack(gap:, align:) { }` | A base with layers painted over it: ordinary children set the height, `layer` children float over them and take no space. Moves to the next page whole. |
91
91
  | `layer(top:, right:, bottom:, left:, width:, height:, **box) { }` | Inside `stack`: a box placed by insets from the stack's edges, in points or as a fraction (`0.4`, `1/3r`) of its width/height; negative insets overhang. Takes every box option (`rotate:`, `shadow:`, `radius:`, …). |
@@ -97,13 +97,13 @@ renders them all, or render one with `stationery render examples/report.rb`.
97
97
  | `keep_with_next: true \| points` | On `text`, `box` or `group`: never end a page with this node; with a number, keep at least that many points of what follows with it. |
98
98
  | `text_style(**style) { }` | Default text style for a block. |
99
99
  | `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links; `rotate(degrees, around:) { }` and `transform([a, b, c, d, e, f]) { }` blocks. |
100
- | `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, border:, background:, radius:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
100
+ | `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, border:, background:, radius:, tooltip:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
101
101
  | `checkbox(name, checked:, size:, label:, at:)` | An interactive check box, with an optional label drawn to its right. |
102
102
  | `radio(name, value, checked:, size:, label:, at:)` | One choice of a radio group: radios sharing `name` form one field whose value is the checked `value`. |
103
103
  | `select(name, options:, value:, width:, height:, editable:, at:)` | A drop-down (combo box); `editable: true` also accepts typed values. |
104
104
  | `signature_field(name, width:, height:, label:, at:)` | An empty signature field for the signer to fill, drawn as a rule over the label. |
105
- | `html(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | Rich text from HTML (ActionText/Trix, CMS output): paragraphs, headings, lists, quotes, code, rules, tables, images, inline marks and links. See [HTML and Markdown](#html-and-markdown). |
106
- | `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | The same from CommonMark (plus GFM tables and strikethrough). |
105
+ | `html(source, styles:, gap:, images:, base_path:, bookmarks:, links:, max_depth:)` | Rich text from HTML (ActionText/Trix, CMS output): paragraphs, headings, lists, quotes, code, rules, tables, images, inline marks and links. See [HTML and Markdown](#html-and-markdown). |
106
+ | `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:, max_depth:)` | The same from CommonMark (plus GFM tables and strikethrough). |
107
107
 
108
108
  Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
109
109
  `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `features` (OpenType feature tags, e.g. `%i[smcp onum]`), `hyphenate` (`true` for English, or `"de"`, `"sv"`; default off), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
@@ -138,6 +138,33 @@ end
138
138
  `DroppedLink` warning. `links: %w[http https]` changes the list, `links: :all` keeps every href
139
139
  from a trusted source.
140
140
  - `gap:` spaces the blocks (default 6); `bookmarks: true` adds h1–h3 to the PDF outline.
141
+ - `max_depth:` (default 64) is how deep the source may nest: HTML elements inside one another,
142
+ or Markdown block quotes and lists. What lies deeper is flattened into the deepest element
143
+ kept, so its text stays and its structure goes, and a `NestingLimit` warning says how deep the
144
+ source went. Block quotes and lists also stop indenting after twelve levels, where they would
145
+ leave their text no width; that is reported the same way.
146
+
147
+ #### Untrusted input
148
+
149
+ `html` and `markdown` are meant for content you did not write (a CMS body, a comment, an
150
+ ActionText field), and nothing in that content can reach outside the document or take the
151
+ process down:
152
+
153
+ - **No requests.** Remote images are never fetched; an image is read only from `images:` or from
154
+ under `base_path:`, and a path that climbs out of it is skipped (`SkippedImage`).
155
+ - **No surprising links.** Only `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`) become
156
+ links (`DroppedLink` for the rest), so a `javascript:` or `file:` href is plain text.
157
+ - **No scripts or styles.** `script`, `style`, `template` and `title` content is dropped, raw HTML
158
+ inside Markdown stays literal text, and no CSS is evaluated beyond `text-align` and inline
159
+ bold and italic.
160
+ - **Bounded nesting.** Five thousand nested `<div>`s, block quotes or lists render flattened,
161
+ with a `NestingLimit` warning, instead of exhausting the stack (the `svg` element guards its
162
+ own nesting the same way, at 128 levels). Emphasis nests without recursion, at most 64
163
+ brackets wait for their `]`, and a link label is at most 999 characters, so parsing takes time
164
+ in proportion to the input.
165
+ - **Size is yours to bound.** There is no limit on how much text is rendered: a megabyte of
166
+ input is a few hundred pages. Cap the length of what you accept before you render it, and use
167
+ `strict` (or read `document.warnings`) when a warning should stop the document.
141
168
 
142
169
  ### Links, bookmarks and table of contents
143
170
 
@@ -190,7 +217,8 @@ keeps its title, with no number and no link.
190
217
  ### Forms
191
218
 
192
219
  Form fields are interactive widgets (an AcroForm) that lay out like boxes, or sit at a fixed page
193
- position with `at: [x, y]`. They work inside boxes, rows and table cells.
220
+ position with `at: [x, y]`. They work inside boxes, rows and table cells. The
221
+ [Forms guide](https://stationery.zoolutions.llc/docs/forms) lists every option of every field.
194
222
 
195
223
  ```ruby
196
224
  text "Name"
@@ -204,14 +232,24 @@ checkbox "terms", checked: false, label: "I accept the terms"
204
232
  signature_field "signature", label: "Signature of the applicant"
205
233
  ```
206
234
 
207
- - Every widget carries its own appearance (drawn in Helvetica, ZapfDingbats for the check mark), so
208
- the form looks the same in every viewer; `NeedAppearances` is set too, so viewers redraw edited
209
- values. Values are Unicode (`/V`); the drawn appearance covers the Windows-1252 range.
235
+ - Every widget carries its own appearance, so the form looks the same in every viewer. Text is set
236
+ in the text style around the field, in the document's own fonts: embedded, with the fallbacks
237
+ applied per character, so a value in any script the fonts cover is drawn (`/V` holds it as
238
+ Unicode). Check marks and radio dots are paths. `NeedAppearances` is set too, so viewers redraw
239
+ edited values; ZapfDingbats is listed for the ones that redraw a button's mark, never embedded
240
+ and never used by the appearances themselves.
241
+ - A field that can be edited keeps printable ASCII and Latin-1 in its font beyond the value it shows
242
+ (and a select the glyphs of every option), about 10 KB per font, so the text a viewer redraws
243
+ after an edit has its glyphs; what is typed outside that range falls back to the viewer's own
244
+ font. A `read_only:` field keeps its value's glyphs only.
245
+ - `tooltip:` is a field's accessible name (`/TU`): by default a check box's or signature's `label:`,
246
+ otherwise the field name. A radio group takes the `tooltip:` of its first choice.
210
247
  - Dotted names build the field hierarchy viewers show as groups; widgets sharing a name are one field
211
248
  with several widgets. A name used as both a field and a group raises `ArgumentError`.
212
249
  - `read_only:`, `required:`, `multiline:`, `max_length:` and `comb:` set the matching field flags.
213
250
  - A radio group's value is its checked choice's `value` (`Off` when none is checked); a select box
214
- lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the signer.
251
+ lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the
252
+ signer, unless `sign field:` signs it (see [Digital signatures](#digital-signatures)).
215
253
  - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
216
254
  `false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
217
255
 
@@ -294,6 +332,27 @@ InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
294
332
  `:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
295
333
  - Every string and stream is encrypted, the document info included.
296
334
 
335
+ ### Embedded files
336
+
337
+ ```ruby
338
+ class InvoicePdf < Stationery::Document
339
+ attach_file "factur-x.xml", invoice_xml, mime: "text/xml", description: "Factur-X",
340
+ relationship: :alternative # every render
341
+ end
342
+
343
+ InvoicePdf.new(invoice).to_pdf(attachments: [{ name: "terms.pdf", data: terms, mime: "application/pdf" }])
344
+ ```
345
+
346
+ - Each file becomes a `/Filespec` with an `/EmbeddedFile` stream (`/Subtype` from `mime:`, `/Params`
347
+ with size, MD5 checksum and `modified_at:`), listed in the catalog's `/EmbeddedFiles` name tree
348
+ and its `/AF` array; `description:` is the `/Desc` viewers show.
349
+ - `relationship:` writes `/AFRelationship`: `:alternative` (a machine-readable twin, as Factur-X
350
+ and ZUGFeRD require), `:source`, `:data`, `:supplement` or `:unspecified` (default).
351
+ - Per-render `attachments:` are added to the class-level ones; the same name twice raises
352
+ `ArgumentError`. An encrypted document encrypts the embedded streams too.
353
+ - For an e-invoice, [`factur_x`](#factur-x--zugferd-e-invoices) embeds the XML, claims PDF/A-3b
354
+ and writes the identification in one line.
355
+
297
356
  ### Accessibility (tagged PDF)
298
357
 
299
358
  ```ruby
@@ -331,6 +390,10 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
331
390
  - Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
332
391
  outside any element are layout artifacts.
333
392
  - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
393
+ - Every document carries an XMP packet (`/Metadata`, uncompressed) mirroring the Info dictionary:
394
+ `dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
395
+ `pdf:Producer`. PDF/A and PDF/UA identification lives there (see
396
+ [PDF/A and PDF/UA](#pdfa-and-pdfua)); `metadata xmp: false` or `to_pdf(xmp: false)` leaves it out.
334
397
  - An image or drawing without `alt:` (`Warnings::MissingAlt`) and a missing `lang`
335
398
  (`Warnings::MissingLanguage`) are warnings, so `strict` catches them.
336
399
  - Untagged documents (the default) are written exactly as before.
@@ -345,8 +408,150 @@ expect(ReportPdf.new).to have_structure(
345
408
  )
346
409
  ```
347
410
 
348
- The PDF is not labelled PDF/UA (no XMP `pdfuaid` metadata), and nothing checks colour contrast or
349
- reading order you build out of positioned boxes (`box(at:)` joins the reading order where it paints).
411
+ `tagged` alone does not label the file; `conformance :pdf_ua1` does (see
412
+ [PDF/A and PDF/UA](#pdfa-and-pdfua)). Nothing checks colour contrast or the reading order you build
413
+ out of positioned boxes (`box(at:)` joins the reading order where it paints).
414
+
415
+ ### PDF/A and PDF/UA
416
+
417
+ ```ruby
418
+ class InvoicePdf < Stationery::Document
419
+ conformance :pdf_a3b # archival: :pdf_a2b or :pdf_a3b
420
+ end
421
+
422
+ class ReportPdf < Stationery::Document
423
+ conformance :pdf_a3b, :pdf_ua1 # archival and accessible
424
+ metadata title: "Annual report 2026", lang: "en"
425
+ end
426
+
427
+ InvoicePdf.new(invoice).to_pdf(conformance: nil) # one render without the claim, or with another
428
+ ```
429
+
430
+ A level is only claimed when the file keeps it, so what cannot conform raises instead of being
431
+ mislabelled: `ArgumentError` for options that contradict the level, `Stationery::ConformanceError`
432
+ (`levels`, `issues`) for content.
433
+
434
+ - **PDF/A-2b and PDF/A-3b** (ISO 19005, level B: the look is reproducible): an sRGB
435
+ `OutputIntent` with the embedded ICC profile (the ICC's `sRGB2014.icc`), `pdfaid:part` and
436
+ `pdfaid:conformance` in the XMP packet (written even with `xmp: false`), the print flag on every
437
+ annotation. Fonts are always embedded and subsetted, and transparency is allowed from part 2 on.
438
+ `encrypt:` raises. Embedded files need `:pdf_a3b` (part 2 only embeds PDF/A files, so
439
+ `attach_file` raises there). CMYK colours and CMYK JPEGs are not covered by the sRGB intent and
440
+ are reported as `Warnings::ConformanceIssue`, so `strict` refuses them.
441
+ - **PDF/UA-1** (ISO 14289): turns `tagged` on, needs `metadata title:` and `lang:`, writes
442
+ `pdfuaid:part`, shows the title in the viewer, orders tabs by structure (`/Tabs /S`) and gives
443
+ every link annotation a description (`/Contents`: the URL, or the target page). A figure without
444
+ `alt:` raises; mark decoration with `alt: false`. Encryption is allowed.
445
+ - Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
446
+ - Interactive form fields are allowed: their appearances draw with embedded fonts and paths, every
447
+ field has a `/TU`, and `NeedAppearances` and ZapfDingbats are left out. Only a field made without
448
+ a font book (`Forms::Field.new` placed with `canvas.widget`) raises, since it draws with the
449
+ standard Helvetica.
450
+ - Without `conformance` nothing changes: the output is byte for byte what it was.
451
+
452
+ `bundle exec rake verify:conformance` renders `examples/invoice.rb` as PDF/A-3b, and
453
+ `examples/report.rb` and `examples/form.rb` as PDF/A-3b plus PDF/UA-1, and validates them with
454
+ [veraPDF](https://verapdf.org) through Docker (`verapdf/cli`); CI runs it on every push. Validate
455
+ your own documents the same way:
456
+
457
+ ```sh
458
+ docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /data/invoice.pdf
459
+ ```
460
+
461
+ In tests, `have_conformance(:pdf_a3b)` checks the claim (not the validity: that is veraPDF's job).
462
+
463
+ ### Factur-X / ZUGFeRD e-invoices
464
+
465
+ A Factur-X (in Germany: ZUGFeRD) invoice is one PDF that people read and accounting software
466
+ books: a PDF/A-3b file with the invoice as Cross Industry Invoice XML embedded in it.
467
+
468
+ ```ruby
469
+ class InvoicePdf < Stationery::Document
470
+ metadata title: "Invoice", lang: "en"
471
+ factur_x(profile: :en16931) { @invoice.to_cii_xml } # evaluated in the document, per render
472
+ # factur_x :invoice_xml # or a method name
473
+ # factur_x File.read("factur-x.xml") # or the XML itself
474
+
475
+ def initialize(invoice) = (super(); @invoice = invoice)
476
+ end
477
+
478
+ InvoicePdf.new(invoice).to_pdf(factur_x: { xml:, profile: :extended }) # per render; nil for none
479
+ ```
480
+
481
+ - `factur_x` claims `conformance :pdf_a3b` (added to declared levels such as `:pdf_ua1`;
482
+ `:pdf_a2b` raises), embeds the XML as `factur-x.xml` (`text/xml`, described as "Factur-X
483
+ Invoice", stamped with its modification date) and writes the `fx:` identification in XMP
484
+ (`DocumentType INVOICE`, `DocumentFileName`, `Version`, `ConformanceLevel`) with the extension
485
+ schema description PDF/A asks for.
486
+ - `profile:` is `:minimum`, `:basic_wl`, `:basic`, `:en16931` (default), `:extended` or
487
+ `:xrechnung` (embedded as `xrechnung.xml`). The XML is the page's `:alternative`, except for
488
+ `:minimum` and `:basic_wl`, whose XML is `:data` beside it. `filename:`, `version:` ("1.0")
489
+ and `relationship:` override the defaults (ZUGFeRD 1.0 used `ZUGFeRD-invoice.xml`).
490
+ - Stationery carries the XML, it does not write or validate it: anything that does not start
491
+ with `<?xml` or `<rsm:CrossIndustryInvoice` raises `ArgumentError`, and what is inside is your
492
+ invoicing code's. `examples/e_invoice.rb` builds a minimal EN 16931 document from the example
493
+ invoice's lines.
494
+ - Everything PDF/A-3b asks applies: no `encrypt:`. Form fields and a signature are fine.
495
+ - Without `factur_x` nothing changes.
496
+
497
+ `bundle exec rake verify:factur_x` validates `examples/e_invoice.rb` with the
498
+ [Mustang](https://www.mustangproject.org) validator through Docker: the PDF/A-3 container and the
499
+ XML against the EN 16931 schema and business rules. CI runs it with `verify:conformance`. In
500
+ tests, `have_factur_x(profile: :en16931)` checks that the invoice is named in XMP and embedded.
501
+
502
+ ### Digital signatures
503
+
504
+ `sign` signs the file with a certificate and its private key, so a reader can tell who issued it
505
+ and that not a byte changed since. It needs nothing but Ruby's own `openssl`, loaded when the first
506
+ signature is made.
507
+
508
+ ```ruby
509
+ class ContractPdf < Stationery::Document
510
+ sign certificate: -> { Rails.application.credentials.dig(:signing, :certificate) }, # PEM or OpenSSL object
511
+ key: -> { Rails.application.credentials.dig(:signing, :key) }, # read per render
512
+ chain: -> { [File.read("config/intermediate.pem")] },
513
+ reason: "Approved", location: "Malmö", contact: "legal@acme.test"
514
+ # sign { { certificate: signer.certificate, key: signer.key, field: "approval" } } # or a block for all of it
515
+
516
+ def view_template
517
+ text "Contract"
518
+ signature_field "approval", label: "Approved by" # `field: "approval"` signs this one
519
+ end
520
+ end
521
+
522
+ ContractPdf.new.to_pdf(sign: { certificate:, key:, passphrase: "…" }) # per render; nil for none
523
+ ```
524
+
525
+ - The signature is `/SubFilter /ETSI.CAdES.detached`: a detached CMS over every byte of the file
526
+ except the signature itself, SHA-256 with an RSA (PKCS #1 v1.5) or EC (ECDSA) key, with the
527
+ signed attributes PAdES baseline B-B asks for (content type, message digest and the ESS
528
+ signing-certificate-v2 that binds it to the certificate; the signing time is the dictionary's
529
+ `/M`). The certificate and its `chain:` travel in the signature.
530
+ - `certificate:` and `chain:` take an `OpenSSL::X509::Certificate` or PEM, `key:` an
531
+ `OpenSSL::PKey` or PEM (`passphrase:` when it is encrypted). Any value may be a block or callable
532
+ answering it for the document being rendered, and `certificate:`, `key:`, `chain:` and
533
+ `passphrase:` a method name, so secrets are read when they are needed and never at class load.
534
+ A key that is not the certificate's raises `ArgumentError`.
535
+ - `field:` names the `signature_field` to sign, which keeps its appearance. Without it the
536
+ signature is invisible: a field of its own (`Signature1`) whose widget has no size, on the first
537
+ page. `name:` is the signer's name (the certificate's common name by default), `at:` the signing
538
+ time (`Time.now`).
539
+ - The file is written once, with `contents_size:` bytes (8192) kept free for the signature, which
540
+ is then filled in place. A long certificate chain may need more; a signature that does not fit
541
+ raises and says so.
542
+ - A signed form asks viewers not to regenerate appearances (`NeedAppearances` is left out) and
543
+ sets `/SigFlags 3`. `encrypt:` and `conformance` combine with `sign`: the signature is the one
544
+ string encryption leaves in the clear, and a signed PDF/A-3b or PDF/UA-1 file still validates.
545
+ - Not covered: signature timestamps (PAdES-T) and long-term validation data (LTV), a second
546
+ signature, and signing a file that already exists. All of them need incremental updates;
547
+ Stationery signs what it renders, once. Whether a viewer trusts the signer is decided by the
548
+ certificate and the viewer's trust list, not by the file.
549
+
550
+ `bundle exec rake verify:signature` signs `examples/invoice.rb` with a throwaway certificate and
551
+ verifies it with `openssl cms -verify` and, when poppler is installed, `pdfsig`;
552
+ `verify:conformance` validates signed PDF/A-3b and PDF/UA-1 renders with veraPDF. In tests,
553
+ `have_signature(name: "Acme Legal")` checks that a signature covers the whole file and verifies
554
+ against the certificate it carries.
350
555
 
351
556
  ### Debugging
352
557
 
@@ -598,6 +803,10 @@ RSpec.describe InvoicePdf do
598
803
  it { is_expected.to have_no_warnings }
599
804
  it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
600
805
  it { is_expected.to have_page_labels(%w[i ii 1 2]) } # from `page_labels`
806
+ it { is_expected.to have_attachment("factur-x.xml", mime: "text/xml", relationship: :alternative) }
807
+ it { is_expected.to have_conformance(:pdf_a3b) } # the level claimed in XMP, from `conformance`
808
+ it { is_expected.to have_factur_x(profile: :en16931) } # the e-invoice XML, named in XMP and embedded
809
+ it { is_expected.to have_signature(name: "Acme Legal") } # covers the whole file and verifies
601
810
  it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
602
811
  it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
603
812
  end
@@ -620,6 +829,10 @@ class InvoicePdfTest < Minitest::Test
620
829
  assert_no_pdf_warnings pdf
621
830
  assert_pdf_language pdf, "en"
622
831
  assert_page_labels pdf, %w[i ii 1 2]
832
+ assert_pdf_attachment pdf, "factur-x.xml", mime: "text/xml"
833
+ assert_pdf_conformance pdf, :pdf_a3b
834
+ assert_factur_x pdf, profile: :en16931
835
+ assert_pdf_signature pdf, name: "Acme Legal"
623
836
  assert_tagged_content pdf
624
837
  assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
625
838
  end
@@ -631,7 +844,11 @@ The matcher names carry a `pdf_` prefix so they never clash with Capybara's
631
844
  titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and inside
632
845
  `all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
633
846
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
634
- `image_count`, `bookmarks`, `metadata`, `lang`, `page_labels`, `warnings`, `tagged?`, `untagged_text` and
847
+ `image_count`, `bookmarks`, `metadata`, `xmp` (the packet), `xmp_values` (`{ "dc:title" => …, "dc:creator" => […] }`),
848
+ `lang`, `page_labels`, `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
849
+ (`{ profile:, filename:, version:, xml: }`), `signatures` (`[{ field:, name:, reason:, location:,
850
+ signed_at:, subfilter:, byte_range:, signer:, valid: }]`), `warnings`, `tagged?`,
851
+ `untagged_text` and
635
852
  `structure` — a tagged PDF's structure tree as nested arrays, each element's text
636
853
  read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
637
854
  between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
@@ -684,20 +901,26 @@ events attached is the most useful thing to put in an issue.
684
901
 
685
902
  `bundle exec rake bench` renders two documents with Stationery and with Prawn
686
903
  2.5 + prawn-table, both embedding the same Open Sans TTF files
687
- (`benchmark/`, not part of CI). Apple M2 Max, Ruby 3.4.2 +YJIT, 27 September 2026:
904
+ (`benchmark/`, not part of CI). Apple M2 Max, Ruby 3.4.2 +YJIT, 28 September 2026:
688
905
 
689
906
  | Document | Engine | Renders/s | Objects allocated | PDF bytes |
690
907
  |---|---|---:|---:|---:|
691
- | Invoice (1 page, `examples/invoice.rb`) | Stationery | 74.6 | 33,231 | 23,525 |
692
- | | Prawn | 45.4 (1.64x slower) | 88,558 | 33,034 |
693
- | Table, 1,500 rows × 5 columns, repeating header | Stationery | 1.47 (46 pages) | 4,462,820 | 233,389 |
694
- | | Prawn | 0.74 (40 pages; 1.99x slower) | 6,901,818 | 2,689,487 |
908
+ | Invoice (1 page, `examples/invoice.rb`) | Stationery | 66.5 | 39,574 | 26,599 |
909
+ | | Prawn | 41.3 (1.61x slower) | 88,558 | 33,034 |
910
+ | Table, 1,500 rows × 5 columns, repeating header | Stationery | 2.31 (46 pages) | 2,596,554 | 133,754 |
911
+ | | Prawn | 0.58 (40 pages; 4.01x slower) | 6,901,811 | 2,689,487 |
695
912
 
696
913
  Stationery compresses content streams; Prawn does not by default, hence the
697
914
  larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
698
915
  20 hottest frames of the table render under StackProf (wall mode; `MODE=cpu` or
699
916
  `MODE=object` for the others).
700
917
 
918
+ Time depends on the machine, so CI holds what does not: `bundle exec rake metrics`
919
+ renders six fixed documents and compares the objects each render allocates, its
920
+ page count and its bytes with `benchmark/baseline.json` (allocations may grow 3%,
921
+ bytes 1%, pages not at all). A change that moves them on purpose records a new
922
+ baseline with `bundle exec rake metrics:update` and says why in the commit.
923
+
701
924
  ## Limitations
702
925
 
703
926
  Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert to `.ttf` or
@@ -708,8 +931,10 @@ in the chain has are drawn as `.notdef` and reported. Text runs left to right; h
708
931
  patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
709
932
  and justification only widens spaces.
710
933
 
711
- SVG covers the shapes, gradients, text and stylesheets icon sets use, nothing else: `use`,
712
- `image`, `clipPath`, `mask`, `pattern`, `filter` and `textPath` are skipped and reported.
934
+ SVG covers the shapes, gradients, text, stylesheets, `use`/`symbol` sprites and clip paths that icon
935
+ sets and exports use, nothing else: `image`, `mask`, `pattern`, `filter` and `textPath` are skipped
936
+ and reported, text inside a `clipPath` does not clip, and the shapes of a clip path join into one
937
+ path, so overlapping shapes wound in opposite directions cancel where they overlap.
713
938
  Images are JPEG and PNG (non-interlaced) and never fetched from a URL; a JPEG is embedded at its
714
939
  source resolution (only PNGs can be downscaled), so an oversized one is reported, not resized. `html` and `markdown` render structure and inline marks, not CSS: only `text-align`
715
940
  and inline `font-weight`/`font-style` are read, and raw HTML inside Markdown stays literal text.
@@ -719,9 +944,12 @@ splits only when every column can; a rotated box and a `stack` move to the next
719
944
  does not wrap around images. Link and form-widget rectangles stay in page space inside `rotate`
720
945
  and `transform`, and `shadow:` is stacked rectangles, not a blur.
721
946
 
722
- PDF: tagged output is not labelled PDF/UA (no XMP), and there is no PDF/A or PDF/X, no digital
723
- signing (`signature_field` is an empty field), no embedded files or JavaScript.
724
- Form-field appearances use Helvetica (Windows-1252), not the document's fonts.
947
+ PDF: PDF/A-2b, PDF/A-3b and PDF/UA-1 only (no PDF/A-1, no level A or U, no PDF/UA-2, no PDF/X) and
948
+ no JavaScript. A render carries one signature (`/ETSI.CAdES.detached`, RSA or EC with SHA-256):
949
+ no signature timestamp (PAdES-T) or long-term validation data, no second signature and no signing
950
+ of a file that already exists, all of which need incremental updates.
951
+ Form fields are set in the document's fonts, but text typed into one is drawn by the viewer:
952
+ characters outside the glyphs the field kept (ASCII and Latin-1) use the viewer's own font.
725
953
 
726
954
  ## License
727
955
 
@@ -67,6 +67,25 @@ module Stationery
67
67
  end
68
68
  end
69
69
 
70
+ # A path the block traces in top-left space, under `transform` when given,
71
+ # for #clip_to.
72
+ def outline(transform: nil, &)
73
+ Path.new(self, transform:).tap(&)
74
+ end
75
+
76
+ # Paints the block inside `outlines` (see #outline), joined into one
77
+ # clipping path under the nonzero rule or, with `even_odd:`, the even-odd
78
+ # one. Without a path to clip to, nothing is inside and the block is skipped.
79
+ def clip_to(outlines, even_odd: false)
80
+ paths = outlines.map(&:to_s).reject(&:empty?)
81
+ return if paths.empty?
82
+
83
+ save do
84
+ emit(*paths, even_odd ? "W* n" : "W n")
85
+ yield self
86
+ end
87
+ end
88
+
70
89
  def fill_rect(x, y, w, h, color:, opacity: nil)
71
90
  shape(fill: color, opacity:) { |p| p.rect(x, y, w, h) }
72
91
  end
@@ -125,9 +144,11 @@ module Stationery
125
144
  end
126
145
 
127
146
  # An interactive form field's widget (a Forms::Field) over the rectangle.
147
+ # Its appearance is drawn here, so the glyphs it needs are in the fonts
148
+ # before they are embedded.
128
149
  def widget(field, x, y, w, h, tag: nil)
129
150
  rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
130
- annotation = { rect:, widget: field }
151
+ annotation = { rect:, widget: field, appearance: Forms::Appearance.new(field, w, h, @resources) }
131
152
  @page.annotations << annotation
132
153
  adopt(annotation, tag, rect) if tag
133
154
  end
@@ -57,19 +57,19 @@ module Stationery
57
57
  OK
58
58
  end
59
59
 
60
- # Documents that are new after loading FILE or whose view_template lives
61
- # in it (so a file already loaded by the host still resolves).
60
+ # Documents that are new after loading FILE or that it defines (so a
61
+ # file already loaded by the host still resolves).
62
62
  def load_documents(path)
63
63
  before = descendants(Document)
64
64
  load(path)
65
65
  after = descendants(Document)
66
- (after - before) | after.select { |klass| template_file(klass) == path }
66
+ (after - before) | after.select { |klass| defined_in?(klass, path) }
67
67
  end
68
68
 
69
69
  def pick(candidates, path)
70
70
  return named_class if @options[:class]
71
71
 
72
- candidates = candidates.reject { |klass| template_file(klass).nil? }
72
+ candidates = local(candidates.reject { |klass| template_file(klass).nil? }, path)
73
73
  raise Error, "no Stationery::Document defined in #{path}" if candidates.empty?
74
74
  return candidates.first if candidates.one?
75
75
 
@@ -77,6 +77,21 @@ module Stationery
77
77
  raise Error, "several documents defined in #{path} (#{names}); choose one with --class NAME"
78
78
  end
79
79
 
80
+ # The documents FILE itself defines, when it also loaded others (a
81
+ # document extending one from a file it requires).
82
+ def local(candidates, path)
83
+ defined_here = candidates.select { |klass| defined_in?(klass, path) }
84
+ defined_here.any? ? defined_here : candidates
85
+ end
86
+
87
+ # Whether FILE opens the class or holds its own view_template.
88
+ def defined_in?(klass, path)
89
+ method = klass.instance_method(:view_template)
90
+ return true if method.owner == klass && method.source_location&.first == path
91
+
92
+ !klass.name.nil? && Object.const_source_location(klass.name)&.first == path
93
+ end
94
+
80
95
  def named_class
81
96
  klass = Object.const_get(@options[:class])
82
97
  raise Error, "#{klass} is not a Stationery::Document" unless klass.is_a?(Class) && klass < Document