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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +198 -18
- data/lib/stationery/builder.rb +5 -3
- data/lib/stationery/canvas/text.rb +4 -3
- data/lib/stationery/canvas.rb +24 -2
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +72 -11
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/fonts/fallback.rb +3 -3
- data/lib/stationery/fonts/font.rb +39 -21
- data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
- data/lib/stationery/fonts/gsub.rb +65 -20
- data/lib/stationery/fonts/ligatures.rb +6 -4
- data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
- data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
- data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
- data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
- data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
- data/lib/stationery/hyphenation.rb +132 -0
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/images/png.rb +66 -0
- data/lib/stationery/images/resampled.rb +80 -0
- data/lib/stationery/layout/image.rb +28 -2
- data/lib/stationery/layout/paginator.rb +1 -1
- data/lib/stationery/layout/text.rb +2 -2
- data/lib/stationery/minitest.rb +17 -0
- data/lib/stationery/page_templates.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +40 -6
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +139 -0
- data/lib/stationery/pdf/factur_x.rb +117 -0
- data/lib/stationery/pdf/icc/LICENSE.md +14 -0
- data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
- data/lib/stationery/pdf/page_labels.rb +71 -0
- data/lib/stationery/pdf/stream.rb +5 -3
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +26 -22
- data/lib/stationery/svg/painter.rb +11 -0
- data/lib/stationery/svg/region.rb +25 -0
- data/lib/stationery/svg/shapes.rb +2 -0
- data/lib/stationery/svg/style.rb +25 -14
- data/lib/stationery/svg/viewport.rb +78 -0
- data/lib/stationery/svg/walker.rb +142 -0
- data/lib/stationery/testing/inspector.rb +81 -0
- data/lib/stationery/testing/matchers.rb +77 -0
- data/lib/stationery/text/entities.rb +14 -4
- data/lib/stationery/text/paragraph.rb +1 -1
- data/lib/stationery/text/style.rb +14 -3
- data/lib/stationery/text/wrapper.rb +73 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +10 -0
- data/lib/stationery.rb +12 -0
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 21d52a1f02122474b5316373c70965424f3479973757219d2b7b87ed453e8642
|
|
4
|
+
data.tar.gz: eaa7be7242e9f38e270cb23a533bac9038963c3e61c44c1e57ebaefe0288ef29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 (`&`, `'`, `'`, 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 `'`.
|
|
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
|
|
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 (`'`, `'`) and HTML 4 named entities once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `<b>` 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
|
|
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
|
-
|
|
344
|
-
|
|
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.
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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, `­` 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`, `
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
|
data/lib/stationery/builder.rb
CHANGED
|
@@ -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
|
-
|
|
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|
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
61
|
-
#
|
|
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|
|
|
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
|
data/lib/stationery/document.rb
CHANGED
|
@@ -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,
|
|
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 =
|
|
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:,
|
|
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 =
|
|
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,
|
|
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
|