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