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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +143 -34
- data/lib/stationery/{svg → css}/selector.rb +1 -1
- data/lib/stationery/{svg → css}/stylesheet.rb +20 -6
- data/lib/stationery/css.rb +20 -0
- data/lib/stationery/document.rb +21 -9
- data/lib/stationery/elements/rich.rb +10 -2
- data/lib/stationery/elements.rb +9 -4
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +5 -3
- data/lib/stationery/html/tokenizer.rb +15 -4
- data/lib/stationery/html/tree_builder/blocks.rb +90 -40
- data/lib/stationery/html/tree_builder/collector.rb +9 -3
- data/lib/stationery/html/tree_builder.rb +11 -1
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +12 -6
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/pdf/assembler.rb +14 -4
- data/lib/stationery/pdf/signature/cms.rb +21 -13
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +16 -9
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +1 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +32 -15
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/testing/inspector.rb +49 -6
- data/lib/stationery/text/breaks.rb +123 -0
- data/lib/stationery/text/paragraph.rb +8 -0
- data/lib/stationery/text/runs_builder.rb +4 -0
- data/lib/stationery/text/wrapper.rb +57 -8
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +9 -0
- data/lib/stationery.rb +3 -2
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41ce5a83b1405e6756b6b2238b4796d1c7331e82f1abb51df2747befa61dbc26
|
|
4
|
+
data.tar.gz: a1c9fab02c1ce65eb761c96084c81b72117aee45ac2c0db8c1326ce94651f7a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
158
|
-
inside Markdown stays literal text
|
|
159
|
-
|
|
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
|
-
-
|
|
540
|
-
|
|
541
|
-
|
|
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:
|
|
546
|
-
signature, and signing a file that already exists. All of them
|
|
547
|
-
Stationery signs what it renders, once. Whether a viewer trusts the
|
|
548
|
-
|
|
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: }]
|
|
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
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
|
909
|
-
|
|
910
|
-
|
|
|
911
|
-
| | Prawn |
|
|
912
|
-
|
|
913
|
-
Stationery
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
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`
|
|
940
|
-
and
|
|
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
|
|
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,25 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
|
-
module
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
24
|
+
plain(css).scan(RULE).each do |selectors, body|
|
|
20
25
|
next if selectors.strip.start_with?("@")
|
|
21
26
|
|
|
22
|
-
declarations =
|
|
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
|
data/lib/stationery/document.rb
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -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))
|
|
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],
|
|
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
|
data/lib/stationery/errors.rb
CHANGED
|
@@ -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}
|
|
15
|
+
CARRIED = /[\p{Space}︀-️\p{M}]/
|
|
16
16
|
|
|
17
17
|
def self.carried?(char) = CARRIED.match?(char)
|
|
18
18
|
|