stationery 0.9.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/README.md +143 -34
  4. data/lib/stationery/{svg → css}/selector.rb +1 -1
  5. data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
  6. data/lib/stationery/css.rb +20 -0
  7. data/lib/stationery/document.rb +21 -9
  8. data/lib/stationery/elements/rich.rb +10 -2
  9. data/lib/stationery/elements.rb +9 -4
  10. data/lib/stationery/errors.rb +4 -0
  11. data/lib/stationery/fonts/fallback.rb +1 -1
  12. data/lib/stationery/html/css.rb +276 -0
  13. data/lib/stationery/html/document.rb +5 -3
  14. data/lib/stationery/html/tokenizer.rb +15 -4
  15. data/lib/stationery/html/tree_builder/blocks.rb +90 -40
  16. data/lib/stationery/html/tree_builder/collector.rb +9 -3
  17. data/lib/stationery/html/tree_builder.rb +11 -1
  18. data/lib/stationery/layout/flow.rb +29 -0
  19. data/lib/stationery/layout/image.rb +12 -6
  20. data/lib/stationery/layout/mark.rb +2 -0
  21. data/lib/stationery/layout/node.rb +4 -0
  22. data/lib/stationery/layout/text.rb +47 -10
  23. data/lib/stationery/pdf/assembler.rb +14 -4
  24. data/lib/stationery/pdf/signature/cms.rb +21 -13
  25. data/lib/stationery/pdf/signature/timestamp.rb +120 -0
  26. data/lib/stationery/pdf/signature.rb +16 -9
  27. data/lib/stationery/pdf/writer.rb +73 -19
  28. data/lib/stationery/rich/nodes.rb +37 -11
  29. data/lib/stationery/rich/renderer/boxes.rb +109 -0
  30. data/lib/stationery/rich/renderer/indents.rb +1 -0
  31. data/lib/stationery/rich/renderer/inlines.rb +9 -2
  32. data/lib/stationery/rich/renderer.rb +32 -15
  33. data/lib/stationery/svg/style.rb +4 -6
  34. data/lib/stationery/testing/inspector.rb +49 -6
  35. data/lib/stationery/text/breaks.rb +123 -0
  36. data/lib/stationery/text/paragraph.rb +8 -0
  37. data/lib/stationery/text/runs_builder.rb +4 -0
  38. data/lib/stationery/text/wrapper.rb +57 -8
  39. data/lib/stationery/version.rb +1 -1
  40. data/lib/stationery/warnings.rb +9 -0
  41. data/lib/stationery.rb +3 -2
  42. metadata +8 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '04892e3a1ad9e6da0174cb033f2a57a048c83c77e57e72aff4a98ccfa48e1ba7'
4
- data.tar.gz: f9ee80ed8edd77108f3d6ae4f19f5a9d537000e6cf0e1efd8707e2682e89faac
3
+ metadata.gz: 41ce5a83b1405e6756b6b2238b4796d1c7331e82f1abb51df2747befa61dbc26
4
+ data.tar.gz: a1c9fab02c1ce65eb761c96084c81b72117aee45ac2c0db8c1326ce94651f7a4
5
5
  SHA512:
6
- metadata.gz: d5c07272f2ff4480d35caa3decebc77336e12b3e8b7aea4f8ddd06f47529c12438a86606fb41350bf9d0eb59915e1cee13505acf75ef9e7544135ddd6878d5e6
7
- data.tar.gz: 4901d1100e09107496cf9d83b6ac0c2a767aacc43090b1c5b87137c35667c3bd009adc8a5c627f09d50bfe1d46dc6172abaddb9dfe0cdfc9fe7426425471814b
6
+ metadata.gz: 9c1a85c89e74b002d14d6dd2aaa7153e34e746636ad6059b8db6a83f302b7058d4efb7430346463fc614ba0b61b7991226c44d724b62479d22ab5b1a007b419a
7
+ data.tar.gz: dfc30baf5b1803d38d7087eed1b24960fe79c3021f119b859d695458e128ed23baf74d961779b2bdb39f1071a0ed7cd09f51d1aa3881eb3076190f1a8d5a63b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
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
+
3
16
  ## 0.9.0 (2026-09-28)
4
17
 
5
18
  Forms in the document's fonts, digital signatures, a much faster text and table pipeline, and hardening for untrusted input.
data/README.md CHANGED
@@ -102,11 +102,12 @@ renders them all, or render one with `stationery render examples/report.rb`.
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:, 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). |
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
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,12 +139,59 @@ 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.
141
145
  - `max_depth:` (default 64) is how deep the source may nest: HTML elements inside one another,
142
146
  or Markdown block quotes and lists. What lies deeper is flattened into the deepest element
143
147
  kept, so its text stays and its structure goes, and a `NestingLimit` warning says how deep the
144
148
  source went. Block quotes and lists also stop indenting after twelve levels, where they would
145
149
  leave their text no width; that is reported the same way.
146
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
+
147
195
  #### Untrusted input
148
196
 
149
197
  `html` and `markdown` are meant for content you did not write (a CMS body, a comment, an
@@ -154,9 +202,10 @@ process down:
154
202
  under `base_path:`, and a path that climbs out of it is skipped (`SkippedImage`).
155
203
  - **No surprising links.** Only `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`) become
156
204
  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.
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.
160
209
  - **Bounded nesting.** Five thousand nested `<div>`s, block quotes or lists render flattened,
161
210
  with a `NestingLimit` warning, instead of exhausting the stack (the `svg` element guards its
162
211
  own nesting the same way, at 128 levels). Emphasis nests without recursion, at most 64
@@ -510,7 +559,8 @@ class ContractPdf < Stationery::Document
510
559
  sign certificate: -> { Rails.application.credentials.dig(:signing, :certificate) }, # PEM or OpenSSL object
511
560
  key: -> { Rails.application.credentials.dig(:signing, :key) }, # read per render
512
561
  chain: -> { [File.read("config/intermediate.pem")] },
513
- reason: "Approved", location: "Malmö", contact: "legal@acme.test"
562
+ reason: "Approved", location: "Malmö", contact: "legal@acme.test",
563
+ timestamp: "https://tsa.example/tsr" # optional: PAdES B-T
514
564
  # sign { { certificate: signer.certificate, key: signer.key, field: "approval" } } # or a block for all of it
515
565
 
516
566
  def view_template
@@ -536,19 +586,31 @@ ContractPdf.new.to_pdf(sign: { certificate:, key:, passphrase: "…" }) # per re
536
586
  signature is invisible: a field of its own (`Signature1`) whose widget has no size, on the first
537
587
  page. `name:` is the signer's name (the certificate's common name by default), `at:` the signing
538
588
  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.
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.
542
601
  - A signed form asks viewers not to regenerate appearances (`NeedAppearances` is left out) and
543
602
  sets `/SigFlags 3`. `encrypt:` and `conformance` combine with `sign`: the signature is the one
544
603
  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.
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.
549
609
 
550
610
  `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`;
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).
552
614
  `verify:conformance` validates signed PDF/A-3b and PDF/UA-1 renders with veraPDF. In tests,
553
615
  `have_signature(name: "Acme Legal")` checks that a signature covers the whole file and verifies
554
616
  against the certificate it carries.
@@ -570,6 +632,20 @@ Invoice.new.to_pdf("invoice.pdf", debug: %i[cell cell_padding])
570
632
  gem "stationery", require: "stationery/rails"
571
633
  ```
572
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
+
573
649
  Controllers gain `render pdf:` and `send_pdf`:
574
650
 
575
651
  ```ruby
@@ -759,6 +835,10 @@ names their authors and licences), drawing a hyphen at the break. A soft hyphen
759
835
  TeX, exempts that word from the patterns; it is never measured or drawn and
760
836
  never reaches the PDF. `Stationery::Hyphenation.hyphenate("Silbentrennung", "de")`
761
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.
762
842
 
763
843
  Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
764
844
  mask). Parsed fonts and images are cached per process.
@@ -847,7 +927,8 @@ exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
847
927
  `image_count`, `bookmarks`, `metadata`, `xmp` (the packet), `xmp_values` (`{ "dc:title" => …, "dc:creator" => […] }`),
848
928
  `lang`, `page_labels`, `attachments`, `conformance` (`[:pdf_a3b, :pdf_ua1]`), `factur_x`
849
929
  (`{ profile:, filename:, version:, xml: }`), `signatures` (`[{ field:, name:, reason:, location:,
850
- signed_at:, subfilter:, byte_range:, signer:, valid: }]`), `warnings`, `tagged?`,
930
+ signed_at:, subfilter:, byte_range:, signer:, valid:, timestamp: }]`, the timestamp `nil` or
931
+ `{ time:, tsa:, valid: }`), `warnings`, `tagged?`,
851
932
  `untagged_text` and
852
933
  `structure` — a tagged PDF's structure tree as nested arrays, each element's text
853
934
  read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
@@ -858,6 +939,9 @@ between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
858
939
 
859
940
  - **Prawn** is an imperative cursor API: every document does its own layout
860
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.
861
945
  - **Headless Chrome** (Grover, ferrum_pdf) renders HTML beautifully but runs
862
946
  a browser per worker; stray processes and memory are the price.
863
947
  - **Typst** is excellent but a native extension and a second template language.
@@ -899,21 +983,43 @@ events attached is the most useful thing to put in an issue.
899
983
 
900
984
  ## Performance
901
985
 
902
- `bundle exec rake bench` renders two documents with Stationery and with Prawn
903
- 2.5 + prawn-table, both embedding the same Open Sans TTF files
904
- (`benchmark/`, not part of CI). Apple M2 Max, Ruby 3.4.2 +YJIT, 28 September 2026:
905
-
906
- | Document | Engine | Renders/s | Objects allocated | PDF bytes |
907
- |---|---|---:|---:|---:|
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 |
912
-
913
- Stationery compresses content streams; Prawn does not by default, hence the
914
- larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
915
- 20 hottest frames of the table render under StackProf (wall mode; `MODE=cpu` or
916
- `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.
917
1023
 
918
1024
  Time depends on the machine, so CI holds what does not: `bundle exec rake metrics`
919
1025
  renders six fixed documents and compares the objects each render allocates, its
@@ -927,7 +1033,8 @@ Fonts: no variable fonts (including CFF2) and no WOFF2 (it needs Brotli; convert
927
1033
  `.woff`); shaping stops at pair kerning and single or ligature substitutions (`liga` by default,
928
1034
  `smcp`, `onum`, `tnum`, `ss01`… on request), so contextual alternates (`calt`, `clig`, `frac`) do
929
1035
  nothing and scripts that need contextual shaping (Arabic, Indic, Thai) draw glyph by glyph, and colour or emoji glyphs no font
930
- 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
931
1038
  patterns are bundled for English, German and Swedish only (a soft hyphen works in any language),
932
1039
  and justification only widens spaces.
933
1040
 
@@ -936,8 +1043,10 @@ sets and exports use, nothing else: `image`, `mask`, `pattern`, `filter` and `te
936
1043
  and reported, text inside a `clipPath` does not clip, and the shapes of a clip path join into one
937
1044
  path, so overlapping shapes wound in opposite directions cancel where they overlap.
938
1045
  Images are JPEG and PNG (non-interlaced) and never fetched from a URL; a JPEG is embedded at its
939
- source resolution (only PNGs can be downscaled), so an oversized one is reported, not resized. `html` and `markdown` render structure and inline marks, not CSS: only `text-align`
940
- 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.
941
1050
 
942
1051
  Layout: a box with a fixed `height:` never splits (use `min_height:` for a floor that can); a row
943
1052
  splits only when every column can; a rotated box and a `stack` move to the next page whole. Text
@@ -946,7 +1055,7 @@ and `transform`, and `shadow:` is stacked rectangles, not a blur.
946
1055
 
947
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
948
1057
  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
1058
+ no long-term validation data (PAdES B-LT), no second signature and no signing
950
1059
  of a file that already exists, all of which need incremental updates.
951
1060
  Form fields are set in the document's fonts, but text typed into one is drawn by the viewer:
952
1061
  characters outside the glyphs the field kept (ASCII and Latin-1) use the viewer's own font.
@@ -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
@@ -113,7 +113,8 @@ module Stationery
113
113
  # method name for certificate:, key:, chain: and passphrase:), and a
114
114
  # block may answer all of them, so keys are read when they are needed.
115
115
  # `field:` names the signature_field it fills; without it the
116
- # signature is invisible. See PDF::Signature.
116
+ # signature is invisible. `timestamp:` is the URL of a time-stamping
117
+ # authority whose token makes it PAdES baseline B-T. See PDF::Signature.
117
118
  def sign(**options, &block)
118
119
  raise ArgumentError, "sign needs certificate: and key:, or a block answering them" unless block || options.any?
119
120
 
@@ -153,19 +154,28 @@ module Stationery
153
154
  def page_options = self.class.config[:page]
154
155
  def metadata = self.class.config[:metadata]
155
156
 
157
+ # The PDF as a binary String, also written to `target` when given: a
158
+ # path or an IO. With a block the file is streamed to it in pieces as it
159
+ # is written and the call answers the number of bytes: the first bytes
160
+ # leave before the last page is assembled and no output buffer is built.
161
+ # Layout runs in full first either way, so peak memory does not drop.
156
162
  def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
157
163
  tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels], attachments: [],
158
164
  xmp: metadata[:xmp] != false, conformance: self.class.config[:conformance],
159
- factur_x: self.class.config[:factur_x], sign: self.class.config[:sign])
165
+ factur_x: self.class.config[:factur_x], sign: self.class.config[:sign], &block)
160
166
  invoice = PDF::FacturX.for(factur_x, self)
161
167
  attachments = PDF::Attachments.merge(self.class.config[:attachments], attachments, invoice&.attachment)
162
168
  conformance = PDF::Conformance.for(invoice ? invoice.conformance(conformance) : conformance)
163
169
  conformance&.validate!(encrypt:, metadata:, attachments:)
170
+ signature = PDF::Signature.for(sign, self)
171
+ raise ArgumentError, "a signed document cannot be streamed to a block: sign needs the whole file" if
172
+ signature && block
173
+ raise ArgumentError, "pass a target or a block, not both" if target && block
174
+
164
175
  options = { strict:, debug:, encrypt:, tagged: tagged || conformance&.pdf_ua?, page_labels:, attachments:,
165
- xmp: xmp || !conformance.nil?, conformance:, invoice:,
166
- signature: PDF::Signature.for(sign, self) }
176
+ xmp: xmp || !conformance.nil?, conformance:, invoice:, signature:, sink: block }
167
177
  Stationery.instrument("render.stationery", document: self.class.name) do |event|
168
- write(render_pdf(event, **options), target)
178
+ block ? render_pdf(event, **options) : write(render_pdf(event, **options), target)
169
179
  end
170
180
  end
171
181
 
@@ -208,22 +218,24 @@ module Stationery
208
218
  raise WarningsError, warnings if strict && warnings.any?
209
219
 
210
220
  pdf = assemble(pages, resources, outline, tagging:, conformance:, **assembly)
211
- event[:bytes] = pdf.bytesize
221
+ event[:bytes] = byte_count(pdf)
212
222
  pdf
213
223
  end
214
224
 
215
225
  def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:, attachments:, xmp:, conformance:,
216
- invoice:, signature:)
226
+ invoice:, signature:, sink:)
217
227
  encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
218
228
  assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, xmp:,
219
229
  lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels),
220
230
  attachments:, conformance:, xmp_extensions: invoice&.xmp_extensions || {},
221
- xmp_schemas: [invoice&.xmp_schema].compact, signature:)
231
+ xmp_schemas: [invoice&.xmp_schema].compact, signature:, sink:)
222
232
  Stationery.instrument("write.stationery", document: self.class.name) do |event|
223
- assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
233
+ assembler.render.tap { |pdf| event[:bytes] = byte_count(pdf) }
224
234
  end
225
235
  end
226
236
 
237
+ def byte_count(pdf) = pdf.is_a?(String) ? pdf.bytesize : pdf
238
+
227
239
  def paginate(root, book:, resources:, warnings:, debug:, tagging:)
228
240
  Stationery.instrument("paginate.stationery", document: self.class.name) do |event|
229
241
  regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
@@ -10,10 +10,18 @@ module Stationery
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
12
  # one kept (text stays, structure goes) and reported as a NestingLimit
13
- # warning, so content from users cannot exhaust the stack.
13
+ # warning, so content from users cannot exhaust the stack. `<style>`
14
+ # rules and inline styles are read for the properties HTML::Css knows;
15
+ # the rest is reported once as an UnsupportedCss warning.
14
16
  def html(source, max_depth: 64, **)
15
17
  require_relative "../html/document"
16
- rich(HTML.parse(source.to_s, max_depth:) { |depth| nesting_limit(depth, max_depth) }, **)
18
+ css = HTML::Css::Report.new
19
+ blocks = HTML.parse(source.to_s, max_depth:, css:) { |depth| nesting_limit(depth, max_depth) }
20
+ if css.any?
21
+ @_builder.warnings << Warnings::UnsupportedCss.new(properties: css.properties,
22
+ selectors: css.selectors)
23
+ end
24
+ rich(blocks, **)
17
25
  end
18
26
 
19
27
  # CommonMark (plus GFM tables and strikethrough); options as for html.
@@ -3,18 +3,21 @@
3
3
  module Stationery
4
4
  # The element DSL available inside every component's view_template.
5
5
  module Elements
6
- PARAGRAPH_DEFAULTS = { align: :left, leading: 0 }.freeze
6
+ PARAGRAPH_DEFAULTS = { align: :left, leading: 0, orphans: 1, widows: 1 }.freeze
7
7
 
8
8
  # A paragraph. Plain strings are always literal; pass `markup: true` to
9
9
  # read inline tags, or a block to build styled runs in Ruby. `heading: 1..6`
10
- # tags it as a heading in a tagged PDF.
10
+ # tags it as a heading in a tagged PDF. `orphans:` and `widows:` are the
11
+ # fewest lines a page break may leave behind and carry over (default 1).
11
12
  def text(content = nil, markup: false, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
12
13
  heading: nil, **options, &)
13
- settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading)).merge(options)
14
+ settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading, :orphans, :widows))
15
+ .merge(options)
14
16
  style = @_builder.style(options)
15
17
  runs = text_runs(content, style, markup, &)
16
18
  node = Layout::Text.new(runs, context: @_builder.context(style), align: settings[:align],
17
- leading: settings[:leading], tag: Tagging::Element.new(Tagging.heading(heading)))
19
+ leading: settings[:leading], orphans: settings[:orphans], widows: settings[:widows],
20
+ tag: Tagging::Element.new(Tagging.heading(heading)))
18
21
  node.keep_with_next = keep_with_next
19
22
  node.break_inside = break_inside
20
23
  @_builder.add(mark(node, anchor, bookmark))
@@ -183,10 +186,12 @@ module Stationery
183
186
  end
184
187
  end
185
188
 
189
+ # A Hash is a cell with options: `{ content:, background:, borders:, padding:, … }`.
186
190
  def cell_content(content)
187
191
  case content
188
192
  when Proc then container(&content)
189
193
  when Component then container { render content }
194
+ when Hash then content.merge(content: cell_content(content[:content]))
190
195
  else content
191
196
  end
192
197
  end
@@ -21,6 +21,10 @@ module Stationery
21
21
  end
22
22
  end
23
23
 
24
+ # Raised when a signature cannot be made as asked: the time-stamping
25
+ # authority is unreachable, refuses, or answers for something else.
26
+ class SignatureError < Error; end
27
+
24
28
  # Raised when a render cannot keep the conformance it claims (PDF/A,
25
29
  # PDF/UA): `levels` are the claimed levels, `issues` what breaks them.
26
30
  class ConformanceError < Error
@@ -12,7 +12,7 @@ module Stationery
12
12
  # stay with their base; whitespace a font lacks draws as a blank of the
13
13
  # right width (see Font::WHITESPACE).
14
14
  class Fallback
15
- CARRIED = /[\p{Space}­‌‍︀-️\p{M}]/
15
+ CARRIED = /[\p{Space}­​‌‍︀-️\p{M}]/
16
16
 
17
17
  def self.carried?(char) = CARRIED.match?(char)
18
18