stationery 0.6.0 → 0.8.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +198 -18
  4. data/lib/stationery/builder.rb +5 -3
  5. data/lib/stationery/canvas/text.rb +4 -3
  6. data/lib/stationery/canvas.rb +24 -2
  7. data/lib/stationery/cli/render.rb +19 -4
  8. data/lib/stationery/document.rb +72 -11
  9. data/lib/stationery/elements.rb +1 -1
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/fallback.rb +3 -3
  12. data/lib/stationery/fonts/font.rb +39 -21
  13. data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
  14. data/lib/stationery/fonts/gsub.rb +65 -20
  15. data/lib/stationery/fonts/ligatures.rb +6 -4
  16. data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
  17. data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
  18. data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
  19. data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
  20. data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
  21. data/lib/stationery/hyphenation.rb +132 -0
  22. data/lib/stationery/images/jpeg.rb +2 -1
  23. data/lib/stationery/images/png.rb +66 -0
  24. data/lib/stationery/images/resampled.rb +80 -0
  25. data/lib/stationery/layout/image.rb +28 -2
  26. data/lib/stationery/layout/paginator.rb +1 -1
  27. data/lib/stationery/layout/text.rb +2 -2
  28. data/lib/stationery/minitest.rb +17 -0
  29. data/lib/stationery/page_templates.rb +2 -1
  30. data/lib/stationery/pdf/assembler.rb +40 -6
  31. data/lib/stationery/pdf/attachments.rb +76 -0
  32. data/lib/stationery/pdf/conformance.rb +139 -0
  33. data/lib/stationery/pdf/factur_x.rb +117 -0
  34. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  35. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  36. data/lib/stationery/pdf/page_labels.rb +71 -0
  37. data/lib/stationery/pdf/stream.rb +5 -3
  38. data/lib/stationery/pdf/xmp.rb +149 -0
  39. data/lib/stationery/resources.rb +3 -0
  40. data/lib/stationery/svg/clip_path.rb +69 -0
  41. data/lib/stationery/svg/document.rb +26 -22
  42. data/lib/stationery/svg/painter.rb +11 -0
  43. data/lib/stationery/svg/region.rb +25 -0
  44. data/lib/stationery/svg/shapes.rb +2 -0
  45. data/lib/stationery/svg/style.rb +25 -14
  46. data/lib/stationery/svg/viewport.rb +78 -0
  47. data/lib/stationery/svg/walker.rb +142 -0
  48. data/lib/stationery/testing/inspector.rb +81 -0
  49. data/lib/stationery/testing/matchers.rb +77 -0
  50. data/lib/stationery/text/entities.rb +14 -4
  51. data/lib/stationery/text/paragraph.rb +1 -1
  52. data/lib/stationery/text/style.rb +14 -3
  53. data/lib/stationery/text/wrapper.rb +73 -12
  54. data/lib/stationery/version.rb +1 -1
  55. data/lib/stationery/warnings.rb +10 -0
  56. data/lib/stationery.rb +12 -0
  57. metadata +20 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfd8cada05438bcc43cc85da2535da95b55c2dbf6020bf95607bd8ebb65c8346
4
- data.tar.gz: 1115be789042eebfdd48a036fc433994694236c4c133ede893a554478b6174bf
3
+ metadata.gz: 21d52a1f02122474b5316373c70965424f3479973757219d2b7b87ed453e8642
4
+ data.tar.gz: eaa7be7242e9f38e270cb23a533bac9038963c3e61c44c1e57ebaefe0288ef29
5
5
  SHA512:
6
- metadata.gz: 2e55c39993d7c6c7c66373f5097a8796ac655b46eabe34eb148808cca220823112a728d4c26b971c54f163dc2b6c45f50f3b6481c050089871daf5964a4011d6
7
- data.tar.gz: 456ca535dc230cbc747dac22322d7a0886ea8da7f70b159bb59224a2afe211dd1cb1a6e79a2a1a7c1f7b0d2007701b465fb49434484953db010a869ae6e4ad97
6
+ metadata.gz: 0dd292c0de3767e870563e954a9ea1b466beed56af675209d69baa09f7c44e337117a5b7256e41b4a35e39f694f0cfc721f2729cd1af8f7dc6081712a4263034
7
+ data.tar.gz: 73286d789a65a258a026fc65fe8bbc1287dfd2df6ee13d07177cac13beba4f0e7bbcbdfc4bbafc4b3643d762efeb6a3cfb9cb6ab019e6a14c1351a920bc998db
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 (2026-09-28)
4
+
5
+ Compliance: PDF/A-3b, PDF/UA-1 and Factur-X e-invoices, verified with veraPDF and Mustang in CI.
6
+
7
+ - 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.
8
+ - 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.
9
+ - 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`.
10
+ - 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`.
11
+ - 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.
12
+ - 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`.
13
+
14
+ ## 0.7.0 (2026-09-28)
15
+
16
+ Typography and images: hyphenation, OpenType features, oversized-image warnings, page labels.
17
+
18
+ - Hyphenation: `hyphenate: true | "en" | "de" | "sv"` on `text`, `text_style`, `default_text` and the rich `styles:` (`p`, `li`). Liang's algorithm over the TeX `hyph-utf8` patterns for English, German and Swedish (bundled with their licences), so a word that does not fit breaks at the longest hyphenation point that does, with the font's hyphen. A soft hyphen (U+00AD, `­` in HTML) is a break point in any language: never drawn or measured unless the line breaks there, and absent from extracted text. Off by default; unchanged output otherwise.
19
+ - OpenType features: `features: %i[smcp onum tnum ss01]` on the same entry points applies the font's GSUB single substitutions (formats 1 and 2, also behind Extension lookups) before ligatures; `Font#features` lists what a font offers, an absent feature is ignored. Substituted glyphs keep their source characters in ToUnicode. Contextual lookups (`calt`, `clig`, `frac`) stay unsupported and are listed as such.
20
+ - Oversized images: a bitmap drawn at more than twice `max_ppi` (300 by default; `images max_ppi: 220` per document, `image(max_ppi:)` per call, `nil` disables) reports `Warnings::OversizedImage` naming the source, pixel width, effective ppi and the limit, so `strict` catches a 1600 px photo drawn 160 pt wide. `downscale: true` resamples a PNG (grey, RGB, palette, alpha) to the limit before embedding; JPEG is never re-encoded and only warns.
21
+ - Page labels: `page_labels 1 => { style: :roman_lower }, 4 => { style: :decimal }, 12 => { style: :alpha, prefix: "Appendix " }` (or `to_pdf(page_labels:)`) writes the `/PageLabels` number tree; `Inspector#page_labels`, `have_page_labels` and `assert_page_labels` read it back.
22
+ - `text(…, markup: true)` decodes HTML character references (`&amp;`, `&#39;`, `&#x27;`, the named entities the HTML parser knows), so data escaped with `html_escape` before adding your own `<b>` tags renders as intended instead of showing `&#39;`.
23
+
3
24
  ## 0.6.0 (2026-09-28)
4
25
 
5
26
  Instrumentation, so a render shows up in AppSignal and friends, and a faster text pipeline.
data/README.md CHANGED
@@ -70,7 +70,8 @@ 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 and postcard collage; `bundle exec rake examples`
73
+ `examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form, postcard collage, event flyer and Factur-X e-invoice
74
+ ([previews and live PDFs](https://stationery.zoolutions.llc/docs/examples)); `bundle exec rake examples`
74
75
  renders them all, or render one with `stationery render examples/report.rb`.
75
76
 
76
77
  ## Elements
@@ -78,13 +79,13 @@ renders them all, or render one with `stationery render examples/report.rb`.
78
79
  | Element | What it does |
79
80
  |---|---|
80
81
  | `text(string, **style)` | A paragraph. Plain strings are literal. |
81
- | `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`; decodes numeric and HTML 4 named entities. |
82
+ | `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`; decodes numeric (`&#39;`, `&#x27;`) and HTML 4 named entities once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `&lt;b&gt;` stays literal text. |
82
83
  | `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
83
84
  | `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). `rotate: -3` turns the painted box around its centre (layout box unchanged, never splits; a `link:` keeps its unrotated rectangle). `shadow: true` or `{ offset: [0, 4], blur: 8, color:, opacity: 0.15 }` paints a soft drop shadow under it, taking no space. `overflow: :hidden` clips the content to the rounded outline. |
84
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. |
85
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. |
86
- | `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:)` | 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. |
87
- | `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). |
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:` (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`). |
88
89
  | `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
89
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. |
90
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:`, …). |
@@ -105,7 +106,7 @@ renders them all, or render one with `stationery render examples/report.rb`.
105
106
  | `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | The same from CommonMark (plus GFM tables and strikethrough). |
106
107
 
107
108
  Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
108
- `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
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`.
109
110
  `align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines
110
111
  ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
111
112
  Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
@@ -259,6 +260,10 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
259
260
  fits a normal page but not the last one continues onto an extra page.
260
261
  - With a header or footer, a `page_template`'s `page.content_box` excludes their space (it is the
261
262
  body's area).
263
+ - `page_labels 1 => { style: :roman_lower }, 3 => { style: :decimal, start: 1, prefix: "A-" }` names
264
+ the pages the way viewers show them ("i", "ii", "A-1", …): a 1-based first page mapped to the range
265
+ that starts there, with `style:` `:decimal`, `:roman`, `:roman_lower`, `:alpha`, `:alpha_lower` or
266
+ `nil` (prefix only), `start:` and `prefix:`. `to_pdf(page_labels:)` overrides it for one render.
262
267
  - Content taller than a page is placed anyway; `document.warnings` lists every overflow.
263
268
  - After `to_pdf`, `document.warnings` is an Enumerable of everything the render noticed but did not
264
269
  raise on, each with a `#message`: overflows, SVG elements that were skipped (`UnsupportedSvg`), and
@@ -289,6 +294,27 @@ InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
289
294
  `:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
290
295
  - Every string and stream is encrypted, the document info included.
291
296
 
297
+ ### Embedded files
298
+
299
+ ```ruby
300
+ class InvoicePdf < Stationery::Document
301
+ attach_file "factur-x.xml", invoice_xml, mime: "text/xml", description: "Factur-X",
302
+ relationship: :alternative # every render
303
+ end
304
+
305
+ InvoicePdf.new(invoice).to_pdf(attachments: [{ name: "terms.pdf", data: terms, mime: "application/pdf" }])
306
+ ```
307
+
308
+ - Each file becomes a `/Filespec` with an `/EmbeddedFile` stream (`/Subtype` from `mime:`, `/Params`
309
+ with size, MD5 checksum and `modified_at:`), listed in the catalog's `/EmbeddedFiles` name tree
310
+ and its `/AF` array; `description:` is the `/Desc` viewers show.
311
+ - `relationship:` writes `/AFRelationship`: `:alternative` (a machine-readable twin, as Factur-X
312
+ and ZUGFeRD require), `:source`, `:data`, `:supplement` or `:unspecified` (default).
313
+ - Per-render `attachments:` are added to the class-level ones; the same name twice raises
314
+ `ArgumentError`. An encrypted document encrypts the embedded streams too.
315
+ - For an e-invoice, [`factur_x`](#factur-x--zugferd-e-invoices) embeds the XML, claims PDF/A-3b
316
+ and writes the identification in one line.
317
+
292
318
  ### Accessibility (tagged PDF)
293
319
 
294
320
  ```ruby
@@ -326,6 +352,10 @@ and across page breaks: a paragraph continued on the next page stays one `P`.
326
352
  - Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
327
353
  outside any element are layout artifacts.
328
354
  - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
355
+ - Every document carries an XMP packet (`/Metadata`, uncompressed) mirroring the Info dictionary:
356
+ `dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and
357
+ `pdf:Producer`. PDF/A and PDF/UA identification lives there (see
358
+ [PDF/A and PDF/UA](#pdfa-and-pdfua)); `metadata xmp: false` or `to_pdf(xmp: false)` leaves it out.
329
359
  - An image or drawing without `alt:` (`Warnings::MissingAlt`) and a missing `lang`
330
360
  (`Warnings::MissingLanguage`) are warnings, so `strict` catches them.
331
361
  - Untagged documents (the default) are written exactly as before.
@@ -340,8 +370,94 @@ expect(ReportPdf.new).to have_structure(
340
370
  )
341
371
  ```
342
372
 
343
- The PDF is not labelled PDF/UA (no XMP `pdfuaid` metadata), and nothing checks colour contrast or
344
- reading order you build out of positioned boxes (`box(at:)` joins the reading order where it paints).
373
+ `tagged` alone does not label the file; `conformance :pdf_ua1` does (see
374
+ [PDF/A and PDF/UA](#pdfa-and-pdfua)). Nothing checks colour contrast or the reading order you build
375
+ out of positioned boxes (`box(at:)` joins the reading order where it paints).
376
+
377
+ ### PDF/A and PDF/UA
378
+
379
+ ```ruby
380
+ class InvoicePdf < Stationery::Document
381
+ conformance :pdf_a3b # archival: :pdf_a2b or :pdf_a3b
382
+ end
383
+
384
+ class ReportPdf < Stationery::Document
385
+ conformance :pdf_a3b, :pdf_ua1 # archival and accessible
386
+ metadata title: "Annual report 2026", lang: "en"
387
+ end
388
+
389
+ InvoicePdf.new(invoice).to_pdf(conformance: nil) # one render without the claim, or with another
390
+ ```
391
+
392
+ A level is only claimed when the file keeps it, so what cannot conform raises instead of being
393
+ mislabelled: `ArgumentError` for options that contradict the level, `Stationery::ConformanceError`
394
+ (`levels`, `issues`) for content.
395
+
396
+ - **PDF/A-2b and PDF/A-3b** (ISO 19005, level B: the look is reproducible): an sRGB
397
+ `OutputIntent` with the embedded ICC profile (the ICC's `sRGB2014.icc`), `pdfaid:part` and
398
+ `pdfaid:conformance` in the XMP packet (written even with `xmp: false`), the print flag on every
399
+ annotation. Fonts are always embedded and subsetted, and transparency is allowed from part 2 on.
400
+ `encrypt:` raises. Embedded files need `:pdf_a3b` (part 2 only embeds PDF/A files, so
401
+ `attach_file` raises there). CMYK colours and CMYK JPEGs are not covered by the sRGB intent and
402
+ are reported as `Warnings::ConformanceIssue`, so `strict` refuses them.
403
+ - **PDF/UA-1** (ISO 14289): turns `tagged` on, needs `metadata title:` and `lang:`, writes
404
+ `pdfuaid:part`, shows the title in the viewer, orders tabs by structure (`/Tabs /S`) and gives
405
+ every link annotation a description (`/Contents`: the URL, or the target page). A figure without
406
+ `alt:` raises; mark decoration with `alt: false`. Encryption is allowed.
407
+ - Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
408
+ - Interactive form fields raise under every level: their appearances draw with the standard
409
+ Helvetica and ZapfDingbats, which are not embedded.
410
+ - Without `conformance` nothing changes: the output is byte for byte what it was.
411
+
412
+ `bundle exec rake verify:conformance` renders `examples/invoice.rb` as PDF/A-3b and
413
+ `examples/report.rb` as PDF/A-3b plus PDF/UA-1 and validates them with
414
+ [veraPDF](https://verapdf.org) through Docker (`verapdf/cli`); CI runs it on every push. Validate
415
+ your own documents the same way:
416
+
417
+ ```sh
418
+ docker run --rm -v "$PWD:/data:ro" verapdf/cli --format text -v --flavour 3b /data/invoice.pdf
419
+ ```
420
+
421
+ In tests, `have_conformance(:pdf_a3b)` checks the claim (not the validity: that is veraPDF's job).
422
+
423
+ ### Factur-X / ZUGFeRD e-invoices
424
+
425
+ A Factur-X (in Germany: ZUGFeRD) invoice is one PDF that people read and accounting software
426
+ books: a PDF/A-3b file with the invoice as Cross Industry Invoice XML embedded in it.
427
+
428
+ ```ruby
429
+ class InvoicePdf < Stationery::Document
430
+ metadata title: "Invoice", lang: "en"
431
+ factur_x(profile: :en16931) { @invoice.to_cii_xml } # evaluated in the document, per render
432
+ # factur_x :invoice_xml # or a method name
433
+ # factur_x File.read("factur-x.xml") # or the XML itself
434
+
435
+ def initialize(invoice) = (super(); @invoice = invoice)
436
+ end
437
+
438
+ InvoicePdf.new(invoice).to_pdf(factur_x: { xml:, profile: :extended }) # per render; nil for none
439
+ ```
440
+
441
+ - `factur_x` claims `conformance :pdf_a3b` (added to declared levels such as `:pdf_ua1`;
442
+ `:pdf_a2b` raises), embeds the XML as `factur-x.xml` (`text/xml`, described as "Factur-X
443
+ Invoice", stamped with its modification date) and writes the `fx:` identification in XMP
444
+ (`DocumentType INVOICE`, `DocumentFileName`, `Version`, `ConformanceLevel`) with the extension
445
+ schema description PDF/A asks for.
446
+ - `profile:` is `:minimum`, `:basic_wl`, `:basic`, `:en16931` (default), `:extended` or
447
+ `:xrechnung` (embedded as `xrechnung.xml`). The XML is the page's `:alternative`, except for
448
+ `:minimum` and `:basic_wl`, whose XML is `:data` beside it. `filename:`, `version:` ("1.0")
449
+ and `relationship:` override the defaults (ZUGFeRD 1.0 used `ZUGFeRD-invoice.xml`).
450
+ - Stationery carries the XML, it does not write or validate it: anything that does not start
451
+ with `<?xml` or `<rsm:CrossIndustryInvoice` raises `ArgumentError`, and what is inside is your
452
+ invoicing code's. `examples/e_invoice.rb` builds a minimal EN 16931 document from the example
453
+ invoice's lines.
454
+ - Everything PDF/A-3b asks applies: no `encrypt:`, no form fields.
455
+ - Without `factur_x` nothing changes.
456
+
457
+ `bundle exec rake verify:factur_x` validates `examples/e_invoice.rb` with the
458
+ [Mustang](https://www.mustangproject.org) validator through Docker: the PDF/A-3 container and the
459
+ XML against the EN 16931 schema and business rules. CI runs it with `verify:conformance`. In
460
+ tests, `have_factur_x(profile: :en16931)` checks that the invoice is named in XMP and embedded.
345
461
 
346
462
  ### Debugging
347
463
 
@@ -521,16 +637,48 @@ line is never wider than the same line unkerned.
521
637
 
522
638
  Standard ligatures (fi, fl, ffi, …) come from the font's GSUB `liga` feature
523
639
  (LigatureSubst lookups, also behind Extension lookups) and are on by default;
524
- `ligatures: false` on an element or in `default_text` turns them off. Only
525
- `liga` applies, not `clig` or `dlig`. Ligatures form within a run of one
526
- style and font, never across a line break, and letter spacing turns them off.
527
- The PDF's ToUnicode map sends a ligature glyph back to all of its
528
- characters, so copied and extracted text still reads "office". Fonts
529
- without a `liga` feature (such as the bundled Inter) are unaffected.
640
+ `ligatures: false` on an element or in `default_text` turns them off.
641
+ Ligatures form within a run of one style and font, never across a line
642
+ break, and letter spacing turns them off. The PDF's ToUnicode map sends a
643
+ ligature glyph back to all of its characters, so copied and extracted text
644
+ still reads "office". Fonts without a `liga` feature (such as the bundled
645
+ Inter) are unaffected.
646
+
647
+ Other OpenType features are opt-in per element, `text_style` or
648
+ `default_text`: `features: %i[smcp onum]` applies the font's small caps and
649
+ oldstyle figures, `tnum`/`pnum` pick tabular or proportional figures for
650
+ tables, `zero` a slashed zero, `dlig` discretionary ligatures, `ss01`… the
651
+ stylistic sets. Single (SingleSubst) and ligature (LigatureSubst) lookups
652
+ are applied in the font's own order; a feature the font lacks is ignored,
653
+ and `Font#features` lists what a font offers. Substituted glyphs keep
654
+ their source characters in ToUnicode, so "2026" in oldstyle figures still
655
+ extracts as "2026". Contextual features (`calt`, `clig`, `frac`) need
656
+ lookup types the reader does not implement and do nothing.
657
+
658
+ Hyphenation is off unless asked for: `hyphenate: "de"` on `text`, `text_style`,
659
+ `default_text` or an `html`/`markdown` style (`styles: { p: { hyphenate: "de" } }`)
660
+ breaks a word that does not fit at the longest point Liang's algorithm allows
661
+ over the bundled TeX patterns (`true` or `"en"` for American English, `"de"`
662
+ for German, `"sv"` for Swedish; `lib/stationery/hyphenation/patterns/LICENSES.md`
663
+ names their authors and licences), drawing a hyphen at the break. A soft hyphen
664
+ (U+00AD, `&shy;` in HTML) names the break points of a word yourself and, as in
665
+ TeX, exempts that word from the patterns; it is never measured or drawn and
666
+ never reaches the PDF. `Stationery::Hyphenation.hyphenate("Silbentrennung", "de")`
667
+ answers `["Sil", "ben", "tren", "nung"]` for your own use.
530
668
 
531
669
  Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
532
670
  mask). Parsed fonts and images are cached per process.
533
671
 
672
+ A bitmap keeps its pixels, so a 1600 px photo drawn 160 pt wide ships all
673
+ 1600 px at 720 ppi. An image drawn at more than twice the document's
674
+ `max_ppi` (300, or `images max_ppi: 220` at class level, `nil` to switch the
675
+ check off) is reported as a `Warnings::OversizedImage` naming the file, its
676
+ pixel width and the resolution it lands at. `images downscale: true` (or
677
+ `image(..., downscale: true)`) resamples a PNG to `max_ppi` at its drawn size
678
+ before embedding it, alpha included; a JPEG is embedded byte for byte, so
679
+ resize it before you embed it (an ActiveStorage variant per drawn size,
680
+ preprocessed, keeps a render to a download).
681
+
534
682
  ## Testing
535
683
 
536
684
  `stationery/rspec` and `stationery/minitest` read a rendered PDF back for
@@ -560,6 +708,10 @@ RSpec.describe InvoicePdf do
560
708
  it { is_expected.to have_image_count(1) }
561
709
  it { is_expected.to have_no_warnings }
562
710
  it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
711
+ it { is_expected.to have_page_labels(%w[i ii 1 2]) } # from `page_labels`
712
+ it { is_expected.to have_attachment("factur-x.xml", mime: "text/xml", relationship: :alternative) }
713
+ it { is_expected.to have_conformance(:pdf_a3b) } # the level claimed in XMP, from `conformance`
714
+ it { is_expected.to have_factur_x(profile: :en16931) } # the e-invoice XML, named in XMP and embedded
563
715
  it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
564
716
  it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
565
717
  end
@@ -581,6 +733,10 @@ class InvoicePdfTest < Minitest::Test
581
733
  assert_pdf_link pdf, /acme\.test/
582
734
  assert_no_pdf_warnings pdf
583
735
  assert_pdf_language pdf, "en"
736
+ assert_page_labels pdf, %w[i ii 1 2]
737
+ assert_pdf_attachment pdf, "factur-x.xml", mime: "text/xml"
738
+ assert_pdf_conformance pdf, :pdf_a3b
739
+ assert_factur_x pdf, profile: :en16931
584
740
  assert_tagged_content pdf
585
741
  assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
586
742
  end
@@ -592,7 +748,10 @@ The matcher names carry a `pdf_` prefix so they never clash with Capybara's
592
748
  titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and inside
593
749
  `all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
594
750
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
595
- `image_count`, `bookmarks`, `metadata`, `lang`, `warnings`, `tagged?`, `untagged_text` and
751
+ `image_count`, `bookmarks`, `metadata`, `xmp` (the packet), `xmp_values` (`{ "dc:title" => …, "dc:creator" => […] }`),
752
+ `lang`, `page_labels`, `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
753
+ (`{ profile:, filename:, version:, xml: }`), `warnings`, `tagged?`,
754
+ `untagged_text` and
596
755
  `structure` — a tagged PDF's structure tree as nested arrays, each element's text
597
756
  read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
598
757
  between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
@@ -661,10 +820,31 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
661
820
 
662
821
  ## Limitations
663
822
 
664
- No variable fonts (including CFF2) or
665
- WOFF2 (it needs Brotli; convert to `.ttf` or `.woff`); SVG covers the shapes icon sets use
666
- (no patterns or masks). A box with a fixed `height:` never splits (use
667
- `min_height:` for a floor that can); a row splits only when every column can.
823
+ Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert to `.ttf` or
824
+ `.woff`); shaping stops at pair kerning and single or ligature substitutions (`liga` by default,
825
+ `smcp`, `onum`, `tnum`, `ss01`… on request), so contextual alternates (`calt`, `clig`, `frac`) do
826
+ nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph, and colour or emoji glyphs no font
827
+ in the chain has are drawn as `.notdef` and reported. Text runs left to right; hyphenation
828
+ patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
829
+ and justification only widens spaces.
830
+
831
+ SVG covers the shapes, gradients, text, stylesheets, `use`/`symbol` sprites and clip paths that icon
832
+ sets and exports use, nothing else: `image`, `mask`, `pattern`, `filter` and `textPath` are skipped
833
+ and reported, text inside a `clipPath` does not clip, and the shapes of a clip path join into one
834
+ path, so overlapping shapes wound in opposite directions cancel where they overlap.
835
+ Images are JPEG and PNG (non-interlaced) and never fetched from a URL; a JPEG is embedded at its
836
+ 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`
837
+ and inline `font-weight`/`font-style` are read, and raw HTML inside Markdown stays literal text.
838
+
839
+ Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
840
+ splits only when every column can; a rotated box and a `stack` move to the next page whole. Text
841
+ does not wrap around images. Link and form-widget rectangles stay in page space inside `rotate`
842
+ and `transform`, and `shadow:` is stacked rectangles, not a blur.
843
+
844
+ 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); no
845
+ digital signing (`signature_field` is an empty field) and no JavaScript.
846
+ Form-field appearances use Helvetica (Windows-1252), not the document's fonts, which also keeps
847
+ forms out of PDF/A and PDF/UA documents.
668
848
 
669
849
  ## License
670
850
 
@@ -5,7 +5,7 @@ module Stationery
5
5
  # (where the next node goes) and the text defaults in effect.
6
6
  class Builder
7
7
  STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning
8
- ligatures].freeze
8
+ ligatures features hyphenate].freeze
9
9
 
10
10
  # Collects a row's columns; anything that is not already a column box is
11
11
  # wrapped in one.
@@ -32,10 +32,12 @@ module Stationery
32
32
  end
33
33
  end
34
34
 
35
- attr_reader :root, :book, :list_depth
35
+ # `images` are the document's bitmap defaults (max_ppi:, downscale:).
36
+ attr_reader :root, :book, :list_depth, :images
36
37
 
37
- def initialize(book:, text: {})
38
+ def initialize(book:, text: {}, images: {})
38
39
  @book = book
40
+ @images = images
39
41
  @root = Layout::Flow.new
40
42
  @containers = [@root]
41
43
  @text = [text]
@@ -8,14 +8,15 @@ module Stationery
8
8
 
9
9
  # Draws `string` with its baseline at (x, y) in top-left coordinates and
10
10
  # returns its advance width. Standard ligatures form unless `ligatures:`
11
- # is false or letter spacing is set.
11
+ # is false or letter spacing is set; `features:` are further OpenType
12
+ # feature tags (Strings) to apply.
12
13
  def text(string, x:, y:, font:, size:, color: "#000000", letter_spacing: 0, rise: 0, opacity: nil,
13
14
  synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false,
14
- kerning: false, ligatures: true, word_spacing: 0)
15
+ kerning: false, ligatures: true, features: Fonts::Font::NO_FEATURES, word_spacing: 0)
15
16
  return 0 if string.empty?
16
17
 
17
18
  color = Color.parse(color)
18
- run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero?)
19
+ run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero?, features:)
19
20
  run = run.with_word_spacing(word_spacing, size) unless word_spacing.zero?
20
21
  width = run.width(size, letter_spacing:)
21
22
  graphics(opacity:) do |ops|
@@ -12,16 +12,19 @@ module Stationery
12
12
  CAPS = { butt: 0, round: 1, square: 2 }.freeze
13
13
  JOINS = { miter: 0, round: 1, bevel: 2 }.freeze
14
14
 
15
- attr_reader :page
15
+ # `warnings` is the render's collector (nil on a bare canvas), for what a
16
+ # node notices only while painting, such as an oversized image.
17
+ attr_reader :page, :warnings
16
18
 
17
19
  # `template: true` records anchors apart, for canvases page templates draw on.
18
20
  # `tagging:` (a Tagging::Tree) marks content for a tagged PDF.
19
- def initialize(page, resources, template: false, debug: false, tagging: nil)
21
+ def initialize(page, resources, template: false, debug: false, tagging: nil, warnings: nil)
20
22
  @page = page
21
23
  @resources = resources
22
24
  @template = template
23
25
  @debug = debug
24
26
  @tagging = tagging
27
+ @warnings = warnings
25
28
  @marked = 0
26
29
  end
27
30
 
@@ -64,6 +67,25 @@ module Stationery
64
67
  end
65
68
  end
66
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
+
67
89
  def fill_rect(x, y, w, h, color:, opacity: nil)
68
90
  shape(fill: color, opacity:) { |p| p.rect(x, y, w, h) }
69
91
  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
@@ -24,7 +24,7 @@ module Stationery
24
24
  superclass.config.transform_values(&:dup)
25
25
  else
26
26
  { page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
27
- templates: [], regions: [], strict: false, tagged: false }
27
+ templates: [], regions: [], strict: false, tagged: false, images: {}, attachments: [] }
28
28
  end
29
29
  end
30
30
 
@@ -50,6 +50,29 @@ module Stationery
50
50
  config[:metadata] = config[:metadata].merge(info)
51
51
  end
52
52
 
53
+ # How viewers name the pages: a 1-based first page mapped to the range
54
+ # starting there, `page_labels 1 => { style: :roman_lower }, 3 =>
55
+ # { style: :decimal, start: 1, prefix: "A-" }`; see PDF::PageLabels.
56
+ def page_labels(spec)
57
+ PDF::PageLabels.entries(spec)
58
+ config[:page_labels] = spec
59
+ end
60
+
61
+ # Embeds a file in every render: `attach_file "invoice.xml", xml,
62
+ # mime: "text/xml", description: "Factur-X", relationship: :alternative`.
63
+ # `relationship:` is :alternative, :source, :data, :supplement or
64
+ # :unspecified (the /AFRelationship); `modified_at:` a Time.
65
+ def attach_file(name, data, **)
66
+ config[:attachments] << PDF::Attachments.build(name, data, **)
67
+ end
68
+
69
+ # Bitmap defaults: `max_ppi:` (300; nil disables) is the resolution
70
+ # above twice of which a drawn image is reported as oversized, and
71
+ # `downscale: true` resamples PNGs to it. See Layout::Image.
72
+ def images(**options)
73
+ config[:images] = config[:images].merge(options)
74
+ end
75
+
53
76
  # Raise WarningsError instead of writing a PDF that produced warnings.
54
77
  def strict(value = true) # rubocop:disable Style/OptionalBooleanParameter
55
78
  config[:strict] = value
@@ -62,6 +85,28 @@ module Stationery
62
85
  config[:tagged] = value
63
86
  end
64
87
 
88
+ # Claims PDF/A-2b, PDF/A-3b and/or PDF/UA-1 (`conformance :pdf_a3b,
89
+ # :pdf_ua1`) and writes what the level asks for; a render that cannot
90
+ # keep the claim raises. See PDF::Conformance.
91
+ def conformance(*levels)
92
+ PDF::Conformance.for(levels)
93
+ config[:conformance] = levels.flatten
94
+ end
95
+
96
+ # Makes every render a Factur-X / ZUGFeRD e-invoice: PDF/A-3b with the
97
+ # invoice XML embedded and identified in XMP. `xml` is the Cross
98
+ # Industry Invoice as a String, or a method name or block answering it
99
+ # for the document being rendered: `factur_x(profile: :en16931) {
100
+ # invoice.to_cii }`. See PDF::FacturX for profiles and options.
101
+ def factur_x(xml = nil, **options, &block)
102
+ source = xml || block
103
+ raise ArgumentError, "factur_x needs the invoice XML, a method name or a block" unless source
104
+
105
+ PDF::FacturX.profile(options.fetch(:profile, :en16931))
106
+ PDF::FacturX.new(source, **options) if source.is_a?(String)
107
+ config[:factur_x] = { xml: source, **options }
108
+ end
109
+
65
110
  # Encrypts every render with the standard security handler; see
66
111
  # PDF::Encryption::StandardSecurity for the options.
67
112
  def encrypt(**)
@@ -94,9 +139,17 @@ module Stationery
94
139
  def metadata = self.class.config[:metadata]
95
140
 
96
141
  def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
97
- tagged: self.class.config[:tagged])
142
+ tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels], attachments: [],
143
+ xmp: metadata[:xmp] != false, conformance: self.class.config[:conformance],
144
+ factur_x: self.class.config[:factur_x])
145
+ invoice = PDF::FacturX.for(factur_x, self)
146
+ attachments = PDF::Attachments.merge(self.class.config[:attachments], attachments, invoice&.attachment)
147
+ conformance = PDF::Conformance.for(invoice ? invoice.conformance(conformance) : conformance)
148
+ conformance&.validate!(encrypt:, metadata:, attachments:)
149
+ options = { strict:, debug:, encrypt:, tagged: tagged || conformance&.pdf_ua?, page_labels:, attachments:,
150
+ xmp: xmp || !conformance.nil?, conformance:, invoice: }
98
151
  Stationery.instrument("render.stationery", document: self.class.name) do |event|
99
- write(render_pdf(event, strict:, debug:, encrypt:, tagged:), target)
152
+ write(render_pdf(event, **options), target)
100
153
  end
101
154
  end
102
155
 
@@ -111,37 +164,45 @@ module Stationery
111
164
 
112
165
  # Builds a page template or region block into a fresh root node.
113
166
  def template_root(info, book:, &)
114
- builder = Builder.new(book:, text: self.class.config[:text])
167
+ builder = builder_for(book)
115
168
  build_with(builder) { instance_exec(info, &) }
116
169
  builder.root
117
170
  end
118
171
 
119
172
  private
120
173
 
174
+ def builder_for(book) = Builder.new(book:, text: self.class.config[:text], images: self.class.config[:images])
175
+
121
176
  # The PDF bytes; `event` is the render.stationery payload it fills in.
122
- def render_pdf(event, strict:, debug:, encrypt:, tagged:)
177
+ def render_pdf(event, strict:, debug:, tagged:, conformance:, **assembly)
123
178
  tagging = Tagging::Tree.new if tagged
124
179
  warnings = Warnings.new
125
180
  book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
126
- builder = Builder.new(book:, text: self.class.config[:text])
181
+ builder = builder_for(book)
127
182
  Stationery.instrument("build.stationery", document: self.class.name) { call(builder) }
128
183
  resources = Resources.new
129
184
  pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
130
185
  outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
131
186
  tagging&.audit(pages, warnings, lang: metadata[:lang])
132
187
  @warnings = warnings
188
+ conformance&.audit!(pages, resources:, warnings:)
133
189
  @fields = Forms::AcroForm.values(pages)
134
190
  event[:pages] = pages.size
135
191
  event[:warnings] = warnings.size
136
192
  raise WarningsError, warnings if strict && warnings.any?
137
193
 
138
- assemble(pages, resources, outline, encrypt:, tagging:).tap { |pdf| event[:bytes] = pdf.bytesize }
194
+ pdf = assemble(pages, resources, outline, tagging:, conformance:, **assembly)
195
+ event[:bytes] = pdf.bytesize
196
+ pdf
139
197
  end
140
198
 
141
- def assemble(pages, resources, outline, encrypt:, tagging:)
199
+ def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:, attachments:, xmp:, conformance:,
200
+ invoice:)
142
201
  encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
143
- assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:,
144
- lang: metadata[:lang])
202
+ assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, xmp:,
203
+ lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels),
204
+ attachments:, conformance:, xmp_extensions: invoice&.xmp_extensions || {},
205
+ xmp_schemas: [invoice&.xmp_schema].compact)
145
206
  Stationery.instrument("write.stationery", document: self.class.name) do |event|
146
207
  assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
147
208
  end
@@ -167,7 +228,7 @@ module Stationery
167
228
  end
168
229
 
169
230
  def info
170
- metadata.except(:lang).to_h do |key, value|
231
+ metadata.except(:lang, :xmp).to_h do |key, value|
171
232
  [INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
172
233
  end
173
234
  end