stationery 0.8.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 +11 -0
- data/README.md +128 -23
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/document.rb +20 -4
- 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/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/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 +4 -0
- data/lib/stationery/pdf/assembler.rb +19 -9
- data/lib/stationery/pdf/conformance.rb +7 -5
- 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/types.rb +4 -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/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +55 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +3 -0
- metadata +6 -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,16 @@
|
|
|
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
|
+
|
|
3
14
|
## 0.8.0 (2026-09-28)
|
|
4
15
|
|
|
5
16
|
Compliance: PDF/A-3b, PDF/UA-1 and Factur-X e-invoices, verified with veraPDF and Mustang in CI.
|
data/README.md
CHANGED
|
@@ -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
|
|
|
@@ -405,12 +443,14 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
|
|
|
405
443
|
every link annotation a description (`/Contents`: the URL, or the target page). A figure without
|
|
406
444
|
`alt:` raises; mark decoration with `alt: false`. Encryption is allowed.
|
|
407
445
|
- Combined, the XMP packet also describes the `pdfuaid` schema to PDF/A (`pdfaExtension:schemas`).
|
|
408
|
-
- Interactive form fields
|
|
409
|
-
|
|
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.
|
|
410
450
|
- Without `conformance` nothing changes: the output is byte for byte what it was.
|
|
411
451
|
|
|
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
|
|
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
|
|
414
454
|
[veraPDF](https://verapdf.org) through Docker (`verapdf/cli`); CI runs it on every push. Validate
|
|
415
455
|
your own documents the same way:
|
|
416
456
|
|
|
@@ -451,7 +491,7 @@ InvoicePdf.new(invoice).to_pdf(factur_x: { xml:, profile: :extended }) # per ren
|
|
|
451
491
|
with `<?xml` or `<rsm:CrossIndustryInvoice` raises `ArgumentError`, and what is inside is your
|
|
452
492
|
invoicing code's. `examples/e_invoice.rb` builds a minimal EN 16931 document from the example
|
|
453
493
|
invoice's lines.
|
|
454
|
-
- Everything PDF/A-3b asks applies: no `encrypt
|
|
494
|
+
- Everything PDF/A-3b asks applies: no `encrypt:`. Form fields and a signature are fine.
|
|
455
495
|
- Without `factur_x` nothing changes.
|
|
456
496
|
|
|
457
497
|
`bundle exec rake verify:factur_x` validates `examples/e_invoice.rb` with the
|
|
@@ -459,6 +499,60 @@ InvoicePdf.new(invoice).to_pdf(factur_x: { xml:, profile: :extended }) # per ren
|
|
|
459
499
|
XML against the EN 16931 schema and business rules. CI runs it with `verify:conformance`. In
|
|
460
500
|
tests, `have_factur_x(profile: :en16931)` checks that the invoice is named in XMP and embedded.
|
|
461
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.
|
|
555
|
+
|
|
462
556
|
### Debugging
|
|
463
557
|
|
|
464
558
|
`to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed),
|
|
@@ -712,6 +806,7 @@ RSpec.describe InvoicePdf do
|
|
|
712
806
|
it { is_expected.to have_attachment("factur-x.xml", mime: "text/xml", relationship: :alternative) }
|
|
713
807
|
it { is_expected.to have_conformance(:pdf_a3b) } # the level claimed in XMP, from `conformance`
|
|
714
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
|
|
715
810
|
it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
|
|
716
811
|
it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
|
|
717
812
|
end
|
|
@@ -737,6 +832,7 @@ class InvoicePdfTest < Minitest::Test
|
|
|
737
832
|
assert_pdf_attachment pdf, "factur-x.xml", mime: "text/xml"
|
|
738
833
|
assert_pdf_conformance pdf, :pdf_a3b
|
|
739
834
|
assert_factur_x pdf, profile: :en16931
|
|
835
|
+
assert_pdf_signature pdf, name: "Acme Legal"
|
|
740
836
|
assert_tagged_content pdf
|
|
741
837
|
assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
|
|
742
838
|
end
|
|
@@ -750,7 +846,8 @@ titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and insid
|
|
|
750
846
|
exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
|
|
751
847
|
`image_count`, `bookmarks`, `metadata`, `xmp` (the packet), `xmp_values` (`{ "dc:title" => …, "dc:creator" => […] }`),
|
|
752
848
|
`lang`, `page_labels`, `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
|
|
753
|
-
(`{ profile:, filename:, version:, xml: }`), `
|
|
849
|
+
(`{ profile:, filename:, version:, xml: }`), `signatures` (`[{ field:, name:, reason:, location:,
|
|
850
|
+
signed_at:, subfilter:, byte_range:, signer:, valid: }]`), `warnings`, `tagged?`,
|
|
754
851
|
`untagged_text` and
|
|
755
852
|
`structure` — a tagged PDF's structure tree as nested arrays, each element's text
|
|
756
853
|
read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
|
|
@@ -804,20 +901,26 @@ events attached is the most useful thing to put in an issue.
|
|
|
804
901
|
|
|
805
902
|
`bundle exec rake bench` renders two documents with Stationery and with Prawn
|
|
806
903
|
2.5 + prawn-table, both embedding the same Open Sans TTF files
|
|
807
|
-
(`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:
|
|
808
905
|
|
|
809
906
|
| Document | Engine | Renders/s | Objects allocated | PDF bytes |
|
|
810
907
|
|---|---|---:|---:|---:|
|
|
811
|
-
| Invoice (1 page, `examples/invoice.rb`) | Stationery |
|
|
812
|
-
| | Prawn |
|
|
813
|
-
| Table, 1,500 rows × 5 columns, repeating header | Stationery |
|
|
814
|
-
| | 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 |
|
|
815
912
|
|
|
816
913
|
Stationery compresses content streams; Prawn does not by default, hence the
|
|
817
914
|
larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
|
|
818
915
|
20 hottest frames of the table render under StackProf (wall mode; `MODE=cpu` or
|
|
819
916
|
`MODE=object` for the others).
|
|
820
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
|
+
|
|
821
924
|
## Limitations
|
|
822
925
|
|
|
823
926
|
Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert to `.ttf` or
|
|
@@ -841,10 +944,12 @@ splits only when every column can; a rotated box and a `stack` move to the next
|
|
|
841
944
|
does not wrap around images. Link and form-widget rectangles stay in page space inside `rotate`
|
|
842
945
|
and `transform`, and `shadow:` is stacked rectangles, not a blur.
|
|
843
946
|
|
|
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)
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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.
|
|
848
953
|
|
|
849
954
|
## License
|
|
850
955
|
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -144,9 +144,11 @@ module Stationery
|
|
|
144
144
|
end
|
|
145
145
|
|
|
146
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.
|
|
147
149
|
def widget(field, x, y, w, h, tag: nil)
|
|
148
150
|
rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
|
|
149
|
-
annotation = { rect:, widget: field }
|
|
151
|
+
annotation = { rect:, widget: field, appearance: Forms::Appearance.new(field, w, h, @resources) }
|
|
150
152
|
@page.annotations << annotation
|
|
151
153
|
adopt(annotation, tag, rect) if tag
|
|
152
154
|
end
|
data/lib/stationery/document.rb
CHANGED
|
@@ -107,6 +107,21 @@ module Stationery
|
|
|
107
107
|
config[:factur_x] = { xml: source, **options }
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# Signs every render with a digital signature over the whole file:
|
|
111
|
+
# `sign certificate: pem, key: pem, reason: "Approved"`. A value may be
|
|
112
|
+
# a block or callable answering it for the document being rendered (a
|
|
113
|
+
# method name for certificate:, key:, chain: and passphrase:), and a
|
|
114
|
+
# block may answer all of them, so keys are read when they are needed.
|
|
115
|
+
# `field:` names the signature_field it fills; without it the
|
|
116
|
+
# signature is invisible. See PDF::Signature.
|
|
117
|
+
def sign(**options, &block)
|
|
118
|
+
raise ArgumentError, "sign needs certificate: and key:, or a block answering them" unless block || options.any?
|
|
119
|
+
|
|
120
|
+
options = PDF::Signature.check(options)
|
|
121
|
+
PDF::Signature.new(**options) unless block || PDF::Signature.deferred?(options)
|
|
122
|
+
config[:sign] = block || options
|
|
123
|
+
end
|
|
124
|
+
|
|
110
125
|
# Encrypts every render with the standard security handler; see
|
|
111
126
|
# PDF::Encryption::StandardSecurity for the options.
|
|
112
127
|
def encrypt(**)
|
|
@@ -141,13 +156,14 @@ module Stationery
|
|
|
141
156
|
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
|
|
142
157
|
tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels], attachments: [],
|
|
143
158
|
xmp: metadata[:xmp] != false, conformance: self.class.config[:conformance],
|
|
144
|
-
factur_x: self.class.config[:factur_x])
|
|
159
|
+
factur_x: self.class.config[:factur_x], sign: self.class.config[:sign])
|
|
145
160
|
invoice = PDF::FacturX.for(factur_x, self)
|
|
146
161
|
attachments = PDF::Attachments.merge(self.class.config[:attachments], attachments, invoice&.attachment)
|
|
147
162
|
conformance = PDF::Conformance.for(invoice ? invoice.conformance(conformance) : conformance)
|
|
148
163
|
conformance&.validate!(encrypt:, metadata:, attachments:)
|
|
149
164
|
options = { strict:, debug:, encrypt:, tagged: tagged || conformance&.pdf_ua?, page_labels:, attachments:,
|
|
150
|
-
xmp: xmp || !conformance.nil?, conformance:, invoice
|
|
165
|
+
xmp: xmp || !conformance.nil?, conformance:, invoice:,
|
|
166
|
+
signature: PDF::Signature.for(sign, self) }
|
|
151
167
|
Stationery.instrument("render.stationery", document: self.class.name) do |event|
|
|
152
168
|
write(render_pdf(event, **options), target)
|
|
153
169
|
end
|
|
@@ -197,12 +213,12 @@ module Stationery
|
|
|
197
213
|
end
|
|
198
214
|
|
|
199
215
|
def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:, attachments:, xmp:, conformance:,
|
|
200
|
-
invoice:)
|
|
216
|
+
invoice:, signature:)
|
|
201
217
|
encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
|
|
202
218
|
assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, xmp:,
|
|
203
219
|
lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels),
|
|
204
220
|
attachments:, conformance:, xmp_extensions: invoice&.xmp_extensions || {},
|
|
205
|
-
xmp_schemas: [invoice&.xmp_schema].compact)
|
|
221
|
+
xmp_schemas: [invoice&.xmp_schema].compact, signature:)
|
|
206
222
|
Stationery.instrument("write.stationery", document: self.class.name) do |event|
|
|
207
223
|
assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
|
|
208
224
|
end
|
|
@@ -3,44 +3,49 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
# Interactive form fields. Each takes a name (dotted names group fields,
|
|
5
5
|
# "address.city") and lays out like a box, or at a fixed page position with
|
|
6
|
-
# `at: [x, y]`.
|
|
6
|
+
# `at: [x, y]`. Their appearances are set in the text style in effect (the
|
|
7
|
+
# document's fonts, embedded), and `tooltip:` names a field for assistive
|
|
8
|
+
# technology (its label or name by default).
|
|
7
9
|
module Elements
|
|
8
10
|
# A text input. `width:` is :full or points; `multiline:`, `max_length:`,
|
|
9
11
|
# `comb:` (a cell count, or true with max_length:), `read_only:`,
|
|
10
12
|
# `required:`, `font_size:`, `border:`, `background:` and `radius:`.
|
|
11
13
|
def text_field(name, value: "", width: :full, height: 22, at: nil, **)
|
|
12
|
-
field_node(Forms::Field.new(:text, name, value: value.to_s, **), width:, height:, at:)
|
|
14
|
+
field_node(Forms::Field.new(:text, name, value: value.to_s, typeface: field_typeface, **), width:, height:, at:)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
# A check box `size` points square, with an optional label to its right.
|
|
16
18
|
def checkbox(name, checked: false, size: 12, label: nil, at: nil, **)
|
|
17
|
-
field = Forms::Field.new(:checkbox, name, value: checked == true, **)
|
|
19
|
+
field = Forms::Field.new(:checkbox, name, value: checked == true, typeface: field_typeface, tooltip: label, **)
|
|
18
20
|
field_node(field, width: size, height: size, at:, label:)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
# One choice of the radio group `name`; its `value` becomes the group's
|
|
22
24
|
# value when checked.
|
|
23
25
|
def radio(name, value, checked: false, size: 12, label: nil, at: nil, **)
|
|
24
|
-
field = Forms::Field.new(:radio, name, value: value.to_s, checked:, **)
|
|
26
|
+
field = Forms::Field.new(:radio, name, value: value.to_s, checked:, typeface: field_typeface, **)
|
|
25
27
|
field_node(field, width: size, height: size, at:, label:)
|
|
26
28
|
end
|
|
27
29
|
|
|
28
30
|
# A drop-down (combo box) of `options`; `editable: true` also accepts
|
|
29
31
|
# typed values.
|
|
30
32
|
def select(name, options:, value: nil, width: :full, height: 22, at: nil, **)
|
|
31
|
-
field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s),
|
|
33
|
+
field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s),
|
|
34
|
+
typeface: field_typeface, **)
|
|
32
35
|
field_node(field, width:, height:, at:)
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
# An empty signature field for the signer to fill, drawn as a rule over
|
|
36
39
|
# the label.
|
|
37
40
|
def signature_field(name, width: :full, height: 40, label: "Signature", at: nil, **)
|
|
38
|
-
field = Forms::Field.new(:signature, name, label: label.to_s, **)
|
|
41
|
+
field = Forms::Field.new(:signature, name, label: label.to_s, typeface: field_typeface, **)
|
|
39
42
|
field_node(field, width:, height:, at:)
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
private
|
|
43
46
|
|
|
47
|
+
def field_typeface = Forms::Typeface.new(@_builder.book, @_builder.style)
|
|
48
|
+
|
|
44
49
|
def field_node(field, width:, height:, at:, label: nil)
|
|
45
50
|
label &&= field_label(label)
|
|
46
51
|
node = Layout::Field.new(field, height:, width:, label:)
|
|
@@ -8,22 +8,29 @@ module Stationery
|
|
|
8
8
|
# under `base_path:`. Remote images are never fetched. `links:` lists the
|
|
9
9
|
# URL schemes written as links (default http, https, mailto and tel;
|
|
10
10
|
# `#anchor` always is); `:all` keeps every href for trusted sources.
|
|
11
|
-
|
|
11
|
+
# Elements nested deeper than `max_depth:` are flattened into the deepest
|
|
12
|
+
# one kept (text stays, structure goes) and reported as a NestingLimit
|
|
13
|
+
# warning, so content from users cannot exhaust the stack.
|
|
14
|
+
def html(source, max_depth: 64, **)
|
|
12
15
|
require_relative "../html/document"
|
|
13
|
-
rich(HTML.parse(source.to_s
|
|
16
|
+
rich(HTML.parse(source.to_s, max_depth:) { |depth| nesting_limit(depth, max_depth) }, **)
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
# CommonMark (plus GFM tables and strikethrough); options as for html.
|
|
17
|
-
def markdown(source,
|
|
20
|
+
def markdown(source, max_depth: 64, **)
|
|
18
21
|
require_relative "../markdown/document"
|
|
19
|
-
rich(Markdown.parse(source.to_s
|
|
22
|
+
rich(Markdown.parse(source.to_s, max_depth:) { |depth| nesting_limit(depth, max_depth) }, **)
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
private
|
|
23
26
|
|
|
24
|
-
def
|
|
27
|
+
def nesting_limit(depth, limit)
|
|
28
|
+
@_builder.warnings << Warnings::NestingLimit.new(depth:, limit:)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def rich(blocks, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
|
|
25
32
|
require_relative "../rich/renderer"
|
|
26
|
-
Rich::Renderer.new(self, @_builder,
|
|
33
|
+
Rich::Renderer.new(self, @_builder, styles:, gap:, images:, base_path:, bookmarks:, links:).render(blocks)
|
|
27
34
|
end
|
|
28
35
|
end
|
|
29
36
|
end
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -95,9 +95,7 @@ module Stationery
|
|
|
95
95
|
name = source.to_s.lstrip.start_with?("<") ? "inline" : File.basename(source.to_s)
|
|
96
96
|
source = File.read(source.to_s) unless name == "inline"
|
|
97
97
|
document = SVG::Document.parse(source)
|
|
98
|
-
|
|
99
|
-
@_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
|
|
100
|
-
end
|
|
98
|
+
svg_warnings(document, name)
|
|
101
99
|
node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context, alt:)
|
|
102
100
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
103
101
|
end
|
|
@@ -154,6 +152,16 @@ module Stationery
|
|
|
154
152
|
|
|
155
153
|
private
|
|
156
154
|
|
|
155
|
+
# What the drawing uses that is not drawn, and nesting that was flattened.
|
|
156
|
+
def svg_warnings(document, name)
|
|
157
|
+
if document.unsupported.any?
|
|
158
|
+
@_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
|
|
159
|
+
end
|
|
160
|
+
return unless document.nesting
|
|
161
|
+
|
|
162
|
+
@_builder.warnings << Warnings::NestingLimit.new(depth: document.nesting, limit: SVG::Parser::MAX_DEPTH)
|
|
163
|
+
end
|
|
164
|
+
|
|
157
165
|
# `bookmark:` is a title, or { title:, level:, open: }.
|
|
158
166
|
def mark(node, anchor, bookmark)
|
|
159
167
|
names = [anchor&.to_s]
|
|
@@ -33,9 +33,10 @@ module Stationery
|
|
|
33
33
|
@pairs = {}
|
|
34
34
|
@glyphs = {}
|
|
35
35
|
@blanks = {}
|
|
36
|
-
@shapes = {}
|
|
37
|
-
@
|
|
38
|
-
@
|
|
36
|
+
@shapes = {}.compare_by_identity
|
|
37
|
+
@metrics = {}.compare_by_identity
|
|
38
|
+
@keys = { true => {}, false => {} }
|
|
39
|
+
@frozen_keys = { true => {}.compare_by_identity, false => {}.compare_by_identity }
|
|
39
40
|
@cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
|
|
40
41
|
end
|
|
41
42
|
|
|
@@ -49,10 +50,12 @@ module Stationery
|
|
|
49
50
|
def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true, features: NO_FEATURES)
|
|
50
51
|
ligatures &&= letter_spacing.zero?
|
|
51
52
|
key = shape_key(ligatures, features)
|
|
52
|
-
|
|
53
|
+
advance, kern = metrics(text, key)
|
|
54
|
+
width = scale(advance, size)
|
|
55
|
+
width += letter_spacing * shape(text, key).first.size unless letter_spacing.zero?
|
|
53
56
|
return width unless kerning
|
|
54
57
|
|
|
55
|
-
width + (
|
|
58
|
+
width + (kern * size / 1000.0)
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
# The GSUB feature tags this font can apply.
|
|
@@ -126,14 +129,22 @@ module Stationery
|
|
|
126
129
|
private
|
|
127
130
|
|
|
128
131
|
# The feature tags to substitute with: `liga` when ligatures are on, plus
|
|
129
|
-
# the requested features.
|
|
132
|
+
# the requested features. One Array per set of tags, so the memos are
|
|
133
|
+
# keyed by its identity: measuring a word hashes the word and nothing
|
|
134
|
+
# else. A style's frozen tags are looked up by identity too; any other
|
|
135
|
+
# Array is normalised first.
|
|
130
136
|
def shape_key(ligatures, features)
|
|
131
|
-
|
|
132
|
-
return features unless ligatures
|
|
133
|
-
return Gsub::LIGA if features.empty?
|
|
137
|
+
return ligatures ? Gsub::LIGA : NO_FEATURES if features.empty?
|
|
134
138
|
|
|
135
|
-
|
|
136
|
-
@
|
|
139
|
+
ligatures = ligatures ? true : false
|
|
140
|
+
return @frozen_keys[ligatures][features] ||= tags_for(ligatures, features) if features.frozen?
|
|
141
|
+
|
|
142
|
+
tags_for(ligatures, features)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def tags_for(ligatures, features)
|
|
146
|
+
features = Text::Style.features(features)
|
|
147
|
+
@keys[ligatures][features] ||= (ligatures ? (features + Gsub::LIGA).sort : features).freeze
|
|
137
148
|
end
|
|
138
149
|
|
|
139
150
|
# [gids, source text of each glyph, extra advance in font units after
|
|
@@ -170,17 +181,16 @@ module Stationery
|
|
|
170
181
|
width - space
|
|
171
182
|
end
|
|
172
183
|
|
|
173
|
-
|
|
174
|
-
|
|
184
|
+
# [advance in font units, kerning in thousandths of an em] of a string,
|
|
185
|
+
# remembered together so a measurement is one lookup.
|
|
186
|
+
def metrics(text, tags)
|
|
187
|
+
(@metrics[tags] ||= {})[text] ||= begin
|
|
175
188
|
gids, _, blanks = shape(text, tags)
|
|
176
|
-
gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 }
|
|
189
|
+
[gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 },
|
|
190
|
+
gids.each_cons(2).sum { |left, right| pair(left, right) }].freeze
|
|
177
191
|
end
|
|
178
192
|
end
|
|
179
193
|
|
|
180
|
-
def kerning_units(text, tags)
|
|
181
|
-
(@kerns[tags] ||= {})[text] ||= shape(text, tags).first.each_cons(2).sum { |left, right| pair(left, right) }
|
|
182
|
-
end
|
|
183
|
-
|
|
184
194
|
# Kerning between two glyphs in thousandths of an em.
|
|
185
195
|
def pair(left, right)
|
|
186
196
|
@pairs[(left << 16) | right] ||= @ttf.kerning.adjust(left, right) * 1000.0 / @ttf.units_per_em
|