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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +251 -37
- data/lib/stationery/canvas.rb +3 -1
- 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 +35 -7
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +21 -6
- data/lib/stationery/elements.rb +20 -7
- data/lib/stationery/errors.rb +4 -0
- data/lib/stationery/fonts/fallback.rb +1 -1
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/css.rb +276 -0
- data/lib/stationery/html/document.rb +10 -2
- 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 +44 -4
- data/lib/stationery/layout/flow.rb +29 -0
- data/lib/stationery/layout/image.rb +21 -7
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +4 -0
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/layout/text.rb +47 -10
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +4 -0
- data/lib/stationery/pdf/assembler.rb +31 -11
- data/lib/stationery/pdf/conformance.rb +7 -5
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +95 -0
- data/lib/stationery/pdf/signature/timestamp.rb +120 -0
- data/lib/stationery/pdf/signature.rb +174 -0
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/pdf/writer.rb +73 -19
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/nodes.rb +37 -11
- data/lib/stationery/rich/renderer/boxes.rb +109 -0
- data/lib/stationery/rich/renderer/indents.rb +43 -0
- data/lib/stationery/rich/renderer/inlines.rb +9 -2
- data/lib/stationery/rich/renderer.rb +49 -16
- data/lib/stationery/svg/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/style.rb +4 -6
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- 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 +69 -11
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +13 -0
- data/lib/stationery.rb +6 -2
- metadata +13 -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,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
|
|
208
|
-
the
|
|
209
|
-
|
|
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
|
|
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
|
|
409
|
-
|
|
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
|
|
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: }`), `
|
|
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
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
|
812
|
-
|
|
813
|
-
|
|
|
814
|
-
| | Prawn |
|
|
815
|
-
|
|
816
|
-
Stationery
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
|
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`
|
|
837
|
-
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.
|
|
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)
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
|
data/lib/stationery/canvas.rb
CHANGED
|
@@ -144,9 +144,11 @@ module Stationery
|
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
# An interactive form field's widget (a Forms::Field) over the rectangle.
|
|
147
|
+
# Its appearance is drawn here, so the glyphs it needs are in the fonts
|
|
148
|
+
# before they are embedded.
|
|
147
149
|
def widget(field, x, y, w, h, tag: nil)
|
|
148
150
|
rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
|
|
149
|
-
annotation = { rect:, widget: field }
|
|
151
|
+
annotation = { rect:, widget: field, appearance: Forms::Appearance.new(field, w, h, @resources) }
|
|
150
152
|
@page.annotations << annotation
|
|
151
153
|
adopt(annotation, tag, rect) if tag
|
|
152
154
|
end
|
|
@@ -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
|