stationery 0.1.0 → 0.3.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 +114 -1
- data/README.md +395 -25
- data/exe/stationery +6 -0
- data/lib/generators/stationery/fonts_generator.rb +24 -0
- data/lib/stationery/builder.rb +26 -3
- data/lib/stationery/canvas/debug.rb +26 -0
- data/lib/stationery/canvas/path.rb +22 -12
- data/lib/stationery/canvas/text.rb +10 -6
- data/lib/stationery/canvas.rb +38 -8
- data/lib/stationery/cli/fonts.rb +85 -0
- data/lib/stationery/cli/render.rb +137 -0
- data/lib/stationery/cli.rb +74 -0
- data/lib/stationery/document.rb +60 -9
- data/lib/stationery/elements/lists.rb +58 -0
- data/lib/stationery/elements/rich.rb +27 -0
- data/lib/stationery/elements.rb +88 -12
- data/lib/stationery/errors.rb +13 -1
- data/lib/stationery/fonts/bundled.rb +25 -0
- data/lib/stationery/fonts/catalog.rb +101 -0
- data/lib/stationery/fonts/cff.rb +140 -0
- data/lib/stationery/fonts/cff_subset.rb +145 -0
- data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
- data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
- data/lib/stationery/fonts/data/OFL.txt +92 -0
- data/lib/stationery/fonts/data/README.md +14 -0
- data/lib/stationery/fonts/embedding/base.rb +56 -0
- data/lib/stationery/fonts/embedding/cff.rb +35 -0
- data/lib/stationery/fonts/embedding/true_type.rb +35 -0
- data/lib/stationery/fonts/fallback.rb +58 -0
- data/lib/stationery/fonts/family.rb +11 -0
- data/lib/stationery/fonts/font.rb +68 -88
- data/lib/stationery/fonts/font_book.rb +49 -8
- data/lib/stationery/fonts/glyph_run.rb +39 -0
- data/lib/stationery/fonts/gpos/class_def.rb +25 -0
- data/lib/stationery/fonts/gpos/coverage.rb +20 -0
- data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
- data/lib/stationery/fonts/gpos.rb +64 -0
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/installer.rb +121 -0
- data/lib/stationery/fonts/kern_table.rb +52 -0
- data/lib/stationery/fonts/kerning.rb +17 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/packs.rb +45 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
- data/lib/stationery/fonts/to_unicode.rb +36 -0
- data/lib/stationery/fonts/true_type.rb +64 -57
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/html/document.rb +11 -0
- data/lib/stationery/html/tokenizer.rb +74 -0
- data/lib/stationery/html/tree_builder/blocks.rb +137 -0
- data/lib/stationery/html/tree_builder/collector.rb +50 -0
- data/lib/stationery/html/tree_builder.rb +72 -0
- data/lib/stationery/html/whitespace.rb +43 -0
- data/lib/stationery/layout/box.rb +102 -16
- data/lib/stationery/layout/flow.rb +48 -15
- data/lib/stationery/layout/image.rb +1 -0
- data/lib/stationery/layout/list_item.rb +89 -0
- data/lib/stationery/layout/mark.rb +58 -0
- data/lib/stationery/layout/node.rb +26 -2
- data/lib/stationery/layout/paginator.rb +35 -6
- data/lib/stationery/layout/positioned.rb +3 -1
- data/lib/stationery/layout/row.rb +34 -4
- data/lib/stationery/layout/svg.rb +48 -0
- data/lib/stationery/layout/table/cell.rb +33 -6
- data/lib/stationery/layout/table/grid.rb +93 -0
- data/lib/stationery/layout/table/row_splitter.rb +36 -0
- data/lib/stationery/layout/table/selection.rb +3 -2
- data/lib/stationery/layout/table.rb +69 -23
- data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
- data/lib/stationery/layout/table_of_contents.rb +43 -0
- data/lib/stationery/layout/text.rb +6 -4
- data/lib/stationery/layout/wrap.rb +78 -0
- data/lib/stationery/list_markers.rb +47 -0
- data/lib/stationery/markdown/block_parser/containers.rb +94 -0
- data/lib/stationery/markdown/block_parser/tables.rb +54 -0
- data/lib/stationery/markdown/block_parser.rb +141 -0
- data/lib/stationery/markdown/document.rb +50 -0
- data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
- data/lib/stationery/markdown/inline_parser/links.rb +70 -0
- data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
- data/lib/stationery/markdown/inline_parser.rb +89 -0
- data/lib/stationery/minitest.rb +33 -0
- data/lib/stationery/outline.rb +37 -0
- data/lib/stationery/page.rb +18 -4
- data/lib/stationery/page_templates.rb +33 -10
- data/lib/stationery/pdf/assembler.rb +20 -8
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/outline_writer.rb +72 -0
- data/lib/stationery/pdf/serializer.rb +22 -10
- data/lib/stationery/pdf/writer.rb +28 -5
- data/lib/stationery/preview.rb +64 -0
- data/lib/stationery/rails/previews_controller.rb +61 -0
- data/lib/stationery/rails.rb +2 -0
- data/lib/stationery/railtie.rb +60 -0
- data/lib/stationery/regions.rb +67 -0
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/rich/nodes.rb +55 -0
- data/lib/stationery/rich/renderer/inlines.rb +53 -0
- data/lib/stationery/rich/renderer.rb +121 -0
- data/lib/stationery/rich/styles.rb +34 -0
- data/lib/stationery/rspec.rb +6 -0
- data/lib/stationery/structure.rb +78 -0
- data/lib/stationery/svg/arc.rb +91 -0
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +103 -0
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +58 -0
- data/lib/stationery/svg/path_data.rb +137 -0
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/shapes.rb +54 -0
- data/lib/stationery/svg/style.rb +85 -0
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +54 -0
- data/lib/stationery/testing/inspector.rb +98 -0
- data/lib/stationery/testing/matchers.rb +123 -0
- data/lib/stationery/text/entities/html4.rb +49 -0
- data/lib/stationery/text/entities.rb +6 -1
- data/lib/stationery/text/line.rb +8 -2
- data/lib/stationery/text/paragraph.rb +33 -11
- data/lib/stationery/text/runs_builder.rb +9 -1
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +4 -2
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +65 -0
- data/lib/stationery.rb +67 -0
- metadata +101 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9ff43b2013eb330ebc103525c440bb5e4cfdaf0d05364baf47fd329c48ad11a
|
|
4
|
+
data.tar.gz: 718ede92892d3c7c0cdc556708880a2ba53f559611b872cfcb8ac946adb59908
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b3a4a85b185b0f06e195a15f81773c86d79086e32e923b1c21c2d39ee528375fd5b75be1581fc3d8180e530071d653939674804c973896d0edb3b57fffbde9fa
|
|
7
|
+
data.tar.gz: fd368b63884a129cd0e6f03507da714891c79b81b6a1aeadde9cfeafc4987824d7cd525c2f86aa3eba397f432a14516429bc697b61177056a790a602d85369ae
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,119 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.3.0 (2026-09-27)
|
|
4
|
+
|
|
5
|
+
The Limitations page, shortened: TrueType collections, WOFF, ligatures, splittable
|
|
6
|
+
`min_height:` boxes, SVG gradients, text and stylesheets, and encryption.
|
|
7
|
+
|
|
8
|
+
- SVG: `linearGradient` and `radialGradient` fills (`fill="url(#id)"`, also in `style`), with stops, `href`/`xlink:href` inheritance, `objectBoundingBox` and `userSpaceOnUse` units and `gradientTransform`, drawn as PDF axial/radial shadings clipped to the shape. Approximations: `reflect`/`repeat` spreads are drawn as `pad` (reported in `unsupported`), stop opacity is the first stop's for the whole gradient, and a gradient stroke is drawn in the gradient's middle colour. A reference to a missing gradient paints its fallback colour or nothing and is reported as `url(#id)`. `Canvas#shade` paints a shading dictionary inside a path.
|
|
9
|
+
- SVG: `text` and `tspan` (`x`/`y`/`dx`/`dy`, first value each; `font-family`, first family the font book knows — registered, bundled or an installed pack — else the document's default; `font-size`, `font-weight`, `font-style`, `text-anchor`, `fill`, `opacity`, transforms), drawn through the document's fonts so they subset and extract like any text. Whitespace collapses as in browsers. Glyphs stay upright: a transform moves the baseline origin and scales the size uniformly, so rotated or skewed text is approximated; `dominant-baseline` is ignored and a gradient fill uses its middle colour. `Layout::Svg` takes `context:`, `SVG::Document#draw` takes `book:` and `family:`, and `FontBook#known?` tells whether a family resolves without substitution.
|
|
10
|
+
- SVG: `<style>` stylesheets (plain or CDATA) as Illustrator and Inkscape export them. Selectors: element, `.class` (`.a.b`), `#id`, `element.class`, comma lists and `*`; rules with combinators (`g path`, `a > b`, …) are ignored and reported once as `style selectors: …`. Cascade: presentation attributes, then rules by specificity (id > class > element, later wins on ties), then inline `style`. Rules style shapes, text (`font-*`) and gradient stops (`stop-color`, `stop-opacity`); `display: none` skips an element and its children, `visibility: hidden` skips it (a `visible` child draws). `class` takes several classes.
|
|
11
|
+
|
|
12
|
+
- Encryption with the standard security handler: `to_pdf(encrypt: { owner_password:, user_password:, permissions:, algorithm: })` or `encrypt …` at class level (`to_pdf(encrypt: nil)` opts out). AES-256 (R6, default), AES-128 (R4) and RC4-128 (R3); every string and stream, document info included, is encrypted.
|
|
13
|
+
|
|
14
|
+
- Standard ligatures from the font's GSUB `liga` feature (LigatureSubst lookups, also behind Extension lookups), **on by default**: "office" in Open Sans draws the ffi ligature, so widths of affected words change slightly. `ligatures: false` on `text`/`text_style` or in `default_text` opts out; any `letter_spacing` turns them off. Text extraction and copy still yield the source characters (ToUnicode maps a ligature glyph to all of them). Fonts without `liga` ligatures, such as the bundled Inter, are unaffected.
|
|
15
|
+
|
|
16
|
+
### Fonts
|
|
17
|
+
|
|
18
|
+
- TrueType collections (`.ttc`): `font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2"` picks a face by a `#N` suffix (face 0 without one); each face is parsed, cached, subset and embedded on its own. `TrueType.new(data, index:)`, `TrueType.collection?` and `TrueType.faces`; an index out of range raises `ArgumentError` naming the face count.
|
|
19
|
+
- WOFF 1.0 web fonts (`.woff`): `font_family "Web", regular: "Brand.woff"`. Tables are inflated with zlib into an in-memory sfnt (`Fonts::WOFF.unpack`), then measured, subset and embedded like the `.ttf`. WOFF2 is still rejected: it needs Brotli, so convert to `.ttf` or `.woff`.
|
|
20
|
+
|
|
21
|
+
- `box(min_height:)` and `column(min_height:)`: a height floor that, unlike `height:`, still splits across pages. The first fragment keeps as much of the floor as the page holds and the next carries the rest, so a row of equal-height cards or an empty signature area can span a page break. Passing both `height:` and `min_height:` raises `ArgumentError`.
|
|
22
|
+
|
|
23
|
+
## 0.2.0 (2026-09-27)
|
|
24
|
+
|
|
25
|
+
Everything from the three planned milestones ("works out of the box", "typography and layout",
|
|
26
|
+
"documents, Rails and testing") shipped together.
|
|
27
|
+
|
|
28
|
+
### Breaking changes
|
|
29
|
+
|
|
30
|
+
- **Behaviour change:** boxes taller than a page now continue on the next page instead of overflowing; pass `break_inside: :avoid` for the old behaviour. A box that fits on a page still moves there whole; `break_inside: :auto` splits it at any page break. `box(decoration: :slice | :clone)` chooses whether padding repeats at a cut; borders are left open and rounded corners cut square.
|
|
31
|
+
- Pair kerning from the font's `kern` table, **on by default**: measured widths shift slightly (kerned lines only get narrower), so wrapping and right-aligned positions can move by a fraction of a point. `kerning: false` on `text`/`text_style` or in `default_text` restores unkerned output. Pairs across a style or font change are not kerned.
|
|
32
|
+
|
|
33
|
+
### Fonts
|
|
34
|
+
|
|
35
|
+
- Inter (OFL) is bundled: documents render without any `font_family`; `font_family "Inter"` needs no paths; `Stationery.bundled_fonts`. An unknown family name without paths raises.
|
|
36
|
+
- Font packs: `stationery fonts list` and `stationery fonts install noto_sans liberation_serif [--into DIR] [--force] [--from PATH]` copy pinned, SHA-256-verified Noto Sans/Serif/Sans Mono, Liberation Sans/Serif/Mono and Inter files (plus their OFL license) into `vendor/fonts/<pack>/`, atomically and offline-capable. `font_family "Noto Sans"` with no paths finds an installed pack through `Stationery.font_paths`, as does an unregistered family name at render time. Ruby API `Stationery::Fonts.install`/`catalog`/`paths`, Rails generator `stationery:fonts`, and `rake fonts:verify`. No runtime downloads.
|
|
37
|
+
- Per-character font fallback: `font_fallbacks "Noto Sans Symbols", ...` (inherited by subclasses). A character missing from the text's family is drawn from the first fallback that has it, then bundled Inter; spaces, joiners and combining marks stay with their base. Glyphs no font has are reported as `MissingGlyph` warnings counted per drawn occurrence. `Font#glyph?` is memoised per character.
|
|
38
|
+
- GPOS pair kerning: the `kern` feature's PairPos lookups (formats 1 and 2, also behind Extension lookups) take precedence over the `kern` table, so GPOS-only fonts such as Inter are kerned too.
|
|
39
|
+
- OpenType fonts with CFF outlines (`.otf`), name-keyed and CID-keyed: embedded whole as a CIDFontType0 (`FontFile3 /OpenType`), CID-keyed text written as CIDs with the font's ROS. Variable CFF2 fonts are rejected with a named reason.
|
|
40
|
+
- CFF fonts are subset: undrawn glyphs become a bare `endchar` (glyph ids, charset, FDSelect, Private DICTs and Subrs kept), embedded as `FontFile3 /CIDFontType0C` with a subset tag.
|
|
41
|
+
|
|
42
|
+
### Text
|
|
43
|
+
|
|
44
|
+
- `align: :justify` on `text`, `text_style` and table cells: wrapped lines are stretched to the full width by widening their spaces (kerning is kept); the last line, lines ending in a newline and lines without spaces stay left-aligned. Link areas widen with the stretched text.
|
|
45
|
+
- `html` and `markdown` elements: ActionText/Trix HTML and CommonMark (GFM tables and strikethrough) rendered as paragraphs, headings, lists, blockquotes, code blocks, rules, tables and images with inline bold/italic/underline/strike/code/links/sub/sup. `styles:` deep-merges per-block defaults, `images:` resolves image sources (or `base_path:`), missing/remote images are skipped with a `SkippedImage` warning and never fetched, `bookmarks: true` outlines h1–h3. Parsers load on first use.
|
|
46
|
+
- Markup decodes all 252 HTML 4 named entities (`—`, `€`, `…`, …); the table loads on first use.
|
|
47
|
+
- Internal: `Stationery::Rich` block model (paragraphs, headings, lists, quotes, code, rules, tables, images; inlines with marks) with lenient `HTML.parse` (implied end tags, browser whitespace collapsing, Trix/ActionText output) and CommonMark-subset `Markdown.parse` (GFM tables and strikethrough, reference links). Loaded on demand; `html`/`markdown` elements follow.
|
|
48
|
+
- Internal: `Fonts::GlyphRun` carries per-glyph advance adjustments (Tj/TJ emission) for upcoming kerning and justification; output unchanged.
|
|
49
|
+
|
|
50
|
+
### Layout
|
|
51
|
+
|
|
52
|
+
- `ul` / `ol` / `li` lists: drawn (disc, circle, square) or text bullets (dash, any String), decimal / alpha / roman / Proc numbering, `start:` and `suffix:`, aligned bodies, nested lists with depth-cycled bullets, and items that split across pages keeping the marker with their first line.
|
|
53
|
+
- `box(break_inside:)` and `column(break_inside:)`.
|
|
54
|
+
- Rows split across pages like boxes, every column at the same break; a column that ends early continues as an empty fragment so backgrounds stay aligned. `row(break_inside:)`.
|
|
55
|
+
- `keep_with_next` now moves with a following node that avoids breaking inside and would not start on the page.
|
|
56
|
+
- `wrap` element: children at their own widths, wrapping onto rows; splits between rows.
|
|
57
|
+
- `box(link:)` makes the whole box clickable; `box(outset:)` bleeds its background past its edges.
|
|
58
|
+
- `keep_with_next:` accepts a number of points of following content to keep.
|
|
59
|
+
- `group(align:)`.
|
|
60
|
+
- Fixed-width children of a flow (`box(width:)`) are measured, split and paginated at their own width, not the flow's.
|
|
61
|
+
- Layout: `split` takes `fresh:` on every node; a flow passes it on to the child that starts a fresh page.
|
|
62
|
+
|
|
63
|
+
### Tables
|
|
64
|
+
|
|
65
|
+
- Table cells span columns and rows: `{ content:, colspan:, rowspan: }`, placed as in HTML; a table only splits between rows no rowspan crosses.
|
|
66
|
+
- A table row taller than the page continues on the next page below the repeated header, its cells cut at the page bottom; `table(split_rows: true)` cuts any row that reaches the page bottom rather than moving it whole.
|
|
67
|
+
- Table cells accept procs built with the DSL (`-> { image logo }`) and components.
|
|
68
|
+
- Faster tables: cell padding is normalised once per cell, and PDF numbers are trimmed without a regexp (about 13% off a 1,500-row table render).
|
|
69
|
+
|
|
70
|
+
### Pages and structure
|
|
71
|
+
|
|
72
|
+
- `header` and `footer` regions that reserve page space, with `height:`, `gap:` and `on:` (`:all`, `:first`, `:rest`, `:odd`, `:even`, a page number, a range or a proc); later declarations win. A `page_template`'s `page.content_box` now excludes their space. `Page#margin_box`.
|
|
73
|
+
- `header`/`footer` `on: :last`: the paginator checks whether the rest of the content fits above a taller last-page region, adding a page when it only fits a normal one.
|
|
74
|
+
- Named anchors and internal links: `anchor:` on `text`, `box`, `group` and `table`, a standalone `anchor` element, and `link: "#name"` / `<a href="#name">` / `box(link: "#name")` written as PDF `/Dest` GoTo links. Unknown targets are dropped with an `UnresolvedLink` warning; duplicate names warn with `DuplicateAnchor`.
|
|
75
|
+
- Bookmarks and document outline: `bookmark:` on `text`, `box`, `group` and `table` (a title, or `{ title:, level:, open: }`) and a standalone `bookmark` element write a nested PDF `/Outlines` tree; the document opens with the outline panel. Bookmarks in page templates are ignored.
|
|
76
|
+
- `table_of_contents` element: one linked row per bookmark with its title indented by level, a dotted/solid leader and a right-aligned page number filled in after pagination (`levels:`, `leader:`, `indent:`, `gap:`, `number_width:`, text style). Paginates across pages.
|
|
77
|
+
|
|
78
|
+
### Drawing
|
|
79
|
+
|
|
80
|
+
- SVG: path (every command, including arcs), rect, circle, ellipse, line, polyline, polygon and g, with fill, stroke, caps, joins, fill-rule, opacity, inline styles, transforms and `currentColor`. `svg` element.
|
|
81
|
+
- SVG: elements that cannot be drawn (`text`, `use`, …) are listed in `SVG::Document#unsupported` and reported as an `UnsupportedSvg` warning.
|
|
82
|
+
- Canvas: `rounded_rect` and `clip` take per-corner radii (`[top_left, top_right, bottom_right, bottom_left]`, scaled down to fit like CSS); `rounded_rect(dash:)`.
|
|
83
|
+
- `to_pdf(debug: true)` outlines every layout rectangle (boxes, padding, columns, flow slots, cells, positioned boxes, images, the page content box), colour-coded by kind; `debug: %i[cell …]` outlines only those kinds.
|
|
84
|
+
|
|
85
|
+
### Warnings
|
|
86
|
+
|
|
87
|
+
- An unregistered family name draws with the first registered family (else bundled Inter) and reports an `UnknownFamily` warning once.
|
|
88
|
+
- `Stationery::Warnings`: one collector per render, deduplicated, with `#message` on every entry (`Overflow`, `MissingGlyph`, `UnknownFamily`, `UnsupportedSvg`, `SkippedImage`, `UnresolvedLink`, `DuplicateAnchor`). `document.warnings` now includes warnings raised while page templates draw.
|
|
89
|
+
- Strict mode: `to_pdf(strict: true)` or class-level `strict` raises `Stationery::WarningsError` when a render produced warnings.
|
|
90
|
+
|
|
91
|
+
### Rails
|
|
92
|
+
|
|
93
|
+
- Rails: a Railtie adds `render pdf: document` (`filename:`, `disposition:`) and `config.stationery` (`renderer`, `font_paths`); `Stationery.font_paths`. A `spec:rails` lane tests it against Rails 8 without adding a dependency.
|
|
94
|
+
- Rails: PDF previews like ActionMailer previews. `Stationery::Preview` subclasses in `spec/pdfs/previews` or `test/pdfs/previews` render at `/rails/stationery/previews` (`?debug=1` when the document supports it); `config.stationery.preview_paths` and `show_previews` (development by default). `rake spec:rails` no longer overwrites the main suite's coverage report.
|
|
95
|
+
|
|
96
|
+
### Tooling
|
|
97
|
+
|
|
98
|
+
- Documentation site at https://stationery.zoolutions.llc, a docs-kit app under `docs/` (not part of the gem): getting started, every element with its options, layout rules, pages and regions, links and bookmarks, fonts, images and SVG, Rails, testing, the CLI, warnings, a cookbook built from `examples/`, performance, limitations and this changelog, rendered from the README and CHANGELOG where they overlap.
|
|
99
|
+
- `stationery` executable with a command registry; `stationery render FILE [--out PATH|-] [--class NAME] [--strict] [--debug]` renders the Document a Ruby file defines (through `self.preview` when it needs arguments) and reports pages and bytes.
|
|
100
|
+
- `require "stationery/rspec"` matchers (`have_pdf_text`, `have_pdf_text_on_page`, `have_page_count`, `have_pdf_link`, `have_image_count`, `have_bookmark`, `have_no_warnings`) and `require "stationery/minitest"` assertions, built on `Stationery::Testing::Inspector`. Needs `pdf-reader` in the test group.
|
|
101
|
+
- Examples: `examples/report.rb` (multi-page annual report: header/footer regions, contents, bookmarks, lists, tables, a split callout, internal links, SVG icons), `examples/letter.rb` (one-page letter with a vector letterhead) and `examples/packing_slip.rb` (landscape, 120-row table with split rows, canvas barcode), each covered by an integration spec and rendered in CI by `rake examples`.
|
|
102
|
+
- `rake bench`: reproducible benchmarks against Prawn + prawn-table (one-page invoice, 1,500-row table) and a StackProf profile script under `benchmark/`.
|
|
103
|
+
|
|
104
|
+
### Performance
|
|
105
|
+
|
|
106
|
+
- Layout caches: container nodes (box, row, flow, wrap, list item) memoise their height per width; table cells remember their height per width and their natural and minimum widths across the fragments of a split table; tables memoise row heights and column metrics; table row boundaries are found in one pass. Fonts memoise style resolution and advance/kerning totals per string, and runs already split for fallback are not split again. Output is byte-identical. A 1,500-row table renders ~26x faster (17.9 s → 0.68 s, 186M → 4.5M allocations), the invoice example 1.3x faster (77k → 33k allocations).
|
|
107
|
+
- `benchmark/profile.rb` profiles in wall mode by default (`MODE=cpu|object`).
|
|
108
|
+
|
|
109
|
+
### Fixes
|
|
110
|
+
|
|
111
|
+
- Fixed: table cells restyled through a selection after the table had been measured kept their old style.
|
|
112
|
+
- Fixed: fonts without an OS/2 v2 table (including every subset) crashed on a nil cap height.
|
|
113
|
+
- `PDF::Writer` raises when a reserved object is never set instead of writing `null`.
|
|
114
|
+
- Gemspec ships every file under `lib/` and `exe/` when built without git, not only `.rb`.
|
|
115
|
+
|
|
116
|
+
## 0.1.0 (2026-09-26)
|
|
4
117
|
|
|
5
118
|
- PDF writer with Flate streams, per-page resources and link annotations.
|
|
6
119
|
- TrueType fonts: cmap 4/12, glyph subsetting, Type0/CIDFontType2 embedding with ToUnicode, synthetic bold and oblique.
|
data/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
Pure-Ruby PDF documents built from Phlex-style components. Describe the page
|
|
4
4
|
with rows, columns, boxes, tables, text and images; a box-layout engine
|
|
5
5
|
measures, places and paginates them; a small PDF writer embeds subsetted
|
|
6
|
-
TrueType fonts
|
|
6
|
+
TrueType and OpenType fonts, JPEG/PNG images and SVG drawings.
|
|
7
|
+
|
|
8
|
+
**Documentation: [stationery.zoolutions.llc](https://stationery.zoolutions.llc)**
|
|
7
9
|
|
|
8
10
|
- **No runtime dependencies.** Standard library only.
|
|
9
11
|
- **No native extensions and no other processes.** No Prawn, no headless
|
|
@@ -17,20 +19,28 @@ gem "stationery"
|
|
|
17
19
|
|
|
18
20
|
Ruby 3.4 or newer.
|
|
19
21
|
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class Hello < Stationery::Document
|
|
26
|
+
def view_template = text("Hello, world", size: 24, weight: :bold)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
File.binwrite("hello.pdf", Hello.new.to_pdf)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
No fonts to configure: Inter ships inside the gem and is used until you
|
|
33
|
+
declare a `font_family`.
|
|
34
|
+
|
|
20
35
|
## A document
|
|
21
36
|
|
|
22
37
|
```ruby
|
|
23
38
|
class InvoicePdf < Stationery::Document
|
|
24
39
|
page size: :a4, margin: [40, 44, 56, 44]
|
|
25
|
-
|
|
26
|
-
default_text font: "Inter", size: 9, color: "#1F2937"
|
|
40
|
+
default_text size: 9, color: "#1F2937" # bundled Inter; font_family "Brand", regular: "…" for your own
|
|
27
41
|
metadata title: "Invoice"
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
box(at: [44, page.height - 34], width: page.content_box.width) do
|
|
31
|
-
text "Page #{page.number} of #{page.count}", size: 7, align: :right
|
|
32
|
-
end
|
|
33
|
-
end
|
|
43
|
+
footer { |page| text "Page #{page.number} of #{page.count}", size: 7, align: :right }
|
|
34
44
|
|
|
35
45
|
def initialize(invoice)
|
|
36
46
|
super()
|
|
@@ -60,28 +70,110 @@ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
|
|
|
60
70
|
InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
|
|
61
71
|
```
|
|
62
72
|
|
|
63
|
-
`examples
|
|
73
|
+
`examples/` has a complete, runnable invoice, annual report, letter and packing slip; `bundle exec rake examples`
|
|
74
|
+
renders them all, or render one with `stationery render examples/report.rb`.
|
|
64
75
|
|
|
65
76
|
## Elements
|
|
66
77
|
|
|
67
78
|
| Element | What it does |
|
|
68
79
|
|---|---|
|
|
69
80
|
| `text(string, **style)` | A paragraph. Plain strings are literal. |
|
|
70
|
-
| `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href=""
|
|
81
|
+
| `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`; decodes numeric and HTML 4 named entities. |
|
|
71
82
|
| `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
|
|
72
|
-
| `box(padding:, background:, border:, radius:, width:, height:, overflow:, at:) { }` | A container. Moves to the next page whole. `overflow: :truncate` or `:shrink_to_fit` for fixed heights. `at: [x, y]` pins it to a page position. |
|
|
73
|
-
| `row(gap:, align:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). |
|
|
74
|
-
| `table(rows, widths:, width:, header:, cell:) { \|t\| }` | Tables. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. |
|
|
83
|
+
| `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). |
|
|
84
|
+
| `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. |
|
|
85
|
+
| `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. |
|
|
75
86
|
| `image(path_or_io, width:, height:, fit:, align:)` | JPEG or PNG, aspect preserved. |
|
|
87
|
+
| `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:`. Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `<style>` stylesheets (element, class, id and `*` selectors). |
|
|
88
|
+
| `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
|
|
89
|
+
| `ul(style:, gap:, indent:, marker_gap:, marker_color:) { li "…" }` | Bulleted list. `style:` is `:disc`, `:circle`, `:square`, `:dash` or any String; unstyled nested lists cycle disc → circle → square. Any node added inside (not only `li`) becomes an item. Items split across pages; the marker stays with the first line. |
|
|
90
|
+
| `ol(format:, start:, suffix:, gap:, indent:, marker_gap:, marker_color:) { }` | Numbered list. `format:` is `:decimal`, `:alpha`, `:upper_alpha`, `:roman`, `:upper_roman` or a Proc `(n) -> String`; `suffix:` defaults to `"."`. Markers right-align so bodies line up. |
|
|
91
|
+
| `li(string, **style)`, `li(gap:) { }` | A list item: a paragraph, or a block of any elements (including nested lists). |
|
|
76
92
|
| `rule(height:, color:)`, `spacer(height)`, `page_break` | Dividers and spacing. |
|
|
77
|
-
| `group(keep_together: true) { }` | Keep a block on one page. |
|
|
93
|
+
| `group(keep_together: true, align:) { }` | Keep a block on one page. |
|
|
94
|
+
| `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. |
|
|
78
95
|
| `text_style(**style) { }` | Default text style for a block. |
|
|
79
96
|
| `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links. |
|
|
97
|
+
| `html(source, styles:, gap:, images:, base_path:, bookmarks:)` | 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). |
|
|
98
|
+
| `markdown(source, styles:, gap:, images:, base_path:, bookmarks:)` | The same from CommonMark (plus GFM tables and strikethrough). |
|
|
80
99
|
|
|
81
100
|
Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
|
|
82
|
-
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `align`, `leading`.
|
|
101
|
+
`letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
|
|
102
|
+
`align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines
|
|
103
|
+
ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
|
|
83
104
|
Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
|
|
84
105
|
|
|
106
|
+
### HTML and Markdown
|
|
107
|
+
|
|
108
|
+
`html` and `markdown` render user content with the elements above; their parsers load on first use.
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
def view_template
|
|
112
|
+
html @post.body.to_s, # ActionText
|
|
113
|
+
images: ->(src) { blob_path_for(src) }, # path, IO or nil (skipped with a warning)
|
|
114
|
+
styles: { h1: { size: 22 }, a: { color: "#0F766E" }, code: { font: "JetBrains Mono" } }
|
|
115
|
+
markdown File.read("NOTES.md"), base_path: "docs", bookmarks: true
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- Images come from `images:` (called with the `src`) or from files under `base_path:`; sources that
|
|
120
|
+
resolve nowhere, point outside `base_path` or are remote URLs are skipped with a `SkippedImage`
|
|
121
|
+
warning. Nothing is ever fetched over the network.
|
|
122
|
+
- `styles:` is deep-merged into the defaults: `h1`–`h6` (`scale:` of the text size, or `size:` in
|
|
123
|
+
points; bold, `keep_with_next`), `p`, `a` (colour, underline), `code` (`font:`; register a
|
|
124
|
+
monospace family for inline and block code, otherwise the text font is used), `pre` and
|
|
125
|
+
`blockquote` (box options), `hr` (rule options), `table` (`cell:` options, `header:` text style),
|
|
126
|
+
`li` (text style) and `img` (`max_width:`).
|
|
127
|
+
- `gap:` spaces the blocks (default 6); `bookmarks: true` adds h1–h3 to the PDF outline.
|
|
128
|
+
|
|
129
|
+
### Links, bookmarks and table of contents
|
|
130
|
+
|
|
131
|
+
A link target starting with `#` jumps to a named anchor in the same PDF; anything else is a URL.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
text "See the totals", link: "#totals"
|
|
135
|
+
text %(Back to the <a href="#intro">introduction</a>), markup: true
|
|
136
|
+
box(link: "#appendix") { text "Appendix" }
|
|
137
|
+
|
|
138
|
+
text "Introduction", anchor: "intro" # also box(anchor:), group(anchor:), table(rows, anchor:)
|
|
139
|
+
box(anchor: "totals") { text "Totals" }
|
|
140
|
+
anchor "appendix" # standalone; moves to the next page with what follows it
|
|
141
|
+
text "Appendix", size: 16
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
An anchor on content that splits across pages points at its first page. A link to an unknown anchor
|
|
145
|
+
is dropped and reported as an `UnresolvedLink` warning; a name defined twice keeps the first and reports
|
|
146
|
+
a `DuplicateAnchor`. Anchors drawn by page templates resolve to the first page.
|
|
147
|
+
|
|
148
|
+
`bookmark:` adds an entry to the PDF outline (the viewer's bookmarks sidebar) pointing at where the
|
|
149
|
+
content paints. Levels nest under the nearest shallower entry above them; `open: true` shows an entry's
|
|
150
|
+
children expanded.
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
text "Introduction", size: 18, bookmark: "Introduction" # level 1
|
|
154
|
+
box(bookmark: { title: "Scope", level: 2, open: true }) { ... } # also group(bookmark:), table(rows, bookmark:)
|
|
155
|
+
bookmark "Appendix", level: 1 # standalone; moves with what follows it
|
|
156
|
+
text "Appendix", size: 16
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A document with bookmarks opens with the outline shown. A bookmark whose content never paints (cut off
|
|
160
|
+
by a fixed-height box) is left out; bookmarks inside page templates are ignored.
|
|
161
|
+
|
|
162
|
+
`table_of_contents` lists the bookmarks with the page each one landed on, one clickable row per entry.
|
|
163
|
+
It reads the outline when layout starts, so bookmarks declared after it are included.
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
text "Contents", size: 18
|
|
167
|
+
table_of_contents # every level, dotted leaders
|
|
168
|
+
table_of_contents(levels: 1..2, leader: :line, indent: 16, size: 9, color: "#374151")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Options: `levels:` (a Range, or an Integer maximum depth), `leader:` (`:dots`, `:line` or `nil`),
|
|
172
|
+
`indent:` per level (points), `gap:` between rows and before the number, `number_width:` (defaults to
|
|
173
|
+
the width of "0000") plus any text style. Numbers are right-aligned in a fixed slot and filled in after
|
|
174
|
+
pagination, so a long contents list paginates without reflowing. An entry whose target never paints
|
|
175
|
+
keeps its title, with no number and no link.
|
|
176
|
+
|
|
85
177
|
## Components
|
|
86
178
|
|
|
87
179
|
Components have Phlex's lifecycle (`around_template`, `before_template`,
|
|
@@ -104,26 +196,285 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
|
|
|
104
196
|
- `page_template { |page| … }` runs on every page after pagination with `page.number`, `page.count`,
|
|
105
197
|
`page.width`, `page.height`, `page.margin` and `page.content_box`. `page_template(layer: :background)`
|
|
106
198
|
paints under the content (full-bleed backgrounds).
|
|
199
|
+
- `header` and `footer` reserve space at the top and bottom of the page; the body flows between them:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
header(gap: 8) do |page|
|
|
203
|
+
row do
|
|
204
|
+
text "ACME"
|
|
205
|
+
text "Page #{page.number} of #{page.count}", align: :right
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
footer(on: :rest) { text "Confidential", size: 7, align: :center }
|
|
209
|
+
header(on: :first) {} # no header on page 1
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`on:` takes `:all` (default), `:first`, `:rest`, `:odd`, `:even`, `:last`, a page number, a range or
|
|
213
|
+
`->(number) { … }`; later declarations win for the pages they match, and an empty block removes the
|
|
214
|
+
region there. `gap:` (default 8) is extra space between the region and the body. Without `height:` a
|
|
215
|
+
region is measured once, on the first page that uses it; pass `height:` when its content varies per
|
|
216
|
+
page. The footer is bottom-aligned. A region taller than its space is still drawn and listed in
|
|
217
|
+
`document.warnings`; regions that leave no room for the body raise `ArgumentError`. With `on: :last`
|
|
218
|
+
(say, a taller footer with totals) the paginator checks whether the rest fits above it; content that
|
|
219
|
+
fits a normal page but not the last one continues onto an extra page.
|
|
220
|
+
- With a header or footer, a `page_template`'s `page.content_box` excludes their space (it is the
|
|
221
|
+
body's area).
|
|
107
222
|
- Content taller than a page is placed anyway; `document.warnings` lists every overflow.
|
|
223
|
+
- After `to_pdf`, `document.warnings` is an Enumerable of everything the render noticed but did not
|
|
224
|
+
raise on, each with a `#message`: overflows, SVG elements that were skipped (`UnsupportedSvg`), and
|
|
225
|
+
the other `Stationery::Warnings::*` kinds (missing glyphs, unknown font families, skipped images,
|
|
226
|
+
unresolved links, duplicate anchors). Equal warnings are listed once; warnings from page templates
|
|
227
|
+
are included. `to_pdf(strict: true)`, or `strict` at class level, raises `Stationery::WarningsError`
|
|
228
|
+
(with `#warnings`) instead of writing a PDF that produced any; `to_pdf(strict: false)` opts one
|
|
229
|
+
render out again.
|
|
230
|
+
|
|
231
|
+
### Encryption
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
class InvoicePdf < Stationery::Document
|
|
235
|
+
encrypt owner_password: "s3cret", permissions: [:print] # every render
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
InvoicePdf.new(invoice).to_pdf(encrypt: { user_password: "1234", owner_password: "s3cret",
|
|
239
|
+
permissions: %i[print copy] }) # override for one render
|
|
240
|
+
InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- `owner_password:` is required (`ArgumentError` when missing or empty); it opens the file with every
|
|
244
|
+
right. `user_password:` defaults to `""`: the file opens without a prompt, but viewers enforce the
|
|
245
|
+
permissions.
|
|
246
|
+
- `permissions:` is a subset of `%i[print modify copy annotate fill_forms extract_accessible assemble
|
|
247
|
+
print_high]` (default: all).
|
|
248
|
+
- `algorithm:` picks the standard security handler. `:aes_256` (default, PDF 2.0 / Acrobat X+);
|
|
249
|
+
`:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
|
|
250
|
+
- Every string and stream is encrypted, the document info included.
|
|
251
|
+
|
|
252
|
+
### Debugging
|
|
253
|
+
|
|
254
|
+
`to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed),
|
|
255
|
+
row columns (blue), flow slots (grey, dotted), table cells (green, padding dashed), positioned boxes (pink),
|
|
256
|
+
images (teal) and the page content box (cyan). Pass an Array to outline only some kinds:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
Invoice.new.to_pdf("invoice.pdf", debug: %i[cell cell_padding])
|
|
260
|
+
```
|
|
108
261
|
|
|
109
262
|
## Rails
|
|
110
263
|
|
|
111
264
|
```ruby
|
|
112
|
-
|
|
265
|
+
# Gemfile
|
|
266
|
+
gem "stationery", require: "stationery/rails"
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Controllers gain `render pdf:` and `send_pdf`:
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
def show
|
|
273
|
+
render pdf: InvoicePdf.new(@invoice), filename: "invoice.pdf" # disposition: "attachment" to download
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def download = send_pdf(InvoicePdf.new(@invoice), filename: "invoice.pdf", disposition: "attachment")
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`render pdf:` only accepts a document; anything else raises `ArgumentError`.
|
|
280
|
+
Configure in `config/application.rb`:
|
|
281
|
+
|
|
282
|
+
| Key | Default | |
|
|
283
|
+
| --- | --- | --- |
|
|
284
|
+
| `config.stationery.renderer` | `true` | `false` skips `render pdf:` (keeps another gem's, e.g. wicked_pdf's) |
|
|
285
|
+
| `config.stationery.font_paths` | `["vendor/fonts"]` | directories under `Rails.root` added to `Stationery.font_paths` when they exist |
|
|
286
|
+
| `config.stationery.preview_paths` | `["spec/pdfs/previews", "test/pdfs/previews"]` | directories under `Rails.root` searched for `*_preview.rb` |
|
|
287
|
+
| `config.stationery.show_previews` | `Rails.env.development?` | mounts the preview routes |
|
|
288
|
+
|
|
289
|
+
### Previews
|
|
290
|
+
|
|
291
|
+
Like ActionMailer previews: a class ending in `Preview` under
|
|
292
|
+
`spec/pdfs/previews` (or `test/pdfs/previews`), one public method per sample
|
|
293
|
+
document.
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
# spec/pdfs/previews/invoice_pdf_preview.rb
|
|
297
|
+
class InvoicePdfPreview < Stationery::Preview
|
|
298
|
+
def paid = InvoicePdf.new(Invoice.paid.first)
|
|
299
|
+
def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id", Invoice.overdue.first.id)))
|
|
300
|
+
end
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Open `/rails/stationery/previews` for the list; each one renders inline at
|
|
304
|
+
`/rails/stationery/previews/invoice_pdf/paid`. Query parameters reach methods
|
|
305
|
+
that take an argument (`?id=42`); `?debug=1` passes `debug: true` to `to_pdf`
|
|
306
|
+
when the document supports it. Preview files are re-`load`ed on every request,
|
|
307
|
+
so edits show up on refresh.
|
|
308
|
+
|
|
309
|
+
The gem has no Rails dependency; the Railtie loads only inside a Rails app.
|
|
310
|
+
|
|
311
|
+
## CLI
|
|
113
312
|
|
|
114
|
-
|
|
313
|
+
```sh
|
|
314
|
+
stationery render app/pdfs/invoice_pdf.rb # writes app/pdfs/invoice_pdf.pdf
|
|
315
|
+
stationery render invoice.rb --out - > invoice.pdf # PDF to stdout
|
|
316
|
+
stationery render pdfs.rb --class InvoicePdf --strict # pick one; fail on layout warnings
|
|
115
317
|
```
|
|
116
318
|
|
|
319
|
+
`render` loads the file and renders the `Stationery::Document` it defines. A
|
|
320
|
+
document whose `initialize` needs arguments renders from `def self.preview`,
|
|
321
|
+
which returns an instance built with sample data. Layout warnings print to
|
|
322
|
+
stderr; `--strict` exits 1 instead of writing. `stationery help` lists the
|
|
323
|
+
commands.
|
|
324
|
+
|
|
325
|
+
```sh
|
|
326
|
+
stationery fonts list # packs, licenses, what is in vendor/fonts
|
|
327
|
+
stationery fonts install noto_sans liberation_serif # into vendor/fonts/<pack>/
|
|
328
|
+
stationery fonts install noto_sans --into app/fonts --force
|
|
329
|
+
stationery fonts install liberation_sans --from ~/Downloads/liberation-fonts-ttf-2.1.5.tar.gz # offline
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`fonts install` downloads pinned files over HTTPS, checks every SHA-256
|
|
333
|
+
before writing anything, and writes the license next to the fonts. Files
|
|
334
|
+
already present are kept unless `--force`. `--from` takes a directory or a
|
|
335
|
+
`.tar.gz` holding the same files (still SHA-checked). In Rails,
|
|
336
|
+
`bin/rails generate stationery:fonts noto_sans` does the same.
|
|
337
|
+
|
|
117
338
|
## Fonts and images
|
|
118
339
|
|
|
119
|
-
Fonts are TrueType (`.ttf`)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
340
|
+
Fonts are TrueType (`.ttf`) or OpenType/CFF (`.otf`, name-keyed or
|
|
341
|
+
CID-keyed) files, WOFF 1.0 web fonts (`.woff`, unwrapped in memory), or
|
|
342
|
+
faces of a TrueType collection (`.ttc`): a `#N` suffix
|
|
343
|
+
on the path picks face N, counted from 0 (face 0 without a suffix):
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2"
|
|
347
|
+
```
|
|
348
|
+
Only the glyphs a document uses are embedded (a CFF font
|
|
349
|
+
keeps its glyph numbering and subroutines; unused glyphs are blanked), with a
|
|
350
|
+
ToUnicode map so text copies and searches correctly. A style without its own
|
|
351
|
+
file (bold, italic) is synthesised.
|
|
352
|
+
|
|
353
|
+
Inter (regular, bold, italic, bold italic; SIL Open Font License) is bundled
|
|
354
|
+
and used when a document declares no family. `font_family "Inter"` with no
|
|
355
|
+
paths selects it explicitly, and `Stationery.bundled_fonts` lists what ships.
|
|
356
|
+
Font files are read lazily, on first use, never when the gem is required.
|
|
357
|
+
|
|
358
|
+
More families come as font packs, installed into your app at development
|
|
359
|
+
time (commit them; nothing is downloaded at runtime):
|
|
360
|
+
|
|
361
|
+
| Pack | Family | License |
|
|
362
|
+
|---|---|---|
|
|
363
|
+
| `inter` | Inter (copied from the gem) | OFL 1.1 |
|
|
364
|
+
| `noto_sans`, `noto_serif`, `noto_sans_mono` | Noto Sans, Noto Serif, Noto Sans Mono | OFL 1.1 |
|
|
365
|
+
| `liberation_sans`, `liberation_serif`, `liberation_mono` | Liberation Sans, Serif, Mono (metric-compatible with Arial, Times New Roman, Courier New) | OFL 1.1, Reserved Font Name "Liberation" |
|
|
366
|
+
|
|
367
|
+
`Stationery.font_paths` lists the directories searched for installed packs;
|
|
368
|
+
the Railtie adds `vendor/fonts` (and `config.stationery.font_paths`) when it
|
|
369
|
+
exists. With the pack's directory there, the family name is enough:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
font_family "Noto Sans" # found in Stationery.font_paths
|
|
373
|
+
font_family "Noto Sans", **Stationery::Fonts.paths(:noto_sans, dir: "vendor/fonts") # outside Rails
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
or append it yourself: `Stationery.font_paths << File.expand_path("vendor/fonts")`.
|
|
377
|
+
From Ruby: `Stationery::Fonts.install(:noto_sans, into: "vendor/fonts")`
|
|
378
|
+
returns `{ regular: path, bold: path, … }`; `Stationery::Fonts.catalog` lists
|
|
379
|
+
the packs. `rake fonts:verify` (development only) downloads every pack and
|
|
380
|
+
checks its SHA-256s.
|
|
381
|
+
|
|
382
|
+
A character the text's family has no glyph for is drawn from the first
|
|
383
|
+
family in `font_fallbacks` that has it, then from bundled Inter, in the same
|
|
384
|
+
weight and style (synthesised when the family lacks the face):
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
class Report < Stationery::Document
|
|
388
|
+
font_family "Brand", regular: "Brand-Regular.ttf"
|
|
389
|
+
font_family "Noto Sans Symbols", regular: "NotoSansSymbols-Regular.ttf"
|
|
390
|
+
font_fallbacks "Noto Sans Symbols"
|
|
391
|
+
|
|
392
|
+
def view_template = text("Next → ☃")
|
|
393
|
+
end
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Spaces, joiners, variation selectors and combining marks stay with the
|
|
397
|
+
character before them. A glyph no font has is drawn as the family's
|
|
398
|
+
`.notdef` and reported as a `Warnings::MissingGlyph` counting each drawn
|
|
399
|
+
occurrence (so `strict` raises on it). Fallback covers every text element,
|
|
400
|
+
table cell, list marker, table of contents entry and page template text;
|
|
401
|
+
direct `canvas.text` calls draw with the font they are given.
|
|
402
|
+
|
|
403
|
+
Text is pair-kerned from the font's GPOS `kern` feature (PairPos lookups,
|
|
404
|
+
including class-based pairs and Extension lookups), falling back to the
|
|
405
|
+
legacy `kern` table (`kerning: false` on an element or in `default_text`
|
|
406
|
+
turns it off). Pairs that straddle a style or
|
|
407
|
+
font change are not kerned. Kerning only tightens in practice, so a kerned
|
|
408
|
+
line is never wider than the same line unkerned.
|
|
409
|
+
|
|
410
|
+
Standard ligatures (fi, fl, ffi, …) come from the font's GSUB `liga` feature
|
|
411
|
+
(LigatureSubst lookups, also behind Extension lookups) and are on by default;
|
|
412
|
+
`ligatures: false` on an element or in `default_text` turns them off. Only
|
|
413
|
+
`liga` applies, not `clig` or `dlig`. Ligatures form within a run of one
|
|
414
|
+
style and font, never across a line break, and letter spacing turns them off.
|
|
415
|
+
The PDF's ToUnicode map sends a ligature glyph back to all of its
|
|
416
|
+
characters, so copied and extracted text still reads "office". Fonts
|
|
417
|
+
without a `liga` feature (such as the bundled Inter) are unaffected.
|
|
123
418
|
|
|
124
419
|
Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
|
|
125
420
|
mask). Parsed fonts and images are cached per process.
|
|
126
421
|
|
|
422
|
+
## Testing
|
|
423
|
+
|
|
424
|
+
`stationery/rspec` and `stationery/minitest` read a rendered PDF back for
|
|
425
|
+
assertions. They need the `pdf-reader` gem, which stationery itself does not
|
|
426
|
+
depend on:
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
# Gemfile
|
|
430
|
+
group :test do
|
|
431
|
+
gem "pdf-reader"
|
|
432
|
+
end
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
The subject is a document (rendered once), PDF bytes, a file path or an IO.
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
# spec/spec_helper.rb
|
|
439
|
+
require "stationery/rspec"
|
|
440
|
+
|
|
441
|
+
RSpec.describe InvoicePdf do
|
|
442
|
+
subject(:pdf) { InvoicePdf.new(invoice) }
|
|
443
|
+
|
|
444
|
+
it { is_expected.to have_pdf_text("Invoice INV-7") }
|
|
445
|
+
it { is_expected.to have_pdf_text_on_page(2, /Total €[\d ,]+/) }
|
|
446
|
+
it { is_expected.to have_page_count(2) }
|
|
447
|
+
it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
|
|
448
|
+
it { is_expected.to have_image_count(1) }
|
|
449
|
+
it { is_expected.to have_no_warnings }
|
|
450
|
+
end
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
```ruby
|
|
454
|
+
# test/test_helper.rb
|
|
455
|
+
require "stationery/minitest"
|
|
456
|
+
|
|
457
|
+
class InvoicePdfTest < Minitest::Test
|
|
458
|
+
include Stationery::Testing::Assertions
|
|
459
|
+
|
|
460
|
+
def test_prints_the_total
|
|
461
|
+
pdf = InvoicePdf.new(invoice)
|
|
462
|
+
|
|
463
|
+
assert_pdf_text pdf, "Invoice INV-7"
|
|
464
|
+
refute_pdf_text pdf, "DRAFT"
|
|
465
|
+
assert_page_count pdf, 2
|
|
466
|
+
assert_pdf_link pdf, /acme\.test/
|
|
467
|
+
assert_no_pdf_warnings pdf
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The matcher names carry a `pdf_` prefix so they never clash with Capybara's
|
|
473
|
+
`have_text` and `have_link`. `have_bookmark` / `assert_bookmark` match outline
|
|
474
|
+
titles. For anything else, `Stationery::Testing::Inspector.new(subject)`
|
|
475
|
+
exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
|
|
476
|
+
`image_count`, `bookmarks`, `metadata` and `warnings`.
|
|
477
|
+
|
|
127
478
|
## Why not Prawn, Chrome or Typst?
|
|
128
479
|
|
|
129
480
|
- **Prawn** is an imperative cursor API: every document does its own layout
|
|
@@ -132,12 +483,31 @@ mask). Parsed fonts and images are cached per process.
|
|
|
132
483
|
a browser per worker; stray processes and memory are the price.
|
|
133
484
|
- **Typst** is excellent but a native extension and a second template language.
|
|
134
485
|
|
|
486
|
+
## Performance
|
|
487
|
+
|
|
488
|
+
`bundle exec rake bench` renders two documents with Stationery and with Prawn
|
|
489
|
+
2.5 + prawn-table, both embedding the same Open Sans TTF files
|
|
490
|
+
(`benchmark/`, not part of CI). Apple M2 Max, Ruby 3.4.2 +YJIT, 27 September 2026:
|
|
491
|
+
|
|
492
|
+
| Document | Engine | Renders/s | Objects allocated | PDF bytes |
|
|
493
|
+
|---|---|---:|---:|---:|
|
|
494
|
+
| Invoice (1 page, `examples/invoice.rb`) | Stationery | 74.6 | 33,231 | 23,525 |
|
|
495
|
+
| | Prawn | 45.4 (1.64x slower) | 88,558 | 33,034 |
|
|
496
|
+
| Table, 1,500 rows × 5 columns, repeating header | Stationery | 1.47 (46 pages) | 4,462,820 | 233,389 |
|
|
497
|
+
| | Prawn | 0.74 (40 pages; 1.99x slower) | 6,901,818 | 2,689,487 |
|
|
498
|
+
|
|
499
|
+
Stationery compresses content streams; Prawn does not by default, hence the
|
|
500
|
+
larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
|
|
501
|
+
20 hottest frames of the table render under StackProf (wall mode; `MODE=cpu` or
|
|
502
|
+
`MODE=object` for the others).
|
|
503
|
+
|
|
135
504
|
## Limitations
|
|
136
505
|
|
|
137
|
-
No
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
506
|
+
No variable fonts (including CFF2) or
|
|
507
|
+
WOFF2 (it needs Brotli; convert to `.ttf` or `.woff`); SVG covers the shapes icon sets use
|
|
508
|
+
(no patterns or masks); no forms or
|
|
509
|
+
tagged PDF. A box with a fixed `height:` never splits (use
|
|
510
|
+
`min_height:` for a floor that can); a row splits only when every column can.
|
|
141
511
|
|
|
142
512
|
## License
|
|
143
513
|
|