stationery 0.3.0 → 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +136 -14
  4. data/lib/stationery/builder.rb +12 -0
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas.rb +50 -8
  8. data/lib/stationery/document.rb +28 -10
  9. data/lib/stationery/elements/forms.rb +55 -0
  10. data/lib/stationery/elements/lists.rb +15 -5
  11. data/lib/stationery/elements/rich.rb +7 -5
  12. data/lib/stationery/elements.rb +34 -7
  13. data/lib/stationery/fonts/fallback.rb +6 -4
  14. data/lib/stationery/fonts/font.rb +51 -8
  15. data/lib/stationery/forms/acro_form.rb +115 -0
  16. data/lib/stationery/forms/appearance.rb +119 -0
  17. data/lib/stationery/forms/field.rb +122 -0
  18. data/lib/stationery/forms/metrics.rb +49 -0
  19. data/lib/stationery/layout/box.rb +64 -12
  20. data/lib/stationery/layout/field.rb +51 -0
  21. data/lib/stationery/layout/flow.rb +17 -9
  22. data/lib/stationery/layout/image.rb +35 -4
  23. data/lib/stationery/layout/list_item.rb +15 -6
  24. data/lib/stationery/layout/node.rb +2 -0
  25. data/lib/stationery/layout/paginator.rb +3 -2
  26. data/lib/stationery/layout/stack.rb +81 -0
  27. data/lib/stationery/layout/svg.rb +7 -3
  28. data/lib/stationery/layout/table/cell.rb +10 -2
  29. data/lib/stationery/layout/table.rb +42 -10
  30. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  31. data/lib/stationery/layout/table_of_contents.rb +1 -1
  32. data/lib/stationery/layout/text.rb +4 -3
  33. data/lib/stationery/minitest.rb +3 -0
  34. data/lib/stationery/page.rb +5 -2
  35. data/lib/stationery/page_templates.rb +7 -4
  36. data/lib/stationery/pdf/assembler.rb +36 -4
  37. data/lib/stationery/preview.rb +23 -2
  38. data/lib/stationery/rails/previews_controller.rb +2 -7
  39. data/lib/stationery/rich/renderer/inlines.rb +10 -2
  40. data/lib/stationery/rich/renderer/links.rb +28 -0
  41. data/lib/stationery/rich/renderer.rb +9 -6
  42. data/lib/stationery/rich/styles.rb +2 -0
  43. data/lib/stationery/rspec.rb +4 -0
  44. data/lib/stationery/structure.rb +15 -9
  45. data/lib/stationery/tagging/element.rb +74 -0
  46. data/lib/stationery/tagging/tree.rb +43 -0
  47. data/lib/stationery/tagging/writer.rb +81 -0
  48. data/lib/stationery/testing/inspector.rb +22 -1
  49. data/lib/stationery/testing/marked_text.rb +60 -0
  50. data/lib/stationery/testing/matchers.rb +35 -0
  51. data/lib/stationery/testing/structure_reader.rb +71 -0
  52. data/lib/stationery/text/paragraph.rb +27 -12
  53. data/lib/stationery/version.rb +1 -1
  54. data/lib/stationery/warnings.rb +12 -0
  55. data/lib/stationery.rb +11 -0
  56. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9ff43b2013eb330ebc103525c440bb5e4cfdaf0d05364baf47fd329c48ad11a
4
- data.tar.gz: 718ede92892d3c7c0cdc556708880a2ba53f559611b872cfcb8ac946adb59908
3
+ metadata.gz: dab8be06e23e27d74ddd2ec627bc34923a79a9c0cb5fe40da55f43bdcb81e337
4
+ data.tar.gz: a7f90eadef2004318933e8a65cd1f86841c5b9bfc4cba8ff6191b2f96946b8e5
5
5
  SHA512:
6
- metadata.gz: b3a4a85b185b0f06e195a15f81773c86d79086e32e923b1c21c2d39ee528375fd5b75be1581fc3d8180e530071d653939674804c973896d0edb3b57fffbde9fa
7
- data.tar.gz: fd368b63884a129cd0e6f03507da714891c79b81b6a1aeadde9cfeafc4987824d7cd525c2f86aa3eba397f432a14516429bc697b61177056a790a602d85369ae
6
+ metadata.gz: ce40fc76233a4c6ba0ca455c9c80ac3a651832ba86ab7003c8f907b2abd8291cbe0a7421302b4a6762e876df284ebe98ed862017eaed7cb4854d609e3fa557ec
7
+ data.tar.gz: b1c1928f47210a8b29586f73881b8430978c644a2ea7489fab251796b7192213057a414c3145c47502fa267e3c857d5b581f91d8bf2bca394e61384844fc21d8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,98 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 (2026-09-27)
4
+
5
+ Rotated, overlapping, cropped photos (the collage look), and fixes from the first weeks of use.
6
+
7
+ - `stack { … }` and `layer(top:, right:, bottom:, left:, width:, height:, **box) { … }`: overlapping content. The stack's ordinary children set its height; each layer is a box placed with CSS-inset semantics relative to the stack (points, or fractions of the stack's width/height; negative insets overhang) and takes no flow height. A stack moves to the next page whole. `examples/postcard.rb` shows the collage: a cover photo with two tilted, framed, shadowed photos over its corners.
8
+ - `box(rotate:)` turns a box clockwise around its centre without moving its layout rectangle (a rotated box never splits); `box(shadow: true | { offset:, blur:, color:, opacity: })` paints a soft drop shadow as stacked rounded rectangles at fading opacity, an artifact taking no space; `box(overflow: :hidden)` clips the content to the rounded outline. `column` takes the same options.
9
+ - `image(fit: :cover, width:, height:)` scales up to fill the box and clips the excess around the centre; `image(radius:)` clips to rounded corners; `image(rotate:)` turns the painted image around its centre. Sizing, measuring and the tagged `Figure` bbox are unchanged; plain images are byte-for-byte as before.
10
+ - `Canvas#rotate(degrees, around: [x, y]) { … }` and `Canvas#transform([a, b, c, d, e, f]) { … }` wrap a block in a `cm` transform given in top-left space, clockwise like CSS `rotate()`. Link and form-widget rectangles are annotations in page space, so they stay unrotated.
11
+ - `html`/`markdown` write link annotations only for `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`); anything else (`javascript:`, `data:`, a relative path) keeps its text without a link and is reported as a `Warnings::DroppedLink`. `links: %w[http https]` changes the list, `links: :all` keeps every href from a trusted source.
12
+ - `html`/`markdown` `styles:` take `ul:` and `ol:` (`gap:`, `indent:`, `marker_gap:`, `marker_color:`, plus `style:` for `ul` and `format:`/`suffix:` for `ol`), passed straight to the list elements, so dense documents can tighten the 4pt item gap.
13
+ - Whitespace no font has (an ideographic space U+3000, a figure space U+2007, a narrow no-break space U+202F, thin and hair spaces, …) is drawn as a blank of the character's conventional width instead of `.notdef`, and no longer reports `MissingGlyph`; font fallback keeps every Unicode White_Space character with its neighbours.
14
+ - Testing: the RSpec matchers include `RSpec::Matchers::Composable`, so `have_pdf_text(…).and have_bookmark(…)`, `.or` and use inside `all`/`include` work. `Inspector#lang` reads the catalog `/Lang`; `have_pdf_language("de")` and `assert_pdf_language` assert it.
15
+ - Previews: `Stationery::Preview#around_render(name, params)` wraps both the preview method and `to_pdf`, for an I18n locale or `CurrentAttributes` that must be in effect while the document renders (`?locale=de`); `Preview#to_pdf(name, params, debug:)` is the entry point the previews controller uses.
16
+
17
+ ## 0.4.0 (2026-09-27)
18
+
19
+ Interactive forms and tagged (accessible) PDF.
20
+
21
+ - Tagged PDF: form fields are `Form` structure elements owning their widget annotations (`/OBJR` + `/StructParent`), so screen readers reach them in reading order.
22
+ - Tagged (accessible) PDF: `tagged` at class level or `to_pdf(tagged: true)` writes a structure tree (`/StructTreeRoot` with a `/ParentTree`, `/MarkInfo`, `/StructParents` on pages) and marks every painted leaf as marked content (`/P <</MCID n>> BDC … EMC`). Text is `P`, or `H1`–`H6` with `text(…, heading: 1..6)`; images and SVG drawings are `Figure` with `/Alt` from `alt:` (`alt: false`: decorative) and a `/BBox`; `box(role: :section | :div | :blockquote | :note | :caption | :article | :part)` groups its content. A node split across pages stays one element with marked content on each page. Headers, footers and page templates are `/Pagination` artifacts, other decoration is a layout artifact. `metadata lang:` writes `/Lang` (and is kept out of the document info); a tagged document with a title sets `/DisplayDocTitle`. New warnings `MissingAlt` and `MissingLanguage` let `strict` catch accessibility gaps. Untagged output is byte-for-byte unchanged. `Canvas#tag`, `#structure` and `#artifact`, `Stationery::Tagging::{Tree, Element, Writer}`.
23
+ - Tagged PDF structure for lists (`L` with `/ListNumbering` > `LI` > `Lbl` + `LBody`; drawn bullets are artifacts), tables (`Table` > `TR` > `TH` with `/Scope /Column` or `TD`, `/ColSpan`/`/RowSpan` attributes; one `Table` across pages, repeated header rows as artifacts), links (linked text is a `Link` inside its paragraph with the annotation's `/OBJR`; annotations get `/StructParent`; `box(link:)` groups its content in a `Link`) and `table_of_contents` (`TOC` > `TOCI` > `Link` + `Reference`). `html`/`markdown` tag headings `H1`–`H6` and block quotes. `Canvas#tag_runs`, `Canvas#link(…, tag:)`, `Layout::Flow.new(…, tag:)`, `Tagging::Element.new(…, attributes:)`.
24
+ - Testing tagged PDFs: `Inspector#structure` reads the structure tree as nested arrays with each element's text taken from its marked content (`[[:Document, [[:H1, "Intro"], [:P, ["Read", [:Link, "the docs"], "now"]]]]]`), `Inspector#tagged?` and `#untagged_text`; matchers `have_structure` and `have_tagged_content`, assertions `assert_pdf_structure` and `assert_tagged_content`. `examples/report.rb` is tagged, with headings and a decorative icon. Empty table cells stay in the tree.
25
+ - Interactive forms (AcroForm): `text_field` (multiline, `max_length:`, `comb:`, `read_only:`, `required:`) and `checkbox` (with `label:`) lay out like boxes or sit at `at: [x, y]`, also inside table cells. Each widget ships its own appearance stream (Helvetica / ZapfDingbats from the standard 14, listed in the AcroForm `/DR`), so forms render in every viewer; `NeedAppearances` is set so edits redraw. Dotted names (`"address.city"`) build parent fields; widgets sharing a name become one field's kids. Values are Unicode text strings and survive encryption. `Document#fields` returns `{ name => value }` after a render; `Canvas#widget` places a field widget; `to_pdf(debug: [:field])` outlines fields.
26
+ - Forms: `radio` groups (radios sharing a name form one `/Btn` field with the radio flag; `/V` is the checked value), `select` combo boxes (`/Ch` with `/Opt`, `editable:` adds the Edit flag) and `signature_field` (an empty `/Sig` field drawn as a rule over its label). `examples/form.rb` is a one-page application form using every field type.
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+ - 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.
33
+ - 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`.
34
+ - `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`.
35
+ - **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.
36
+ - 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.
37
+ - 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.
38
+ - 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.
39
+ - 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.
40
+ - 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.
41
+ - 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.
42
+ - 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.
43
+ - `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.
44
+ - `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.
45
+ - Markup decodes all 252 HTML 4 named entities (`&mdash;`, `&euro;`, `&hellip;`, …); the table loads on first use.
46
+ - 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.
47
+ - Internal: `Fonts::GlyphRun` carries per-glyph advance adjustments (Tj/TJ emission) for upcoming kerning and justification; output unchanged.
48
+ - `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.
49
+ - `box(break_inside:)` and `column(break_inside:)`.
50
+ - 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:)`.
51
+ - `keep_with_next` now moves with a following node that avoids breaking inside and would not start on the page.
52
+ - `wrap` element: children at their own widths, wrapping onto rows; splits between rows.
53
+ - `box(link:)` makes the whole box clickable; `box(outset:)` bleeds its background past its edges.
54
+ - `keep_with_next:` accepts a number of points of following content to keep.
55
+ - `group(align:)`.
56
+ - Fixed-width children of a flow (`box(width:)`) are measured, split and paginated at their own width, not the flow's.
57
+ - Layout: `split` takes `fresh:` on every node; a flow passes it on to the child that starts a fresh page.
58
+ - Table cells span columns and rows: `{ content:, colspan:, rowspan: }`, placed as in HTML; a table only splits between rows no rowspan crosses.
59
+ - 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.
60
+ - Table cells accept procs built with the DSL (`-> { image logo }`) and components.
61
+ - 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).
62
+ - `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`.
63
+ - `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.
64
+ - 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`.
65
+ - 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.
66
+ - `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.
67
+ - 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.
68
+ - SVG: elements that cannot be drawn (`text`, `use`, …) are listed in `SVG::Document#unsupported` and reported as an `UnsupportedSvg` warning.
69
+ - 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:)`.
70
+ - `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.
71
+ - An unregistered family name draws with the first registered family (else bundled Inter) and reports an `UnknownFamily` warning once.
72
+ - `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.
73
+ - Strict mode: `to_pdf(strict: true)` or class-level `strict` raises `Stationery::WarningsError` when a render produced warnings.
74
+ - 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.
75
+ - 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.
76
+ - 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.
77
+ - `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.
78
+ - `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.
79
+ - 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`.
80
+ - `rake bench`: reproducible benchmarks against Prawn + prawn-table (one-page invoice, 1,500-row table) and a StackProf profile script under `benchmark/`.
81
+ - 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).
82
+ - `benchmark/profile.rb` profiles in wall mode by default (`MODE=cpu|object`).
83
+ - Fixed: table cells restyled through a selection after the table had been measured kept their old style.
84
+ - Fixed: fonts without an OS/2 v2 table (including every subset) crashed on a nil cap height.
85
+ - `PDF::Writer` raises when a reserved object is never set instead of writing `null`.
86
+ - Gemspec ships every file under `lib/` and `exe/` when built without git, not only `.rb`.
87
+ - PDF writer with Flate streams, per-page resources and link annotations.
88
+ - TrueType fonts: cmap 4/12, glyph subsetting, Type0/CIDFontType2 embedding with ToUnicode, synthetic bold and oblique.
89
+ - JPEG and PNG images, including alpha soft masks and palette transparency.
90
+ - Canvas: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, opacity, text runs, images, links.
91
+ - Text: inline markup, Ruby run builder, wrapping, alignment, leading, truncate and shrink-to-fit.
92
+ - Layout: flow, box, row/column, table, image, rule, spacer, page break, group, canvas escape hatch; pagination with header rows, keep-together and keep-with-next.
93
+ - Components with Phlex's lifecycle; documents with page settings, font families, default text, metadata and page templates.
94
+ - `Stationery::Rails#send_pdf`.
95
+
3
96
  ## 0.3.0 (2026-09-27)
4
97
 
5
98
  The Limitations page, shortened: TrueType collections, WOFF, ligatures, splittable
data/README.md CHANGED
@@ -70,7 +70,7 @@ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
70
70
  InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
71
71
  ```
72
72
 
73
- `examples/` has a complete, runnable invoice, annual report, letter and packing slip; `bundle exec rake examples`
73
+ `examples/` has a complete, runnable invoice, annual report, letter, packing slip, fillable form and postcard collage; `bundle exec rake examples`
74
74
  renders them all, or render one with `stationery render examples/report.rb`.
75
75
 
76
76
  ## Elements
@@ -80,12 +80,14 @@ renders them all, or render one with `stationery render examples/report.rb`.
80
80
  | `text(string, **style)` | A paragraph. Plain strings are literal. |
81
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. |
82
82
  | `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
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). |
83
+ | `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:) { }` | 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). `rotate: -3` turns the painted box around its centre (layout box unchanged, never splits; a `link:` keeps its unrotated rectangle). `shadow: true` or `{ offset: [0, 4], blur: 8, color:, opacity: 0.15 }` paints a soft drop shadow under it, taking no space. `overflow: :hidden` clips the content to the rounded outline. |
84
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
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. |
86
- | `image(path_or_io, width:, height:, fit:, align:)` | JPEG or PNG, aspect preserved. |
86
+ | `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:)` | JPEG or PNG, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. |
87
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
88
  | `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
89
+ | `stack(gap:, align:) { }` | A base with layers painted over it: ordinary children set the height, `layer` children float over them and take no space. Moves to the next page whole. |
90
+ | `layer(top:, right:, bottom:, left:, width:, height:, **box) { }` | Inside `stack`: a box placed by insets from the stack's edges, in points or as a fraction (`0.4`, `1/3r`) of its width/height; negative insets overhang. Takes every box option (`rotate:`, `shadow:`, `radius:`, …). |
89
91
  | `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
92
  | `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
93
  | `li(string, **style)`, `li(gap:) { }` | A list item: a paragraph, or a block of any elements (including nested lists). |
@@ -93,9 +95,14 @@ renders them all, or render one with `stationery render examples/report.rb`.
93
95
  | `group(keep_together: true, align:) { }` | Keep a block on one page. |
94
96
  | `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. |
95
97
  | `text_style(**style) { }` | Default text style for a block. |
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). |
98
+ | `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links; `rotate(degrees, around:) { }` and `transform([a, b, c, d, e, f]) { }` blocks. |
99
+ | `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, border:, background:, radius:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
100
+ | `checkbox(name, checked:, size:, label:, at:)` | An interactive check box, with an optional label drawn to its right. |
101
+ | `radio(name, value, checked:, size:, label:, at:)` | One choice of a radio group: radios sharing `name` form one field whose value is the checked `value`. |
102
+ | `select(name, options:, value:, width:, height:, editable:, at:)` | A drop-down (combo box); `editable: true` also accepts typed values. |
103
+ | `signature_field(name, width:, height:, label:, at:)` | An empty signature field for the signer to fill, drawn as a rule over the label. |
104
+ | `html(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | Rich text from HTML (ActionText/Trix, CMS output): paragraphs, headings, lists, quotes, code, rules, tables, images, inline marks and links. See [HTML and Markdown](#html-and-markdown). |
105
+ | `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:)` | The same from CommonMark (plus GFM tables and strikethrough). |
99
106
 
100
107
  Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
101
108
  `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
@@ -123,7 +130,12 @@ end
123
130
  points; bold, `keep_with_next`), `p`, `a` (colour, underline), `code` (`font:`; register a
124
131
  monospace family for inline and block code, otherwise the text font is used), `pre` and
125
132
  `blockquote` (box options), `hr` (rule options), `table` (`cell:` options, `header:` text style),
126
- `li` (text style) and `img` (`max_width:`).
133
+ `ul` and `ol` (list options: `gap:`, `indent:`, `marker_gap:`, `marker_color:`, plus `style:` for
134
+ `ul` and `format:`/`suffix:` for `ol`), `li` (text style) and `img` (`max_width:`).
135
+ - Links are written only for `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`); anything
136
+ else (`javascript:`, `data:`, a relative path) keeps its text without a link and is reported as a
137
+ `DroppedLink` warning. `links: %w[http https]` changes the list, `links: :all` keeps every href
138
+ from a trusted source.
127
139
  - `gap:` spaces the blocks (default 6); `bookmarks: true` adds h1–h3 to the PDF outline.
128
140
 
129
141
  ### Links, bookmarks and table of contents
@@ -174,6 +186,34 @@ the width of "0000") plus any text style. Numbers are right-aligned in a fixed s
174
186
  pagination, so a long contents list paginates without reflowing. An entry whose target never paints
175
187
  keeps its title, with no number and no link.
176
188
 
189
+ ### Forms
190
+
191
+ Form fields are interactive widgets (an AcroForm) that lay out like boxes, or sit at a fixed page
192
+ position with `at: [x, y]`. They work inside boxes, rows and table cells.
193
+
194
+ ```ruby
195
+ text "Name"
196
+ text_field "applicant.name", value: @applicant.name, required: true
197
+ text_field "applicant.notes", multiline: true, height: 60
198
+ text_field "applicant.pin", comb: 6 # six cells; sets max_length
199
+ select "applicant.country", options: %w[Sweden Norway Denmark], value: "Sweden"
200
+ radio "plan", "basic", label: "Basic" # radios sharing a name form one group
201
+ radio "plan", "pro", checked: true, label: "Pro"
202
+ checkbox "terms", checked: false, label: "I accept the terms"
203
+ signature_field "signature", label: "Signature of the applicant"
204
+ ```
205
+
206
+ - Every widget carries its own appearance (drawn in Helvetica, ZapfDingbats for the check mark), so
207
+ the form looks the same in every viewer; `NeedAppearances` is set too, so viewers redraw edited
208
+ values. Values are Unicode (`/V`); the drawn appearance covers the Windows-1252 range.
209
+ - Dotted names build the field hierarchy viewers show as groups; widgets sharing a name are one field
210
+ with several widgets. A name used as both a field and a group raises `ArgumentError`.
211
+ - `read_only:`, `required:`, `multiline:`, `max_length:` and `comb:` set the matching field flags.
212
+ - A radio group's value is its checked choice's `value` (`Off` when none is checked); a select box
213
+ lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the signer.
214
+ - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
215
+ `false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
216
+
177
217
  ## Components
178
218
 
179
219
  Components have Phlex's lifecycle (`around_template`, `before_template`,
@@ -249,6 +289,60 @@ InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
249
289
  `:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
250
290
  - Every string and stream is encrypted, the document info included.
251
291
 
292
+ ### Accessibility (tagged PDF)
293
+
294
+ ```ruby
295
+ class ReportPdf < Stationery::Document
296
+ tagged # or to_pdf(tagged: true) for one render
297
+ metadata title: "Q3 report", lang: "en-US"
298
+
299
+ def view_template
300
+ text "Quarterly report", size: 20, heading: 1
301
+ text "Revenue grew in every region."
302
+ image "chart.png", width: 300, alt: "Revenue by region, Q1 to Q3"
303
+ box(role: :note, padding: 8) { text "Figures are unaudited." }
304
+ end
305
+ end
306
+ ```
307
+
308
+ A tagged PDF carries a structure tree screen readers and reflowing viewers follow, in paint order
309
+ and across page breaks: a paragraph continued on the next page stays one `P`.
310
+
311
+ - `text` is a `P`; `heading: 1..6` makes it `H1`–`H6`.
312
+ - `image`/`svg` are a `Figure` with `/Alt` from `alt:` and a bounding box; `alt: false` marks one
313
+ decorative (an artifact, not announced).
314
+ - `box(role:)` groups its content: `:section` (`Sect`), `:div`, `:blockquote`, `:note`, `:caption`,
315
+ `:article` (`Art`), `:part`. Boxes without a role, rows, columns, groups and wraps add no element.
316
+ - Lists are `L` (with `/ListNumbering` from the bullet shape or number format) > `LI` > `Lbl` (a text
317
+ marker; drawn bullets are artifacts) and `LBody`.
318
+ - Tables are `Table` > `TR` > `TH` (header rows, `/Scope /Column`) or `TD`, with `/ColSpan` and
319
+ `/RowSpan`. A table split across pages stays one `Table`; its repeated header rows are artifacts.
320
+ - Linked text is a `Link` inside its paragraph holding the link annotation (`/OBJR`, `/StructParent`);
321
+ `box(link:)` groups its content in a `Link`.
322
+ - `table_of_contents` is `TOC` > `TOCI` > `Link` (the title and its annotation) and `Reference` (the
323
+ page number).
324
+ - `html`/`markdown` tag headings `H1`–`H6` (with or without `bookmarks: true`) and block quotes
325
+ `BlockQuote`, and give images their `alt`.
326
+ - Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
327
+ outside any element are layout artifacts.
328
+ - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
329
+ - An image or drawing without `alt:` (`Warnings::MissingAlt`) and a missing `lang`
330
+ (`Warnings::MissingLanguage`) are warnings, so `strict` catches them.
331
+ - Untagged documents (the default) are written exactly as before.
332
+
333
+ Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
334
+
335
+ ```ruby
336
+ expect(ReportPdf.new).to have_tagged_content # tagged, and no text outside marked content
337
+ expect(ReportPdf.new).to have_structure(
338
+ [[:Document, [[:H1, "Quarterly report"], [:P, "Revenue grew in every region."],
339
+ [:Figure, "Revenue by region, Q1 to Q3"], [:Note, [[:P, "Figures are unaudited."]]]]]]
340
+ )
341
+ ```
342
+
343
+ The PDF is not labelled PDF/UA (no XMP `pdfuaid` metadata), and nothing checks colour contrast or
344
+ reading order you build out of positioned boxes (`box(at:)` joins the reading order where it paints).
345
+
252
346
  ### Debugging
253
347
 
254
348
  `to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed),
@@ -306,6 +400,21 @@ that take an argument (`?id=42`); `?debug=1` passes `debug: true` to `to_pdf`
306
400
  when the document supports it. Preview files are re-`load`ed on every request,
307
401
  so edits show up on refresh.
308
402
 
403
+ Anything that must be in effect *while* the document renders (an I18n locale,
404
+ `CurrentAttributes`, a time zone) goes in `around_render`, which wraps both the
405
+ preview method and `to_pdf`:
406
+
407
+ ```ruby
408
+ class FlyerPdfPreview < Stationery::Preview
409
+ def month(params) = FlyerPdf.new(Event.upcoming)
410
+
411
+ def around_render(_name, params) = I18n.with_locale(params.fetch("locale", I18n.default_locale)) { yield }
412
+ end
413
+ ```
414
+
415
+ `/rails/stationery/previews/flyer_pdf/month?locale=de` renders in German.
416
+ `Preview#to_pdf(name, params, debug:)` is the same entry point for your own code.
417
+
309
418
  The gem has no Rails dependency; the Railtie loads only inside a Rails app.
310
419
 
311
420
  ## CLI
@@ -394,9 +503,12 @@ end
394
503
  ```
395
504
 
396
505
  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,
506
+ character before them. Whitespace no font has (an ideographic space U+3000,
507
+ a figure space, a narrow no-break space, …) is drawn as a blank of the
508
+ character's conventional width, never as `.notdef`. Any other glyph no font
509
+ has is drawn as the family's `.notdef` and reported as a
510
+ `Warnings::MissingGlyph` counting each drawn occurrence (so `strict` raises
511
+ on it). Fallback covers every text element,
400
512
  table cell, list marker, table of contents entry and page template text;
401
513
  direct `canvas.text` calls draw with the font they are given.
402
514
 
@@ -447,6 +559,9 @@ RSpec.describe InvoicePdf do
447
559
  it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
448
560
  it { is_expected.to have_image_count(1) }
449
561
  it { is_expected.to have_no_warnings }
562
+ it { is_expected.to have_pdf_language("en") } # the catalog /Lang from `metadata lang:`
563
+ it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
564
+ it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
450
565
  end
451
566
  ```
452
567
 
@@ -465,15 +580,23 @@ class InvoicePdfTest < Minitest::Test
465
580
  assert_page_count pdf, 2
466
581
  assert_pdf_link pdf, /acme\.test/
467
582
  assert_no_pdf_warnings pdf
583
+ assert_pdf_language pdf, "en"
584
+ assert_tagged_content pdf
585
+ assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
468
586
  end
469
587
  end
470
588
  ```
471
589
 
472
590
  The matcher names carry a `pdf_` prefix so they never clash with Capybara's
473
591
  `have_text` and `have_link`. `have_bookmark` / `assert_bookmark` match outline
474
- titles. For anything else, `Stationery::Testing::Inspector.new(subject)`
592
+ titles. The RSpec matchers compose like the built-ins: `.and` / `.or`, and inside
593
+ `all`, `include` or `match`. For anything else, `Stationery::Testing::Inspector.new(subject)`
475
594
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
476
- `image_count`, `bookmarks`, `metadata` and `warnings`.
595
+ `image_count`, `bookmarks`, `metadata`, `lang`, `warnings`, `tagged?`, `untagged_text` and
596
+ `structure` — a tagged PDF's structure tree as nested arrays, each element's text
597
+ read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
598
+ between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
599
+ `Figure` reads as its alt text.
477
600
 
478
601
  ## Why not Prawn, Chrome or Typst?
479
602
 
@@ -505,8 +628,7 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
505
628
 
506
629
  No variable fonts (including CFF2) or
507
630
  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
631
+ (no patterns or masks). A box with a fixed `height:` never splits (use
510
632
  `min_height:` for a floor that can); a row splits only when every column can.
511
633
 
512
634
  ## License
@@ -62,6 +62,17 @@ module Stationery
62
62
  @containers.pop
63
63
  end
64
64
 
65
+ # Whether nodes are being added straight into a stack's flow (where a
66
+ # layer may go).
67
+ def in_stack? = stacks.include?(@containers.last)
68
+
69
+ def stack(flow, &)
70
+ stacks << flow
71
+ within(flow, &)
72
+ ensure
73
+ stacks.delete(flow)
74
+ end
75
+
65
76
  def with_text(**overrides)
66
77
  @text << text_defaults.merge(overrides.compact)
67
78
  yield
@@ -70,6 +81,7 @@ module Stationery
70
81
  end
71
82
 
72
83
  def text_defaults = @text.last
84
+ def stacks = @stacks ||= []
73
85
  def outline = @outline ||= Outline.new
74
86
  def warnings = @book.warnings
75
87
 
@@ -8,7 +8,8 @@ module Stationery
8
8
  module Debug
9
9
  DEBUG_COLORS = {
10
10
  box: "#E11D48", padding: "#E11D48", column: "#2563EB", cell: "#16A34A", cell_padding: "#16A34A",
11
- flow: "#9CA3AF", positioned: "#DB2777", image: "#0D9488", page: "#06B6D4", region: "#0EA5E9"
11
+ flow: "#9CA3AF", positioned: "#DB2777", image: "#0D9488", page: "#06B6D4", region: "#0EA5E9",
12
+ field: "#7C3AED", stack: "#F59E0B"
12
13
  }.freeze
13
14
  DEBUG_DASHES = { padding: [2, 2], cell_padding: [2, 2], flow: [1, 2] }.freeze
14
15
 
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ class Canvas
5
+ # Marked content for tagged PDF. Without a Tagging::Tree every method
6
+ # just yields, so an untagged document's content streams stay as they were.
7
+ module Marking
8
+ def tagging? = !@tagging.nil?
9
+
10
+ # Paints the block as one marked-content sequence of `element` (a fresh
11
+ # MCID on this page), joining the element to the open structure. With no
12
+ # element the block is an artifact. `bbox` is [x, y, w, h] in top-left
13
+ # coordinates, kept as the element's layout bounding box.
14
+ def tag(element, bbox: nil, &)
15
+ return artifact(&) unless element
16
+ return yield unless structure?
17
+
18
+ element.attach(open_element)
19
+ element.place(pdf_box(*bbox)) if bbox
20
+ open_run(element)
21
+ begin
22
+ yield
23
+ ensure
24
+ close_run
25
+ end
26
+ end
27
+
28
+ # Marks what the block paints as consecutive sequences: the block gets a
29
+ # callable, `mark.(element) { … }`, that continues the open sequence when
30
+ # `element` is the one it marks and otherwise starts one for it. A nil
31
+ # element is `parent`; any other joins `parent` as a child (a Link
32
+ # inside its paragraph). Without a parent the block is an artifact.
33
+ def tag_runs(parent, &)
34
+ return artifact { yield PASS } unless parent
35
+ return yield PASS unless structure?
36
+
37
+ parent.attach(open_element)
38
+ current = nil
39
+ yield(lambda do |element = nil, &block|
40
+ element ||= parent
41
+ unless element.equal?(current)
42
+ close_run if current
43
+ element.attach(parent)
44
+ open_run(current = element)
45
+ end
46
+ block.call
47
+ end)
48
+ ensure
49
+ close_run if current
50
+ end
51
+
52
+ # Opens `element` (nil: none) as the parent of the elements painted in
53
+ # the block, without marking any content itself.
54
+ def structure(element)
55
+ return yield unless element && structure?
56
+
57
+ element.attach(open_element)
58
+ open_elements << element
59
+ begin
60
+ yield
61
+ ensure
62
+ open_elements.pop
63
+ end
64
+ end
65
+
66
+ # Paints the block as an artifact: content that is not part of the
67
+ # document's structure. `type:` (:pagination, :layout, :page) and
68
+ # `subtype:` (:header, :footer, :watermark) describe it.
69
+ def artifact(type: nil, subtype: nil, &)
70
+ return yield unless structure?
71
+
72
+ properties = { Type: type && capital(type), Subtype: subtype && capital(subtype) }.compact
73
+ emit(properties.empty? ? "/Artifact BMC" : "/Artifact #{PDF::Serializer.dump(properties)} BDC")
74
+ @marked += 1
75
+ begin
76
+ yield
77
+ ensure
78
+ close_run
79
+ end
80
+ end
81
+
82
+ PASS = ->(_element = nil, &block) { block.call }
83
+
84
+ private
85
+
86
+ # Records an annotation as an /OBJR of `element`, when that element is in the tree.
87
+ # Joins `element` to the open structure with `annotation` as its only
88
+ # content (a form field's widget): no marked content, just the /OBJR.
89
+ def adopt(annotation, element, bbox)
90
+ return unless structure?
91
+
92
+ element.attach(open_element)
93
+ element.place(bbox)
94
+ own(annotation, element)
95
+ end
96
+
97
+ def own(annotation, element)
98
+ return unless @tagging && element.attached?
99
+
100
+ annotation[:tag] = element
101
+ element.kids << Stationery::Tagging::ObjectRef.new(@page, annotation)
102
+ end
103
+
104
+ def open_run(element)
105
+ emit("/#{element.type} <</MCID #{@tagging.mark(@page, element)}>> BDC")
106
+ @marked += 1
107
+ end
108
+
109
+ def close_run
110
+ @marked -= 1
111
+ emit("EMC")
112
+ end
113
+
114
+ def structure? = @tagging && @marked.zero?
115
+ def open_elements = @open_elements ||= []
116
+ def open_element = open_elements.last || @tagging.root
117
+ def capital(value) = value.to_s.capitalize.to_sym
118
+
119
+ def pdf_box(x, y, w, h)
120
+ [x, @page.height - y - h, x + w, @page.height - y].map { |value| num_value(value) }
121
+ end
122
+ end
123
+ end
124
+ end