stationery 0.8.0 → 0.10.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +251 -37
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/{svg → css}/selector.rb +1 -1
  6. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  7. data/lib/stationery/css.rb +20 -0
  8. data/lib/stationery/document.rb +35 -7
  9. data/lib/stationery/elements/forms.rb +11 -6
  10. data/lib/stationery/elements/rich.rb +21 -6
  11. data/lib/stationery/elements.rb +20 -7
  12. data/lib/stationery/errors.rb +4 -0
  13. data/lib/stationery/fonts/fallback.rb +1 -1
  14. data/lib/stationery/fonts/font.rb +28 -18
  15. data/lib/stationery/forms/acro_form.rb +95 -25
  16. data/lib/stationery/forms/appearance.rb +92 -30
  17. data/lib/stationery/forms/field.rb +20 -11
  18. data/lib/stationery/forms/metrics.rb +3 -17
  19. data/lib/stationery/forms/typeface.rb +113 -0
  20. data/lib/stationery/html/css.rb +276 -0
  21. data/lib/stationery/html/document.rb +10 -2
  22. data/lib/stationery/html/tokenizer.rb +15 -4
  23. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  24. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  25. data/lib/stationery/html/tree_builder.rb +44 -4
  26. data/lib/stationery/layout/flow.rb +29 -0
  27. data/lib/stationery/layout/image.rb +21 -7
  28. data/lib/stationery/layout/mark.rb +2 -0
  29. data/lib/stationery/layout/node.rb +4 -0
  30. data/lib/stationery/layout/table/cell.rb +14 -4
  31. data/lib/stationery/layout/table.rb +72 -14
  32. data/lib/stationery/layout/text.rb +47 -10
  33. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  34. data/lib/stationery/markdown/block_parser.rb +9 -3
  35. data/lib/stationery/markdown/document.rb +8 -2
  36. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  37. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  38. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  39. data/lib/stationery/markdown/inline_parser.rb +9 -3
  40. data/lib/stationery/minitest.rb +4 -0
  41. data/lib/stationery/pdf/assembler.rb +31 -11
  42. data/lib/stationery/pdf/conformance.rb +7 -5
  43. data/lib/stationery/pdf/serializer.rb +3 -1
  44. data/lib/stationery/pdf/signature/cms.rb +95 -0
  45. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  46. data/lib/stationery/pdf/signature.rb +174 -0
  47. data/lib/stationery/pdf/types.rb +4 -0
  48. data/lib/stationery/pdf/writer.rb +73 -19
  49. data/lib/stationery/rich/nesting.rb +30 -0
  50. data/lib/stationery/rich/nodes.rb +37 -11
  51. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  52. data/lib/stationery/rich/renderer/indents.rb +43 -0
  53. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  54. data/lib/stationery/rich/renderer.rb +49 -16
  55. data/lib/stationery/svg/document.rb +8 -4
  56. data/lib/stationery/svg/parser.rb +51 -11
  57. data/lib/stationery/svg/style.rb +4 -6
  58. data/lib/stationery/svg/walker.rb +2 -0
  59. data/lib/stationery/tagging/element.rb +1 -1
  60. data/lib/stationery/testing/inspector.rb +98 -0
  61. data/lib/stationery/testing/matchers.rb +25 -0
  62. data/lib/stationery/text/breaks.rb +123 -0
  63. data/lib/stationery/text/paragraph.rb +8 -0
  64. data/lib/stationery/text/runs_builder.rb +4 -0
  65. data/lib/stationery/text/wrapper.rb +69 -11
  66. data/lib/stationery/version.rb +1 -1
  67. data/lib/stationery/warnings.rb +13 -0
  68. data/lib/stationery.rb +6 -2
  69. metadata +13 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 21d52a1f02122474b5316373c70965424f3479973757219d2b7b87ed453e8642
4
- data.tar.gz: eaa7be7242e9f38e270cb23a533bac9038963c3e61c44c1e57ebaefe0288ef29
3
+ metadata.gz: 41ce5a83b1405e6756b6b2238b4796d1c7331e82f1abb51df2747befa61dbc26
4
+ data.tar.gz: a1c9fab02c1ce65eb761c96084c81b72117aee45ac2c0db8c1326ce94651f7a4
5
5
  SHA512:
6
- metadata.gz: 0dd292c0de3767e870563e954a9ea1b466beed56af675209d69baa09f7c44e337117a5b7256e41b4a35e39f694f0cfc721f2729cd1af8f7dc6081712a4263034
7
- data.tar.gz: 73286d789a65a258a026fc65fe8bbc1287dfd2df6ee13d07177cac13beba4f0e7bbcbdfc4bbafc4b3643d762efeb6a3cfb9cb6ab019e6a14c1351a920bc998db
6
+ metadata.gz: 9c1a85c89e74b002d14d6dd2aaa7153e34e746636ad6059b8db6a83f302b7058d4efb7430346463fc614ba0b61b7991226c44d724b62479d22ab5b1a007b419a
7
+ data.tar.gz: dfc30baf5b1803d38d7087eed1b24960fe79c3021f119b859d695458e128ed23baf74d961779b2bdb39f1071a0ed7cd09f51d1aa3881eb3076190f1a8d5a63b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 (2026-09-28)
4
+
5
+ CSS for `html`, better line and page breaking, timestamped signatures, and streaming to a block.
6
+
7
+ - `html` reads CSS: `<style>` rules (element, `.class`, `#id`, compound selectors, comma lists, `*`; also inside `@media print`/`all`) and inline `style`, cascaded after `styles:` by specificity. Properties: `color`, `font-size` (`px`, `pt`, `em`, `rem`, `%`, keywords), `font-weight`, `font-style`, `text-decoration`, `text-align`, `background-color` and `padding` on blocks and cells, `margin-top`/`margin-bottom`, `border` on `table`/`td`/`th`, `width` on `img`, `table` and cells, `page-break-before`/`-after`/`-inside` and the `break-*` forms. `<font color size>` and `<center>` are read too. Anything else, and selectors with combinators or pseudo-classes, is ignored and reported once as `Warnings::UnsupportedCss`; `url()` and `@import` are never read. HTML without styles renders as before.
8
+ - Orphans and widows: `orphans:` and `widows:` on `text`, `text_style`, `default_text` and rich `styles:` (`p`, `li`), default 1 and 1. A paragraph that would leave too few lines behind moves whole; one that would carry too few takes more with it; at the top of a fresh page it splits rather than overflow.
9
+ - Line breaking: a zero-width space (U+200B) and `<wbr>` are break opportunities that draw nothing and never reach the file. Lines may break between CJK characters (ideographs, kana, Hangul, fullwidth forms) with kinsoku rules: no break before closing punctuation, small kana and iteration marks, none after an opening bracket. Hyphenation and the character-break fallback leave CJK runs alone.
10
+ - Signature timestamps: `sign(…, timestamp: "https://tsa.example/tsr")` (or a Hash with `url:`, `username:`, `password:`, `hash:`, `client:`) requests an RFC 3161 token over the signature and embeds it as `id-aa-signatureTimeStampToken`, giving a PAdES-B-T signature. A TSA that cannot be reached, refuses, or answers for another imprint or nonce raises `Stationery::SignatureError`; nothing is written untimestamped. `Inspector#signatures` entries gain `timestamp: { time:, tsa:, valid: }`.
11
+ - Streaming: `to_pdf { |chunk| … }` hands the file over in pieces as objects are written (first bytes sooner, no output buffer) and returns the byte count. Layout still runs in full first, so peak memory is unchanged. Every other form of `to_pdf` returns the String and is byte for byte what it was. A block with `sign:` or with a target raises `ArgumentError`.
12
+ - **Behaviour changes:** a `page_break` inside a `group` now breaks the page (it was ignored unless the group overflowed). `image(width: 0.5)`: a Float up to 1 is a share of the available width, as for boxes and columns, so `width: 1.0` is full width, not one point. A table cell may be a Hash of cell options around its content. In SVG and HTML stylesheets an at-rule no longer swallows the rule after it.
13
+ - `Stationery::CSS` holds the selector and stylesheet parsers SVG and HTML share (`SVG::Selector` and `SVG::Stylesheet` remain as constants). `Text::RunsBuilder#scale` and `#with`.
14
+ - Benchmarks: `rake bench` compares three engines when the `:benchmark` group has sghtmltopdf (Prawn and stationery otherwise) and reports the best render, allocations, bytes and pages; the docs gain a Comparison page with a feature matrix against Prawn and sghtmltopdf.
15
+
16
+ ## 0.9.0 (2026-09-28)
17
+
18
+ Forms in the document's fonts, digital signatures, a much faster text and table pipeline, and hardening for untrusted input.
19
+
20
+ - 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.
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - `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.
25
+ - 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.
26
+
3
27
  ## 0.8.0 (2026-09-28)
4
28
 
5
29
  Compliance: PDF/A-3b, PDF/UA-1 and Factur-X e-invoices, verified with veraPDF and Mustang in CI.
data/README.md CHANGED
@@ -97,16 +97,17 @@ 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, styled by a CSS subset (`<style>` rules and inline `style`). 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
- `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `features` (OpenType feature tags, e.g. `%i[smcp onum]`), `hyphenate` (`true` for English, or `"de"`, `"sv"`; default off), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
109
+ `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`,
110
+ `orphans` and `widows` (the fewest lines a page break may leave behind and carry over; default 1, a paragraph that cannot meet them moves whole).
110
111
  `align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines
111
112
  ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
112
113
  Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
@@ -138,6 +139,81 @@ end
138
139
  `DroppedLink` warning. `links: %w[http https]` changes the list, `links: :all` keeps every href
139
140
  from a trusted source.
140
141
  - `gap:` spaces the blocks (default 6); `bookmarks: true` adds h1–h3 to the PDF outline.
142
+ - `html` reads a subset of CSS from `<style>` elements and inline `style` attributes (see
143
+ [What CSS is read](#what-css-is-read)); what it does not read is reported once as an
144
+ `UnsupportedCss` warning. `markdown` reads none.
145
+ - `max_depth:` (default 64) is how deep the source may nest: HTML elements inside one another,
146
+ or Markdown block quotes and lists. What lies deeper is flattened into the deepest element
147
+ kept, so its text stays and its structure goes, and a `NestingLimit` warning says how deep the
148
+ source went. Block quotes and lists also stop indenting after twelve levels, where they would
149
+ leave their text no width; that is reported the same way.
150
+
151
+ #### What CSS is read
152
+
153
+ `html` applies `<style>` rules and inline `style` attributes on top of `styles:`. The cascade is
154
+ the usual one: the element's defaults from `styles:`, then stylesheet rules by specificity (id over
155
+ class over element, the later rule winning a tie), then the inline style, then the element's own
156
+ mark (`<b>`, `<i>`, `<u>`, `<s>`).
157
+
158
+ Selectors are an element name, `.class`, `#id`, any compound of them (`p.lead`, `td.n.total`),
159
+ comma lists and `*`. Selectors with combinators or pseudo-classes (`ul li`, `a > b`, `a:hover`) are
160
+ ignored and reported. At-rules are skipped, except that rules inside `@media print` and
161
+ `@media all` are read.
162
+
163
+ | Property | Values | Applies to |
164
+ | --- | --- | --- |
165
+ | `color` | `#rgb`, `#rrggbb`, `rgb()`, `rgba()` (alpha ignored), the common colour names | text, inherited |
166
+ | `font-size` | `px` (0.75 pt), `pt`, `em`, `%`, `xx-small` to `xx-large`, `smaller`, `larger`; relative sizes multiply the size around them | text, inherited |
167
+ | `font-weight` | `bold`, `normal`, `100`–`900` (600 and up is bold) | text, inherited |
168
+ | `font-style` | `italic`, `oblique`, `normal` | text, inherited |
169
+ | `text-decoration` | `underline`, `line-through`, `none` | text, inherited |
170
+ | `text-align` | `left`, `center`, `right`, `justify` | paragraphs, headings, cells, images; inherited from a container |
171
+ | `background-color` | as `color`, or `transparent` | `p`, `div` and other containers, `blockquote`, `pre`, `table`, `td`, `th` |
172
+ | `padding`, `padding-top` … `padding-left` | one to four lengths in `px` or `pt` | the same |
173
+ | `margin`, `margin-top`, `margin-bottom` | lengths in `px` or `pt`; top and bottom only, added to `gap:` | blocks |
174
+ | `border` | `1px solid #ccc` in any order, `none` | `table` (every cell), `td`, `th` |
175
+ | `width` | `px`, `pt`, `%`, `auto` | `img`, `table`, and `td`/`th` (column widths, when every cell of the first row has one) |
176
+ | `page-break-before`, `page-break-after`, `break-before`, `break-after` | `always`, `page`, `auto` | blocks |
177
+ | `page-break-inside`, `break-inside` | `avoid`, `auto` | blocks |
178
+
179
+ `<font color size>` and `<center>` are read the same way. Everything else (`display`, `float`,
180
+ `position`, `font-family`, `line-height`, `em` lengths outside `font-size`, `url()` values,
181
+ inline backgrounds) is ignored and named in the `UnsupportedCss` warning, so `strict` catches
182
+ content that expects more than this. No value is ever fetched: `url()` and `@import` are dropped.
183
+
184
+ ```ruby
185
+ html <<~HTML
186
+ <style>
187
+ .note { background-color: #FEF3C7; padding: 8pt; margin: 6pt 0; break-inside: avoid }
188
+ td.amount { text-align: right; width: 25% }
189
+ h2 { page-break-before: always; color: #0F766E }
190
+ </style>
191
+ <div class="note"><p><b>Note.</b> Prices include VAT.</p></div>
192
+ HTML
193
+ ```
194
+
195
+ #### Untrusted input
196
+
197
+ `html` and `markdown` are meant for content you did not write (a CMS body, a comment, an
198
+ ActionText field), and nothing in that content can reach outside the document or take the
199
+ process down:
200
+
201
+ - **No requests.** Remote images are never fetched; an image is read only from `images:` or from
202
+ under `base_path:`, and a path that climbs out of it is skipped (`SkippedImage`).
203
+ - **No surprising links.** Only `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`) become
204
+ links (`DroppedLink` for the rest), so a `javascript:` or `file:` href is plain text.
205
+ - **No scripts, no fetched styles.** `script`, `template` and `title` content is dropped and raw
206
+ HTML inside Markdown stays literal text. CSS is read only for the fixed list of properties
207
+ above: `url()` and `@import` are dropped unread, and nothing in a style can position content
208
+ outside the flow or hide it.
209
+ - **Bounded nesting.** Five thousand nested `<div>`s, block quotes or lists render flattened,
210
+ with a `NestingLimit` warning, instead of exhausting the stack (the `svg` element guards its
211
+ own nesting the same way, at 128 levels). Emphasis nests without recursion, at most 64
212
+ brackets wait for their `]`, and a link label is at most 999 characters, so parsing takes time
213
+ in proportion to the input.
214
+ - **Size is yours to bound.** There is no limit on how much text is rendered: a megabyte of
215
+ input is a few hundred pages. Cap the length of what you accept before you render it, and use
216
+ `strict` (or read `document.warnings`) when a warning should stop the document.
141
217
 
142
218
  ### Links, bookmarks and table of contents
143
219
 
@@ -190,7 +266,8 @@ keeps its title, with no number and no link.
190
266
  ### Forms
191
267
 
192
268
  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.
269
+ position with `at: [x, y]`. They work inside boxes, rows and table cells. The
270
+ [Forms guide](https://stationery.zoolutions.llc/docs/forms) lists every option of every field.
194
271
 
195
272
  ```ruby
196
273
  text "Name"
@@ -204,14 +281,24 @@ checkbox "terms", checked: false, label: "I accept the terms"
204
281
  signature_field "signature", label: "Signature of the applicant"
205
282
  ```
206
283
 
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.
284
+ - Every widget carries its own appearance, so the form looks the same in every viewer. Text is set
285
+ in the text style around the field, in the document's own fonts: embedded, with the fallbacks
286
+ applied per character, so a value in any script the fonts cover is drawn (`/V` holds it as
287
+ Unicode). Check marks and radio dots are paths. `NeedAppearances` is set too, so viewers redraw
288
+ edited values; ZapfDingbats is listed for the ones that redraw a button's mark, never embedded
289
+ and never used by the appearances themselves.
290
+ - A field that can be edited keeps printable ASCII and Latin-1 in its font beyond the value it shows
291
+ (and a select the glyphs of every option), about 10 KB per font, so the text a viewer redraws
292
+ after an edit has its glyphs; what is typed outside that range falls back to the viewer's own
293
+ font. A `read_only:` field keeps its value's glyphs only.
294
+ - `tooltip:` is a field's accessible name (`/TU`): by default a check box's or signature's `label:`,
295
+ otherwise the field name. A radio group takes the `tooltip:` of its first choice.
210
296
  - Dotted names build the field hierarchy viewers show as groups; widgets sharing a name are one field
211
297
  with several widgets. A name used as both a field and a group raises `ArgumentError`.
212
298
  - `read_only:`, `required:`, `multiline:`, `max_length:` and `comb:` set the matching field flags.
213
299
  - 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.
300
+ lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the
301
+ signer, unless `sign field:` signs it (see [Digital signatures](#digital-signatures)).
215
302
  - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
216
303
  `false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
217
304
 
@@ -405,12 +492,14 @@ mislabelled: `ArgumentError` for options that contradict the level, `Stationery:
405
492
  every link annotation a description (`/Contents`: the URL, or the target page). A figure without
406
493
  `alt:` raises; mark decoration with `alt: false`. Encryption is allowed.
407
494
  - 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.
495
+ - Interactive form fields are allowed: their appearances draw with embedded fonts and paths, every
496
+ field has a `/TU`, and `NeedAppearances` and ZapfDingbats are left out. Only a field made without
497
+ a font book (`Forms::Field.new` placed with `canvas.widget`) raises, since it draws with the
498
+ standard Helvetica.
410
499
  - Without `conformance` nothing changes: the output is byte for byte what it was.
411
500
 
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
501
+ `bundle exec rake verify:conformance` renders `examples/invoice.rb` as PDF/A-3b, and
502
+ `examples/report.rb` and `examples/form.rb` as PDF/A-3b plus PDF/UA-1, and validates them with
414
503
  [veraPDF](https://verapdf.org) through Docker (`verapdf/cli`); CI runs it on every push. Validate
415
504
  your own documents the same way:
416
505
 
@@ -451,7 +540,7 @@ InvoicePdf.new(invoice).to_pdf(factur_x: { xml:, profile: :extended }) # per ren
451
540
  with `<?xml` or `<rsm:CrossIndustryInvoice` raises `ArgumentError`, and what is inside is your
452
541
  invoicing code's. `examples/e_invoice.rb` builds a minimal EN 16931 document from the example
453
542
  invoice's lines.
454
- - Everything PDF/A-3b asks applies: no `encrypt:`, no form fields.
543
+ - Everything PDF/A-3b asks applies: no `encrypt:`. Form fields and a signature are fine.
455
544
  - Without `factur_x` nothing changes.
456
545
 
457
546
  `bundle exec rake verify:factur_x` validates `examples/e_invoice.rb` with the
@@ -459,6 +548,73 @@ InvoicePdf.new(invoice).to_pdf(factur_x: { xml:, profile: :extended }) # per ren
459
548
  XML against the EN 16931 schema and business rules. CI runs it with `verify:conformance`. In
460
549
  tests, `have_factur_x(profile: :en16931)` checks that the invoice is named in XMP and embedded.
461
550
 
551
+ ### Digital signatures
552
+
553
+ `sign` signs the file with a certificate and its private key, so a reader can tell who issued it
554
+ and that not a byte changed since. It needs nothing but Ruby's own `openssl`, loaded when the first
555
+ signature is made.
556
+
557
+ ```ruby
558
+ class ContractPdf < Stationery::Document
559
+ sign certificate: -> { Rails.application.credentials.dig(:signing, :certificate) }, # PEM or OpenSSL object
560
+ key: -> { Rails.application.credentials.dig(:signing, :key) }, # read per render
561
+ chain: -> { [File.read("config/intermediate.pem")] },
562
+ reason: "Approved", location: "Malmö", contact: "legal@acme.test",
563
+ timestamp: "https://tsa.example/tsr" # optional: PAdES B-T
564
+ # sign { { certificate: signer.certificate, key: signer.key, field: "approval" } } # or a block for all of it
565
+
566
+ def view_template
567
+ text "Contract"
568
+ signature_field "approval", label: "Approved by" # `field: "approval"` signs this one
569
+ end
570
+ end
571
+
572
+ ContractPdf.new.to_pdf(sign: { certificate:, key:, passphrase: "…" }) # per render; nil for none
573
+ ```
574
+
575
+ - The signature is `/SubFilter /ETSI.CAdES.detached`: a detached CMS over every byte of the file
576
+ except the signature itself, SHA-256 with an RSA (PKCS #1 v1.5) or EC (ECDSA) key, with the
577
+ signed attributes PAdES baseline B-B asks for (content type, message digest and the ESS
578
+ signing-certificate-v2 that binds it to the certificate; the signing time is the dictionary's
579
+ `/M`). The certificate and its `chain:` travel in the signature.
580
+ - `certificate:` and `chain:` take an `OpenSSL::X509::Certificate` or PEM, `key:` an
581
+ `OpenSSL::PKey` or PEM (`passphrase:` when it is encrypted). Any value may be a block or callable
582
+ answering it for the document being rendered, and `certificate:`, `key:`, `chain:` and
583
+ `passphrase:` a method name, so secrets are read when they are needed and never at class load.
584
+ A key that is not the certificate's raises `ArgumentError`.
585
+ - `field:` names the `signature_field` to sign, which keeps its appearance. Without it the
586
+ signature is invisible: a field of its own (`Signature1`) whose widget has no size, on the first
587
+ page. `name:` is the signer's name (the certificate's common name by default), `at:` the signing
588
+ time (`Time.now`).
589
+ - `timestamp: "https://tsa.example/tsr"` asks an RFC 3161 time-stamping authority (TSA) to sign the
590
+ signature value and the time it saw it, and carries that token in the signature's unsigned
591
+ attributes: PAdES baseline B-T. A reader can then trust the signing time, and the signature, after
592
+ the signer's certificate has expired. It is one HTTP request per render, with Ruby's own
593
+ `net/http`; `timestamp: { url:, username:, password:, hash: :sha256 }` adds HTTP basic auth or
594
+ another digest (`:sha384`, `:sha512`), and `client:` (a callable given the request DER and
595
+ answering the response DER) replaces the HTTP client. A TSA that cannot be reached, refuses, or
596
+ answers for another request or another digest raises `Stationery::SignatureError`: the document
597
+ is never written without the timestamp it was asked for.
598
+ - The file is written once, with `contents_size:` bytes kept free for the signature (8192, or
599
+ 16384 with a timestamp, whose token brings the TSA's certificates along), which is then filled in
600
+ place. A long certificate chain may need more; a signature that does not fit raises and says so.
601
+ - A signed form asks viewers not to regenerate appearances (`NeedAppearances` is left out) and
602
+ sets `/SigFlags 3`. `encrypt:` and `conformance` combine with `sign`: the signature is the one
603
+ string encryption leaves in the clear, and a signed PDF/A-3b or PDF/UA-1 file still validates.
604
+ - Not covered: long-term validation data (LTV: the revocation answers and document timestamps of
605
+ PAdES B-LT and B-LTA), a second signature, and signing a file that already exists. All of them
606
+ need incremental updates; Stationery signs what it renders, once. Whether a viewer trusts the
607
+ signer, or the TSA, is decided by their certificates and the viewer's trust list, not by the
608
+ file.
609
+
610
+ `bundle exec rake verify:signature` signs `examples/invoice.rb` with a throwaway certificate and
611
+ verifies it with `openssl cms -verify` and, when poppler is installed, `pdfsig`; with
612
+ `TSA_URL=http://timestamp.digicert.com` it also timestamps a PDF/A-3b render at that authority and
613
+ checks the token (`verify:timestamp`, not part of CI: it needs the network).
614
+ `verify:conformance` validates signed PDF/A-3b and PDF/UA-1 renders with veraPDF. In tests,
615
+ `have_signature(name: "Acme Legal")` checks that a signature covers the whole file and verifies
616
+ against the certificate it carries.
617
+
462
618
  ### Debugging
463
619
 
464
620
  `to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed),
@@ -476,6 +632,20 @@ Invoice.new.to_pdf("invoice.pdf", debug: %i[cell cell_padding])
476
632
  gem "stationery", require: "stationery/rails"
477
633
  ```
478
634
 
635
+ ### Large documents
636
+
637
+ `to_pdf { |chunk| … }` streams the file to the block in pieces as it is written and answers the
638
+ number of bytes: the first bytes leave sooner and no output buffer is built. Peak memory does
639
+ not drop. Layout runs in full before the first byte (pagination has to see every page) and the
640
+ laid-out pages are what a render holds: a 938-page document peaks at 187 MB as a String and
641
+ 185 MB streamed. The streamed file lists its objects in the order they were written, a page's
642
+ before the next page's and fonts, the structure tree and the catalog last; the String keeps them
643
+ in numbered order. Both are the same document. A signed document cannot go to a block, since the
644
+ signature covers every byte (`ArgumentError`). `to_pdf`, `to_pdf(path)` and `to_pdf(io)` build the
645
+ String and return it. In a controller with `ActionController::Live`,
646
+ `document.to_pdf { |chunk| response.stream.write(chunk) }` streams a download; `send_pdf` and
647
+ `render pdf:` buffer.
648
+
479
649
  Controllers gain `render pdf:` and `send_pdf`:
480
650
 
481
651
  ```ruby
@@ -665,6 +835,10 @@ names their authors and licences), drawing a hyphen at the break. A soft hyphen
665
835
  TeX, exempts that word from the patterns; it is never measured or drawn and
666
836
  never reaches the PDF. `Stationery::Hyphenation.hyphenate("Silbentrennung", "de")`
667
837
  answers `["Sil", "ben", "tren", "nung"]` for your own use.
838
+ Lines also break at a zero-width space (U+200B, `<wbr>` in HTML), which is never
839
+ drawn, and between ideographic characters (CJK ideographs, kana, Hangul), keeping
840
+ a closing mark such as 。」 on the line before it and an opening bracket with what
841
+ follows, so Japanese, Chinese and Korean text wraps without spaces.
668
842
 
669
843
  Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
670
844
  mask). Parsed fonts and images are cached per process.
@@ -712,6 +886,7 @@ RSpec.describe InvoicePdf do
712
886
  it { is_expected.to have_attachment("factur-x.xml", mime: "text/xml", relationship: :alternative) }
713
887
  it { is_expected.to have_conformance(:pdf_a3b) } # the level claimed in XMP, from `conformance`
714
888
  it { is_expected.to have_factur_x(profile: :en16931) } # the e-invoice XML, named in XMP and embedded
889
+ it { is_expected.to have_signature(name: "Acme Legal") } # covers the whole file and verifies
715
890
  it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
716
891
  it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
717
892
  end
@@ -737,6 +912,7 @@ class InvoicePdfTest < Minitest::Test
737
912
  assert_pdf_attachment pdf, "factur-x.xml", mime: "text/xml"
738
913
  assert_pdf_conformance pdf, :pdf_a3b
739
914
  assert_factur_x pdf, profile: :en16931
915
+ assert_pdf_signature pdf, name: "Acme Legal"
740
916
  assert_tagged_content pdf
741
917
  assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
742
918
  end
@@ -750,7 +926,9 @@ titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and insid
750
926
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
751
927
  `image_count`, `bookmarks`, `metadata`, `xmp` (the packet), `xmp_values` (`{ "dc:title" => …, "dc:creator" => […] }`),
752
928
  `lang`, `page_labels`, `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
753
- (`{ profile:, filename:, version:, xml: }`), `warnings`, `tagged?`,
929
+ (`{ profile:, filename:, version:, xml: }`), `signatures` (`[{ field:, name:, reason:, location:,
930
+ signed_at:, subfilter:, byte_range:, signer:, valid:, timestamp: }]`, the timestamp `nil` or
931
+ `{ time:, tsa:, valid: }`), `warnings`, `tagged?`,
754
932
  `untagged_text` and
755
933
  `structure` — a tagged PDF's structure tree as nested arrays, each element's text
756
934
  read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
@@ -761,6 +939,9 @@ between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
761
939
 
762
940
  - **Prawn** is an imperative cursor API: every document does its own layout
763
941
  arithmetic. Stationery is a layout engine with a component DSL.
942
+ - **sghtmltopdf** renders HTML and CSS in Rust, fast and with a real CSS layout
943
+ engine, but writes no outline, forms, tagged PDF, PDF/A, encryption or
944
+ signatures, and is a native extension.
764
945
  - **Headless Chrome** (Grover, ferrum_pdf) renders HTML beautifully but runs
765
946
  a browser per worker; stray processes and memory are the price.
766
947
  - **Typst** is excellent but a native extension and a second template language.
@@ -802,21 +983,49 @@ events attached is the most useful thing to put in an issue.
802
983
 
803
984
  ## Performance
804
985
 
805
- `bundle exec rake bench` renders two documents with Stationery and with Prawn
806
- 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:
808
-
809
- | Document | Engine | Renders/s | Objects allocated | PDF bytes |
810
- |---|---|---:|---:|---:|
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 |
815
-
816
- Stationery compresses content streams; Prawn does not by default, hence the
817
- larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
818
- 20 hottest frames of the table render under StackProf (wall mode; `MODE=cpu` or
819
- `MODE=object` for the others).
986
+ `bundle exec rake bench` renders three documents with Stationery, with Prawn 2.5 +
987
+ prawn-table, and with [sghtmltopdf](https://github.com/waka/sghtmltopdf) 0.5.1, an
988
+ HTML-to-PDF engine written in Rust, given HTML/CSS twins of the same documents. Every
989
+ engine embeds the same Open Sans TTF files (`benchmark/`, not part of CI). Apple M2 Max,
990
+ Ruby 3.4.2 +YJIT, 28 September 2026, warm caches, the best of three runs:
991
+
992
+ | Document | Engine | Best render | Renders/s | Objects allocated | PDF bytes | Pages |
993
+ |---|---|---:|---:|---:|---:|---:|
994
+ | Invoice (`examples/invoice.rb`) | Stationery | 13.1 ms | 70.1 | 39,562 | 26,599 | 1 |
995
+ | | Prawn | 18.3 ms | 45.2 | 88,558 | 33,034 | 1 |
996
+ | | sghtmltopdf | 3.4 ms | 259.4 | 53 | 36,693 | 1 |
997
+ | Table, 1,500 rows × 5 columns, repeating header | Stationery | 395 ms | 2.45 | 2,596,598 | 133,754 | 46 |
998
+ | | Prawn | 1,373 ms | 0.71 | 6,901,807 | 2,689,487 | 40 |
999
+ | | sghtmltopdf | 96 ms | 9.99 | 53 | 369,338 | 45 |
1000
+ | Cover photo and six illustrated sections | Stationery | 9.0 ms | 99.8 | 45,866 | 41,531 | 3 |
1001
+ | | Prawn | 29.7 ms | 30.9 | 102,967 | 55,391 | 2 |
1002
+ | | sghtmltopdf | 6.7 ms | 136.8 | 53 | 43,031 | 2 |
1003
+
1004
+ sghtmltopdf is the fastest: 3.9x Stationery on the invoice, 4.1x on the table and 1.3x on
1005
+ the photo document. That is native code against Ruby, and its 53 objects are the binding's:
1006
+ the engine allocates outside the Ruby heap. Stationery is 1.4x, 3.5x and 3.3x faster than
1007
+ Prawn and writes the smallest files of the three (28%, 64% and 3% smaller than
1008
+ sghtmltopdf's). Stationery compresses content streams; Prawn does not by default, hence its
1009
+ larger files. Page counts differ where the engines' line heights and table padding do.
1010
+ "Best render" is the fastest single render, the figure other work on the machine disturbs
1011
+ least; renders per second is the average over five seconds.
1012
+
1013
+ What Stationery optimises for is not the last millisecond: it is pure Ruby with no native
1014
+ extension to compile or precompile, its output is the same bytes on every platform, and the
1015
+ PDF features (tagged output, PDF/A, forms, signatures) live in the same process as your
1016
+ models. See the [comparison](https://stationery.zoolutions.llc/docs/comparison) for what
1017
+ each engine does.
1018
+
1019
+ `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the 20 hottest frames of the
1020
+ table render under StackProf (wall mode; `MODE=cpu` or `MODE=object` for the others).
1021
+ `SGHTMLTOPDF=0 bundle exec rake bench` leaves the third engine out, as does a machine
1022
+ without its gem.
1023
+
1024
+ Time depends on the machine, so CI holds what does not: `bundle exec rake metrics`
1025
+ renders six fixed documents and compares the objects each render allocates, its
1026
+ page count and its bytes with `benchmark/baseline.json` (allocations may grow 3%,
1027
+ bytes 1%, pages not at all). A change that moves them on purpose records a new
1028
+ baseline with `bundle exec rake metrics:update` and says why in the commit.
820
1029
 
821
1030
  ## Limitations
822
1031
 
@@ -824,7 +1033,8 @@ Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert
824
1033
  `.woff`); shaping stops at pair kerning and single or ligature substitutions (`liga` by default,
825
1034
  `smcp`, `onum`, `tnum`, `ss01`… on request), so contextual alternates (`calt`, `clig`, `frac`) do
826
1035
  nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph, and colour or emoji glyphs no font
827
- in the chain has are drawn as `.notdef` and reported. Text runs left to right; hyphenation
1036
+ in the chain has are drawn as `.notdef` and reported. Text runs left to right (CJK text wraps between
1037
+ ideographs, but there is no vertical layout); hyphenation
828
1038
  patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
829
1039
  and justification only widens spaces.
830
1040
 
@@ -833,18 +1043,22 @@ sets and exports use, nothing else: `image`, `mask`, `pattern`, `filter` and `te
833
1043
  and reported, text inside a `clipPath` does not clip, and the shapes of a clip path join into one
834
1044
  path, so overlapping shapes wound in opposite directions cancel where they overlap.
835
1045
  Images are JPEG and PNG (non-interlaced) and never fetched from a URL; a JPEG is embedded at its
836
- source resolution (only PNGs can be downscaled), so an oversized one is reported, not resized. `html` and `markdown` render structure and inline marks, not CSS: only `text-align`
837
- and inline `font-weight`/`font-style` are read, and raw HTML inside Markdown stays literal text.
1046
+ source resolution (only PNGs can be downscaled), so an oversized one is reported, not resized. `html` reads a fixed subset of CSS (colours, sizes, weights, alignment, margins, padding, table
1047
+ borders and widths, page breaks; see [What CSS is read](#what-css-is-read)), not a layout
1048
+ engine's worth: no `display`, floats, positioning, `font-family` or selectors with combinators.
1049
+ `markdown` reads no CSS, and raw HTML inside Markdown stays literal text.
838
1050
 
839
1051
  Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
840
1052
  splits only when every column can; a rotated box and a `stack` move to the next page whole. Text
841
1053
  does not wrap around images. Link and form-widget rectangles stay in page space inside `rotate`
842
1054
  and `transform`, and `shadow:` is stacked rectangles, not a blur.
843
1055
 
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.
1056
+ 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
1057
+ no JavaScript. A render carries one signature (`/ETSI.CAdES.detached`, RSA or EC with SHA-256):
1058
+ no long-term validation data (PAdES B-LT), no second signature and no signing
1059
+ of a file that already exists, all of which need incremental updates.
1060
+ Form fields are set in the document's fonts, but text typed into one is drawn by the viewer:
1061
+ characters outside the glyphs the field kept (ASCII and Latin-1) use the viewer's own font.
848
1062
 
849
1063
  ## License
850
1064
 
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- module SVG
4
+ module CSS
5
5
  # A compound CSS selector: an element name or `*`, then any number of
6
6
  # `.class` and `#id` parts. Combinators, pseudo-classes and attribute
7
7
  # selectors are not read.
@@ -1,25 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- module SVG
4
+ module CSS
5
5
  # The rules of a document's <style> elements. An element's declarations
6
6
  # are those of every matching rule, the more specific winning and the
7
- # later winning among equals. At-rules are skipped and `!important` is
8
- # read as a plain declaration; rules whose selectors use combinators are
9
- # ignored and listed in #unsupported.
7
+ # later winning among equals. At-rules are skipped (`@import`, `@font-face`,
8
+ # `@page`, `@media screen`), except that the rules inside `@media print`
9
+ # and `@media all` are read; `!important` is read as a plain declaration;
10
+ # rules whose selectors use combinators are ignored and listed in
11
+ # #unsupported.
10
12
  class Stylesheet
11
13
  Rule = Data.define(:selector, :declarations, :index)
12
14
 
13
15
  RULE = /([^{}]+)\{([^{}]*)\}/
14
16
  COMMENT = %r{/\*.*?\*/}m
17
+ STATEMENT = /@[\w-]+[^;{}]*;/
18
+ AT_BLOCK = /@([\w-]+)([^{};]*)\{((?:[^{}]*\{[^{}]*\})*[^{}]*)\}/
19
+ PRINTED = /\b(print|all)\b/i
15
20
 
16
21
  def self.parse(css)
17
22
  rules = []
18
23
  unsupported = []
19
- css.gsub(COMMENT, "").scan(RULE).each do |selectors, body|
24
+ plain(css).scan(RULE).each do |selectors, body|
20
25
  next if selectors.strip.start_with?("@")
21
26
 
22
- declarations = Style.declarations(body).transform_values { |value| value.sub(/\s*!important\z/, "") }
27
+ declarations = CSS.declarations(body).transform_values { |value| value.sub(/\s*!important\z/, "") }
23
28
  selectors.split(",").map(&:strip).each do |text|
24
29
  selector = Selector.parse(text)
25
30
  selector ? rules << Rule.new(selector, declarations, rules.size) : unsupported << text
@@ -28,6 +33,13 @@ module Stationery
28
33
  new(rules, unsupported)
29
34
  end
30
35
 
36
+ # The rules outside at-rules, plus those a print medium would read.
37
+ def self.plain(css)
38
+ css.gsub(COMMENT, "").gsub(STATEMENT, "").gsub(AT_BLOCK) do
39
+ Regexp.last_match(1).casecmp?("media") && Regexp.last_match(2).match?(PRINTED) ? Regexp.last_match(3) : ""
40
+ end
41
+ end
42
+
31
43
  attr_reader :unsupported
32
44
 
33
45
  def initialize(rules, unsupported)
@@ -37,6 +49,8 @@ module Stationery
37
49
 
38
50
  EMPTY = new([], []).freeze
39
51
 
52
+ def empty? = @rules.empty?
53
+
40
54
  def declarations(element)
41
55
  @rules.select { |rule| rule.selector.match?(element) }
42
56
  .sort_by { |rule| [rule.selector.specificity, rule.index] }
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "css/selector"
4
+ require_relative "css/stylesheet"
5
+
6
+ module Stationery
7
+ # The CSS the svg and html elements read: `<style>` rules with element,
8
+ # class and id selectors, and inline `style` declarations.
9
+ module CSS
10
+ module_function
11
+
12
+ # `"a: 1; b : 2"` → `{ "a" => "1", "b" => "2" }`; names lowercased.
13
+ def declarations(style)
14
+ style.to_s.split(";").filter_map do |declaration|
15
+ key, value = declaration.split(":", 2)&.map(&:strip)
16
+ [key.downcase, value] if key && value && !key.empty?
17
+ end.to_h
18
+ end
19
+ end
20
+ end