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.
Files changed (138) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -1
  3. data/README.md +395 -25
  4. data/exe/stationery +6 -0
  5. data/lib/generators/stationery/fonts_generator.rb +24 -0
  6. data/lib/stationery/builder.rb +26 -3
  7. data/lib/stationery/canvas/debug.rb +26 -0
  8. data/lib/stationery/canvas/path.rb +22 -12
  9. data/lib/stationery/canvas/text.rb +10 -6
  10. data/lib/stationery/canvas.rb +38 -8
  11. data/lib/stationery/cli/fonts.rb +85 -0
  12. data/lib/stationery/cli/render.rb +137 -0
  13. data/lib/stationery/cli.rb +74 -0
  14. data/lib/stationery/document.rb +60 -9
  15. data/lib/stationery/elements/lists.rb +58 -0
  16. data/lib/stationery/elements/rich.rb +27 -0
  17. data/lib/stationery/elements.rb +88 -12
  18. data/lib/stationery/errors.rb +13 -1
  19. data/lib/stationery/fonts/bundled.rb +25 -0
  20. data/lib/stationery/fonts/catalog.rb +101 -0
  21. data/lib/stationery/fonts/cff.rb +140 -0
  22. data/lib/stationery/fonts/cff_subset.rb +145 -0
  23. data/lib/stationery/fonts/data/Inter-Bold.ttf +0 -0
  24. data/lib/stationery/fonts/data/Inter-BoldItalic.ttf +0 -0
  25. data/lib/stationery/fonts/data/Inter-Italic.ttf +0 -0
  26. data/lib/stationery/fonts/data/Inter-Regular.ttf +0 -0
  27. data/lib/stationery/fonts/data/OFL.txt +92 -0
  28. data/lib/stationery/fonts/data/README.md +14 -0
  29. data/lib/stationery/fonts/embedding/base.rb +56 -0
  30. data/lib/stationery/fonts/embedding/cff.rb +35 -0
  31. data/lib/stationery/fonts/embedding/true_type.rb +35 -0
  32. data/lib/stationery/fonts/fallback.rb +58 -0
  33. data/lib/stationery/fonts/family.rb +11 -0
  34. data/lib/stationery/fonts/font.rb +68 -88
  35. data/lib/stationery/fonts/font_book.rb +49 -8
  36. data/lib/stationery/fonts/glyph_run.rb +39 -0
  37. data/lib/stationery/fonts/gpos/class_def.rb +25 -0
  38. data/lib/stationery/fonts/gpos/coverage.rb +20 -0
  39. data/lib/stationery/fonts/gpos/pair_pos.rb +83 -0
  40. data/lib/stationery/fonts/gpos.rb +64 -0
  41. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  42. data/lib/stationery/fonts/gsub.rb +65 -0
  43. data/lib/stationery/fonts/installer.rb +121 -0
  44. data/lib/stationery/fonts/kern_table.rb +52 -0
  45. data/lib/stationery/fonts/kerning.rb +17 -0
  46. data/lib/stationery/fonts/ligatures.rb +16 -0
  47. data/lib/stationery/fonts/packs.rb +45 -0
  48. data/lib/stationery/fonts/registry.rb +18 -9
  49. data/lib/stationery/fonts/sfnt_metrics.rb +51 -0
  50. data/lib/stationery/fonts/to_unicode.rb +36 -0
  51. data/lib/stationery/fonts/true_type.rb +64 -57
  52. data/lib/stationery/fonts/woff.rb +52 -0
  53. data/lib/stationery/html/document.rb +11 -0
  54. data/lib/stationery/html/tokenizer.rb +74 -0
  55. data/lib/stationery/html/tree_builder/blocks.rb +137 -0
  56. data/lib/stationery/html/tree_builder/collector.rb +50 -0
  57. data/lib/stationery/html/tree_builder.rb +72 -0
  58. data/lib/stationery/html/whitespace.rb +43 -0
  59. data/lib/stationery/layout/box.rb +102 -16
  60. data/lib/stationery/layout/flow.rb +48 -15
  61. data/lib/stationery/layout/image.rb +1 -0
  62. data/lib/stationery/layout/list_item.rb +89 -0
  63. data/lib/stationery/layout/mark.rb +58 -0
  64. data/lib/stationery/layout/node.rb +26 -2
  65. data/lib/stationery/layout/paginator.rb +35 -6
  66. data/lib/stationery/layout/positioned.rb +3 -1
  67. data/lib/stationery/layout/row.rb +34 -4
  68. data/lib/stationery/layout/svg.rb +48 -0
  69. data/lib/stationery/layout/table/cell.rb +33 -6
  70. data/lib/stationery/layout/table/grid.rb +93 -0
  71. data/lib/stationery/layout/table/row_splitter.rb +36 -0
  72. data/lib/stationery/layout/table/selection.rb +3 -2
  73. data/lib/stationery/layout/table.rb +69 -23
  74. data/lib/stationery/layout/table_of_contents/entry.rb +56 -0
  75. data/lib/stationery/layout/table_of_contents.rb +43 -0
  76. data/lib/stationery/layout/text.rb +6 -4
  77. data/lib/stationery/layout/wrap.rb +78 -0
  78. data/lib/stationery/list_markers.rb +47 -0
  79. data/lib/stationery/markdown/block_parser/containers.rb +94 -0
  80. data/lib/stationery/markdown/block_parser/tables.rb +54 -0
  81. data/lib/stationery/markdown/block_parser.rb +141 -0
  82. data/lib/stationery/markdown/document.rb +50 -0
  83. data/lib/stationery/markdown/inline_parser/emphasis.rb +80 -0
  84. data/lib/stationery/markdown/inline_parser/links.rb +70 -0
  85. data/lib/stationery/markdown/inline_parser/nodes.rb +39 -0
  86. data/lib/stationery/markdown/inline_parser.rb +89 -0
  87. data/lib/stationery/minitest.rb +33 -0
  88. data/lib/stationery/outline.rb +37 -0
  89. data/lib/stationery/page.rb +18 -4
  90. data/lib/stationery/page_templates.rb +33 -10
  91. data/lib/stationery/pdf/assembler.rb +20 -8
  92. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  93. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  94. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  95. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  96. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  97. data/lib/stationery/pdf/outline_writer.rb +72 -0
  98. data/lib/stationery/pdf/serializer.rb +22 -10
  99. data/lib/stationery/pdf/writer.rb +28 -5
  100. data/lib/stationery/preview.rb +64 -0
  101. data/lib/stationery/rails/previews_controller.rb +61 -0
  102. data/lib/stationery/rails.rb +2 -0
  103. data/lib/stationery/railtie.rb +60 -0
  104. data/lib/stationery/regions.rb +67 -0
  105. data/lib/stationery/resources.rb +8 -2
  106. data/lib/stationery/rich/nodes.rb +55 -0
  107. data/lib/stationery/rich/renderer/inlines.rb +53 -0
  108. data/lib/stationery/rich/renderer.rb +121 -0
  109. data/lib/stationery/rich/styles.rb +34 -0
  110. data/lib/stationery/rspec.rb +6 -0
  111. data/lib/stationery/structure.rb +78 -0
  112. data/lib/stationery/svg/arc.rb +91 -0
  113. data/lib/stationery/svg/bounds.rb +76 -0
  114. data/lib/stationery/svg/document.rb +103 -0
  115. data/lib/stationery/svg/gradient.rb +115 -0
  116. data/lib/stationery/svg/painter.rb +70 -0
  117. data/lib/stationery/svg/parser.rb +58 -0
  118. data/lib/stationery/svg/path_data.rb +137 -0
  119. data/lib/stationery/svg/selector.rb +37 -0
  120. data/lib/stationery/svg/shading.rb +34 -0
  121. data/lib/stationery/svg/shapes.rb +54 -0
  122. data/lib/stationery/svg/style.rb +85 -0
  123. data/lib/stationery/svg/stylesheet.rb +47 -0
  124. data/lib/stationery/svg/text.rb +122 -0
  125. data/lib/stationery/svg/transform.rb +54 -0
  126. data/lib/stationery/testing/inspector.rb +98 -0
  127. data/lib/stationery/testing/matchers.rb +123 -0
  128. data/lib/stationery/text/entities/html4.rb +49 -0
  129. data/lib/stationery/text/entities.rb +6 -1
  130. data/lib/stationery/text/line.rb +8 -2
  131. data/lib/stationery/text/paragraph.rb +33 -11
  132. data/lib/stationery/text/runs_builder.rb +9 -1
  133. data/lib/stationery/text/style.rb +4 -2
  134. data/lib/stationery/text/wrapper.rb +4 -2
  135. data/lib/stationery/version.rb +1 -1
  136. data/lib/stationery/warnings.rb +65 -0
  137. data/lib/stationery.rb +67 -0
  138. metadata +101 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7df6bdee0612f0952d1fb587f64720fb78d976e77b7327292dd261b5afbe14d7
4
- data.tar.gz: 9c9c800f4c4a5aea772df63676eeb5936a3282828eec401494256d1691cae30b
3
+ metadata.gz: f9ff43b2013eb330ebc103525c440bb5e4cfdaf0d05364baf47fd329c48ad11a
4
+ data.tar.gz: 718ede92892d3c7c0cdc556708880a2ba53f559611b872cfcb8ac946adb59908
5
5
  SHA512:
6
- metadata.gz: ea8fa33394b8f68a0316d0bba056e97d48bee53db8ffd4b8addb9aea023b0d492f42c34884408d0771c136993bcc1249eb2064bfbe0f11cecb00aa52bc1561e0
7
- data.tar.gz: dc14973b6ca838dd0be5651dc0cb51b404e8c0e3fe5958ad1edcac3dad9026ad6a66a13c21d15bb01483412a651a9aafad655c047d9c73868405d123608d1daa
6
+ metadata.gz: b3a4a85b185b0f06e195a15f81773c86d79086e32e923b1c21c2d39ee528375fd5b75be1581fc3d8180e530071d653939674804c973896d0edb3b57fffbde9fa
7
+ data.tar.gz: fd368b63884a129cd0e6f03507da714891c79b81b6a1aeadde9cfeafc4987824d7cd525c2f86aa3eba397f432a14516429bc697b61177056a790a602d85369ae
data/CHANGELOG.md CHANGED
@@ -1,6 +1,119 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
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 (`&mdash;`, `&euro;`, `&hellip;`, …); 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 and JPEG/PNG images.
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
- font_family "Inter", regular: "fonts/Inter-Regular.ttf", bold: "fonts/Inter-Bold.ttf"
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
- page_template do |page|
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/invoice.rb` is a complete, runnable invoice: `ruby -Ilib examples/invoice.rb`.
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
- require "stationery/rails" # adds send_pdf to controllers
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
- def show = send_pdf(InvoicePdf.new(@invoice), filename: "invoice.pdf")
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`) files. Only the glyphs a document uses are
120
- embedded, with a ToUnicode map so text copies and searches correctly. A style
121
- without its own file (bold, italic) is synthesised. There is no built-in
122
- font: declare at least one `font_family`.
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 kerning or ligatures, no OpenType/CFF, TrueType collections, variable
138
- fonts or WOFF; no full justification; no SVG (paths are available on the
139
- canvas); no encryption, outlines, forms or tagged PDF; boxes and rows do not
140
- split across pages.
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
 
data/exe/stationery ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "stationery/cli"
5
+
6
+ exit Stationery::CLI.start(ARGV)