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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +128 -23
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/document.rb +20 -4
  6. data/lib/stationery/elements/forms.rb +11 -6
  7. data/lib/stationery/elements/rich.rb +13 -6
  8. data/lib/stationery/elements.rb +11 -3
  9. data/lib/stationery/fonts/font.rb +28 -18
  10. data/lib/stationery/forms/acro_form.rb +95 -25
  11. data/lib/stationery/forms/appearance.rb +92 -30
  12. data/lib/stationery/forms/field.rb +20 -11
  13. data/lib/stationery/forms/metrics.rb +3 -17
  14. data/lib/stationery/forms/typeface.rb +113 -0
  15. data/lib/stationery/html/document.rb +8 -2
  16. data/lib/stationery/html/tree_builder.rb +34 -4
  17. data/lib/stationery/layout/image.rb +9 -1
  18. data/lib/stationery/layout/table/cell.rb +14 -4
  19. data/lib/stationery/layout/table.rb +72 -14
  20. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  21. data/lib/stationery/markdown/block_parser.rb +9 -3
  22. data/lib/stationery/markdown/document.rb +8 -2
  23. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  24. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  25. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  26. data/lib/stationery/markdown/inline_parser.rb +9 -3
  27. data/lib/stationery/minitest.rb +4 -0
  28. data/lib/stationery/pdf/assembler.rb +19 -9
  29. data/lib/stationery/pdf/conformance.rb +7 -5
  30. data/lib/stationery/pdf/serializer.rb +3 -1
  31. data/lib/stationery/pdf/signature/cms.rb +87 -0
  32. data/lib/stationery/pdf/signature.rb +167 -0
  33. data/lib/stationery/pdf/types.rb +4 -0
  34. data/lib/stationery/rich/nesting.rb +30 -0
  35. data/lib/stationery/rich/renderer/indents.rb +42 -0
  36. data/lib/stationery/rich/renderer.rb +22 -6
  37. data/lib/stationery/svg/document.rb +8 -4
  38. data/lib/stationery/svg/parser.rb +51 -11
  39. data/lib/stationery/svg/walker.rb +2 -0
  40. data/lib/stationery/tagging/element.rb +1 -1
  41. data/lib/stationery/testing/inspector.rb +55 -0
  42. data/lib/stationery/testing/matchers.rb +25 -0
  43. data/lib/stationery/text/wrapper.rb +12 -3
  44. data/lib/stationery/version.rb +1 -1
  45. data/lib/stationery/warnings.rb +4 -0
  46. data/lib/stationery.rb +3 -0
  47. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21d52a1f02122474b5316373c70965424f3479973757219d2b7b87ed453e8642
4
- data.tar.gz: eaa7be7242e9f38e270cb23a533bac9038963c3e61c44c1e57ebaefe0288ef29
3
+ metadata.gz: '04892e3a1ad9e6da0174cb033f2a57a048c83c77e57e72aff4a98ccfa48e1ba7'
4
+ data.tar.gz: f9ee80ed8edd77108f3d6ae4f19f5a9d537000e6cf0e1efd8707e2682e89faac
5
5
  SHA512:
6
- metadata.gz: 0dd292c0de3767e870563e954a9ea1b466beed56af675209d69baa09f7c44e337117a5b7256e41b4a35e39f694f0cfc721f2729cd1af8f7dc6081712a4263034
7
- data.tar.gz: 73286d789a65a258a026fc65fe8bbc1287dfd2df6ee13d07177cac13beba4f0e7bbcbdfc4bbafc4b3643d762efeb6a3cfb9cb6ab019e6a14c1351a920bc998db
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 (drawn in Helvetica, ZapfDingbats for the check mark), so
208
- the form looks the same in every viewer; `NeedAppearances` is set too, so viewers redraw edited
209
- values. Values are Unicode (`/V`); the drawn appearance covers the Windows-1252 range.
235
+ - Every widget carries its own appearance, so the form looks the same in every viewer. Text is set
236
+ in the text style around the field, in the document's own fonts: embedded, with the fallbacks
237
+ applied per character, so a value in any script the fonts cover is drawn (`/V` holds it as
238
+ Unicode). Check marks and radio dots are paths. `NeedAppearances` is set too, so viewers redraw
239
+ edited values; ZapfDingbats is listed for the ones that redraw a button's mark, never embedded
240
+ and never used by the appearances themselves.
241
+ - A field that can be edited keeps printable ASCII and Latin-1 in its font beyond the value it shows
242
+ (and a select the glyphs of every option), about 10 KB per font, so the text a viewer redraws
243
+ after an edit has its glyphs; what is typed outside that range falls back to the viewer's own
244
+ font. A `read_only:` field keeps its value's glyphs only.
245
+ - `tooltip:` is a field's accessible name (`/TU`): by default a check box's or signature's `label:`,
246
+ otherwise the field name. A radio group takes the `tooltip:` of its first choice.
210
247
  - Dotted names build the field hierarchy viewers show as groups; widgets sharing a name are one field
211
248
  with several widgets. A name used as both a field and a group raises `ArgumentError`.
212
249
  - `read_only:`, `required:`, `multiline:`, `max_length:` and `comb:` set the matching field flags.
213
250
  - A radio group's value is its checked choice's `value` (`Off` when none is checked); a select box
214
- lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the signer.
251
+ lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the
252
+ signer, unless `sign field:` signs it (see [Digital signatures](#digital-signatures)).
215
253
  - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
216
254
  `false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
217
255
 
@@ -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 raise under every level: their appearances draw with the standard
409
- Helvetica and ZapfDingbats, which are not embedded.
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:`, no form fields.
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: }`), `warnings`, `tagged?`,
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, 27 September 2026:
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 | 74.6 | 33,231 | 23,525 |
812
- | | Prawn | 45.4 (1.64x slower) | 88,558 | 33,034 |
813
- | Table, 1,500 rows × 5 columns, repeating header | Stationery | 1.47 (46 pages) | 4,462,820 | 233,389 |
814
- | | Prawn | 0.74 (40 pages; 1.99x slower) | 6,901,818 | 2,689,487 |
908
+ | Invoice (1 page, `examples/invoice.rb`) | Stationery | 66.5 | 39,574 | 26,599 |
909
+ | | Prawn | 41.3 (1.61x slower) | 88,558 | 33,034 |
910
+ | Table, 1,500 rows × 5 columns, repeating header | Stationery | 2.31 (46 pages) | 2,596,554 | 133,754 |
911
+ | | Prawn | 0.58 (40 pages; 4.01x slower) | 6,901,811 | 2,689,487 |
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); 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.
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
 
@@ -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
@@ -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
- def html(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
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), styles:, gap:, images:, base_path:, bookmarks:, links:)
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, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
20
+ def markdown(source, max_depth: 64, **)
18
21
  require_relative "../markdown/document"
19
- rich(Markdown.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
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 rich(blocks, **)
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, **).render(blocks)
33
+ Rich::Renderer.new(self, @_builder, styles:, gap:, images:, base_path:, bookmarks:, links:).render(blocks)
27
34
  end
28
35
  end
29
36
  end
@@ -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
- if document.unsupported.any?
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
- @advances = {}
38
- @kerns = {}
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
- width = scale(advance_units(text, key), size) + (letter_spacing * shape(text, key).first.size)
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 + (kerning_units(text, key) * size / 1000.0)
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. Frozen and shared, so it keys the memos cheaply.
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
- features = Text::Style.features(features) unless features.frozen? && features.all?(String)
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
- @keys ||= {}
136
- @keys[features] ||= (features + Gsub::LIGA).sort.freeze
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
- def advance_units(text, tags)
174
- (@advances[tags] ||= {})[text] ||= begin
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