stationery 0.3.0 → 0.4.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +98 -4
  4. data/lib/stationery/canvas/debug.rb +2 -1
  5. data/lib/stationery/canvas/marking.rb +124 -0
  6. data/lib/stationery/canvas.rb +24 -8
  7. data/lib/stationery/document.rb +28 -10
  8. data/lib/stationery/elements/forms.rb +55 -0
  9. data/lib/stationery/elements/lists.rb +15 -5
  10. data/lib/stationery/elements.rb +10 -7
  11. data/lib/stationery/forms/acro_form.rb +115 -0
  12. data/lib/stationery/forms/appearance.rb +119 -0
  13. data/lib/stationery/forms/field.rb +122 -0
  14. data/lib/stationery/forms/metrics.rb +49 -0
  15. data/lib/stationery/layout/box.rb +11 -5
  16. data/lib/stationery/layout/field.rb +51 -0
  17. data/lib/stationery/layout/flow.rb +17 -9
  18. data/lib/stationery/layout/image.rb +4 -2
  19. data/lib/stationery/layout/list_item.rb +15 -6
  20. data/lib/stationery/layout/node.rb +2 -0
  21. data/lib/stationery/layout/paginator.rb +3 -2
  22. data/lib/stationery/layout/svg.rb +7 -3
  23. data/lib/stationery/layout/table/cell.rb +10 -2
  24. data/lib/stationery/layout/table.rb +42 -10
  25. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  26. data/lib/stationery/layout/table_of_contents.rb +1 -1
  27. data/lib/stationery/layout/text.rb +4 -3
  28. data/lib/stationery/minitest.rb +2 -0
  29. data/lib/stationery/page.rb +5 -2
  30. data/lib/stationery/page_templates.rb +7 -4
  31. data/lib/stationery/pdf/assembler.rb +36 -4
  32. data/lib/stationery/rich/renderer.rb +3 -3
  33. data/lib/stationery/structure.rb +15 -9
  34. data/lib/stationery/tagging/element.rb +74 -0
  35. data/lib/stationery/tagging/tree.rb +43 -0
  36. data/lib/stationery/tagging/writer.rb +81 -0
  37. data/lib/stationery/testing/inspector.rb +15 -0
  38. data/lib/stationery/testing/marked_text.rb +60 -0
  39. data/lib/stationery/testing/matchers.rb +25 -0
  40. data/lib/stationery/testing/structure_reader.rb +71 -0
  41. data/lib/stationery/text/paragraph.rb +27 -12
  42. data/lib/stationery/version.rb +1 -1
  43. data/lib/stationery/warnings.rb +8 -0
  44. data/lib/stationery.rb +10 -0
  45. metadata +13 -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: dc2011ee51b715eb946943e60d0512157f2215acd871133433c6edf4f81bb9a9
4
+ data.tar.gz: a5a9c654b4c415ab77ba005eb228ba67fae05f87af1adeb39871ccb3353a00a3
5
5
  SHA512:
6
- metadata.gz: b3a4a85b185b0f06e195a15f81773c86d79086e32e923b1c21c2d39ee528375fd5b75be1581fc3d8180e530071d653939674804c973896d0edb3b57fffbde9fa
7
- data.tar.gz: fd368b63884a129cd0e6f03507da714891c79b81b6a1aeadde9cfeafc4987824d7cd525c2f86aa3eba397f432a14516429bc697b61177056a790a602d85369ae
6
+ metadata.gz: 239ad178900f1cce55589e1e6882fda2e713631df0a0d732945eefd0d8df762e9baa0a44efde7ea13fa643e6ee0dd038594886cd09cc14aac2cb0486ef88f05b
7
+ data.tar.gz: 0ac757cdf6a15a654c83b26fa80c5be99e20e9e28e09653c31c299627a5c46c5cfb3965cfa58e58c2c6ade241c3b86f07fbe37a761ad2738e09207ce0711c200
data/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-09-27)
4
+
5
+ Interactive forms and tagged (accessible) PDF.
6
+
7
+ - Tagged PDF: form fields are `Form` structure elements owning their widget annotations (`/OBJR` + `/StructParent`), so screen readers reach them in reading order.
8
+ - 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}`.
9
+ - 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:)`.
10
+ - 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.
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+ - 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.
17
+ - 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.
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
+ - `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`.
21
+ - **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.
22
+ - 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.
23
+ - 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.
24
+ - 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.
25
+ - 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.
26
+ - 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.
27
+ - 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.
28
+ - 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.
29
+ - `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.
30
+ - `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.
31
+ - Markup decodes all 252 HTML 4 named entities (`&mdash;`, `&euro;`, `&hellip;`, …); the table loads on first use.
32
+ - 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.
33
+ - Internal: `Fonts::GlyphRun` carries per-glyph advance adjustments (Tj/TJ emission) for upcoming kerning and justification; output unchanged.
34
+ - `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.
35
+ - `box(break_inside:)` and `column(break_inside:)`.
36
+ - 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:)`.
37
+ - `keep_with_next` now moves with a following node that avoids breaking inside and would not start on the page.
38
+ - `wrap` element: children at their own widths, wrapping onto rows; splits between rows.
39
+ - `box(link:)` makes the whole box clickable; `box(outset:)` bleeds its background past its edges.
40
+ - `keep_with_next:` accepts a number of points of following content to keep.
41
+ - `group(align:)`.
42
+ - Fixed-width children of a flow (`box(width:)`) are measured, split and paginated at their own width, not the flow's.
43
+ - Layout: `split` takes `fresh:` on every node; a flow passes it on to the child that starts a fresh page.
44
+ - Table cells span columns and rows: `{ content:, colspan:, rowspan: }`, placed as in HTML; a table only splits between rows no rowspan crosses.
45
+ - 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.
46
+ - Table cells accept procs built with the DSL (`-> { image logo }`) and components.
47
+ - 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).
48
+ - `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`.
49
+ - `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.
50
+ - 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`.
51
+ - 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.
52
+ - `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.
53
+ - 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.
54
+ - SVG: elements that cannot be drawn (`text`, `use`, …) are listed in `SVG::Document#unsupported` and reported as an `UnsupportedSvg` warning.
55
+ - 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:)`.
56
+ - `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.
57
+ - An unregistered family name draws with the first registered family (else bundled Inter) and reports an `UnknownFamily` warning once.
58
+ - `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.
59
+ - Strict mode: `to_pdf(strict: true)` or class-level `strict` raises `Stationery::WarningsError` when a render produced warnings.
60
+ - 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.
61
+ - 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.
62
+ - 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.
63
+ - `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.
64
+ - `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.
65
+ - 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`.
66
+ - `rake bench`: reproducible benchmarks against Prawn + prawn-table (one-page invoice, 1,500-row table) and a StackProf profile script under `benchmark/`.
67
+ - 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).
68
+ - `benchmark/profile.rb` profiles in wall mode by default (`MODE=cpu|object`).
69
+ - Fixed: table cells restyled through a selection after the table had been measured kept their old style.
70
+ - Fixed: fonts without an OS/2 v2 table (including every subset) crashed on a nil cap height.
71
+ - `PDF::Writer` raises when a reserved object is never set instead of writing `null`.
72
+ - Gemspec ships every file under `lib/` and `exe/` when built without git, not only `.rb`.
73
+ - PDF writer with Flate streams, per-page resources and link annotations.
74
+ - TrueType fonts: cmap 4/12, glyph subsetting, Type0/CIDFontType2 embedding with ToUnicode, synthetic bold and oblique.
75
+ - JPEG and PNG images, including alpha soft masks and palette transparency.
76
+ - Canvas: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, opacity, text runs, images, links.
77
+ - Text: inline markup, Ruby run builder, wrapping, alignment, leading, truncate and shrink-to-fit.
78
+ - 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.
79
+ - Components with Phlex's lifecycle; documents with page settings, font families, default text, metadata and page templates.
80
+ - `Stationery::Rails#send_pdf`.
81
+
3
82
  ## 0.3.0 (2026-09-27)
4
83
 
5
84
  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 and fillable form; `bundle exec rake examples`
74
74
  renders them all, or render one with `stationery render examples/report.rb`.
75
75
 
76
76
  ## Elements
@@ -94,6 +94,11 @@ renders them all, or render one with `stationery render examples/report.rb`.
94
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. |
95
95
  | `text_style(**style) { }` | Default text style for a block. |
96
96
  | `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links. |
97
+ | `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). |
98
+ | `checkbox(name, checked:, size:, label:, at:)` | An interactive check box, with an optional label drawn to its right. |
99
+ | `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`. |
100
+ | `select(name, options:, value:, width:, height:, editable:, at:)` | A drop-down (combo box); `editable: true` also accepts typed values. |
101
+ | `signature_field(name, width:, height:, label:, at:)` | An empty signature field for the signer to fill, drawn as a rule over the label. |
97
102
  | `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
103
  | `markdown(source, styles:, gap:, images:, base_path:, bookmarks:)` | The same from CommonMark (plus GFM tables and strikethrough). |
99
104
 
@@ -174,6 +179,34 @@ the width of "0000") plus any text style. Numbers are right-aligned in a fixed s
174
179
  pagination, so a long contents list paginates without reflowing. An entry whose target never paints
175
180
  keeps its title, with no number and no link.
176
181
 
182
+ ### Forms
183
+
184
+ Form fields are interactive widgets (an AcroForm) that lay out like boxes, or sit at a fixed page
185
+ position with `at: [x, y]`. They work inside boxes, rows and table cells.
186
+
187
+ ```ruby
188
+ text "Name"
189
+ text_field "applicant.name", value: @applicant.name, required: true
190
+ text_field "applicant.notes", multiline: true, height: 60
191
+ text_field "applicant.pin", comb: 6 # six cells; sets max_length
192
+ select "applicant.country", options: %w[Sweden Norway Denmark], value: "Sweden"
193
+ radio "plan", "basic", label: "Basic" # radios sharing a name form one group
194
+ radio "plan", "pro", checked: true, label: "Pro"
195
+ checkbox "terms", checked: false, label: "I accept the terms"
196
+ signature_field "signature", label: "Signature of the applicant"
197
+ ```
198
+
199
+ - Every widget carries its own appearance (drawn in Helvetica, ZapfDingbats for the check mark), so
200
+ the form looks the same in every viewer; `NeedAppearances` is set too, so viewers redraw edited
201
+ values. Values are Unicode (`/V`); the drawn appearance covers the Windows-1252 range.
202
+ - Dotted names build the field hierarchy viewers show as groups; widgets sharing a name are one field
203
+ with several widgets. A name used as both a field and a group raises `ArgumentError`.
204
+ - `read_only:`, `required:`, `multiline:`, `max_length:` and `comb:` set the matching field flags.
205
+ - A radio group's value is its checked choice's `value` (`Off` when none is checked); a select box
206
+ lists its `options:` and draws the chosen `value`; a signature field is left unsigned for the signer.
207
+ - `document.fields` returns `{ name => value }` for the last render (a check box's value is `true` or
208
+ `false`, an unchecked radio group's and a signature field's `nil`). Encrypted documents keep their fields fillable.
209
+
177
210
  ## Components
178
211
 
179
212
  Components have Phlex's lifecycle (`around_template`, `before_template`,
@@ -249,6 +282,60 @@ InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
249
282
  `:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
250
283
  - Every string and stream is encrypted, the document info included.
251
284
 
285
+ ### Accessibility (tagged PDF)
286
+
287
+ ```ruby
288
+ class ReportPdf < Stationery::Document
289
+ tagged # or to_pdf(tagged: true) for one render
290
+ metadata title: "Q3 report", lang: "en-US"
291
+
292
+ def view_template
293
+ text "Quarterly report", size: 20, heading: 1
294
+ text "Revenue grew in every region."
295
+ image "chart.png", width: 300, alt: "Revenue by region, Q1 to Q3"
296
+ box(role: :note, padding: 8) { text "Figures are unaudited." }
297
+ end
298
+ end
299
+ ```
300
+
301
+ A tagged PDF carries a structure tree screen readers and reflowing viewers follow, in paint order
302
+ and across page breaks: a paragraph continued on the next page stays one `P`.
303
+
304
+ - `text` is a `P`; `heading: 1..6` makes it `H1`–`H6`.
305
+ - `image`/`svg` are a `Figure` with `/Alt` from `alt:` and a bounding box; `alt: false` marks one
306
+ decorative (an artifact, not announced).
307
+ - `box(role:)` groups its content: `:section` (`Sect`), `:div`, `:blockquote`, `:note`, `:caption`,
308
+ `:article` (`Art`), `:part`. Boxes without a role, rows, columns, groups and wraps add no element.
309
+ - Lists are `L` (with `/ListNumbering` from the bullet shape or number format) > `LI` > `Lbl` (a text
310
+ marker; drawn bullets are artifacts) and `LBody`.
311
+ - Tables are `Table` > `TR` > `TH` (header rows, `/Scope /Column`) or `TD`, with `/ColSpan` and
312
+ `/RowSpan`. A table split across pages stays one `Table`; its repeated header rows are artifacts.
313
+ - Linked text is a `Link` inside its paragraph holding the link annotation (`/OBJR`, `/StructParent`);
314
+ `box(link:)` groups its content in a `Link`.
315
+ - `table_of_contents` is `TOC` > `TOCI` > `Link` (the title and its annotation) and `Reference` (the
316
+ page number).
317
+ - `html`/`markdown` tag headings `H1`–`H6` (with or without `bookmarks: true`) and block quotes
318
+ `BlockQuote`, and give images their `alt`.
319
+ - Headers, footers and page templates are pagination artifacts; backgrounds, borders and rules drawn
320
+ outside any element are layout artifacts.
321
+ - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name.
322
+ - An image or drawing without `alt:` (`Warnings::MissingAlt`) and a missing `lang`
323
+ (`Warnings::MissingLanguage`) are warnings, so `strict` catches them.
324
+ - Untagged documents (the default) are written exactly as before.
325
+
326
+ Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)):
327
+
328
+ ```ruby
329
+ expect(ReportPdf.new).to have_tagged_content # tagged, and no text outside marked content
330
+ expect(ReportPdf.new).to have_structure(
331
+ [[:Document, [[:H1, "Quarterly report"], [:P, "Revenue grew in every region."],
332
+ [:Figure, "Revenue by region, Q1 to Q3"], [:Note, [[:P, "Figures are unaudited."]]]]]]
333
+ )
334
+ ```
335
+
336
+ The PDF is not labelled PDF/UA (no XMP `pdfuaid` metadata), and nothing checks colour contrast or
337
+ reading order you build out of positioned boxes (`box(at:)` joins the reading order where it paints).
338
+
252
339
  ### Debugging
253
340
 
254
341
  `to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed),
@@ -447,6 +534,8 @@ RSpec.describe InvoicePdf do
447
534
  it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
448
535
  it { is_expected.to have_image_count(1) }
449
536
  it { is_expected.to have_no_warnings }
537
+ it { is_expected.to have_tagged_content } # a tagged PDF with every text tagged or an artifact
538
+ it { is_expected.to have_structure([[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]) }
450
539
  end
451
540
  ```
452
541
 
@@ -465,6 +554,8 @@ class InvoicePdfTest < Minitest::Test
465
554
  assert_page_count pdf, 2
466
555
  assert_pdf_link pdf, /acme\.test/
467
556
  assert_no_pdf_warnings pdf
557
+ assert_tagged_content pdf
558
+ assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
468
559
  end
469
560
  end
470
561
  ```
@@ -473,7 +564,11 @@ The matcher names carry a `pdf_` prefix so they never clash with Capybara's
473
564
  `have_text` and `have_link`. `have_bookmark` / `assert_bookmark` match outline
474
565
  titles. For anything else, `Stationery::Testing::Inspector.new(subject)`
475
566
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
476
- `image_count`, `bookmarks`, `metadata` and `warnings`.
567
+ `image_count`, `bookmarks`, `metadata`, `warnings`, `tagged?`, `untagged_text` and
568
+ `structure` — a tagged PDF's structure tree as nested arrays, each element's text
569
+ read from its marked content: `[type, "text"]`, `[type, [children]]` (its own text
570
+ between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
571
+ `Figure` reads as its alt text.
477
572
 
478
573
  ## Why not Prawn, Chrome or Typst?
479
574
 
@@ -505,8 +600,7 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
505
600
 
506
601
  No variable fonts (including CFF2) or
507
602
  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
603
+ (no patterns or masks). A box with a fixed `height:` never splits (use
510
604
  `min_height:` for a floor that can); a row splits only when every column can.
511
605
 
512
606
  ## License
@@ -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"
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
@@ -7,6 +7,7 @@ module Stationery
7
7
  class Canvas
8
8
  include Text
9
9
  include Debug
10
+ include Marking
10
11
 
11
12
  CAPS = { butt: 0, round: 1, square: 2 }.freeze
12
13
  JOINS = { miter: 0, round: 1, bevel: 2 }.freeze
@@ -14,11 +15,14 @@ module Stationery
14
15
  attr_reader :page
15
16
 
16
17
  # `template: true` records anchors apart, for canvases page templates draw on.
17
- def initialize(page, resources, template: false, debug: false)
18
+ # `tagging:` (a Tagging::Tree) marks content for a tagged PDF.
19
+ def initialize(page, resources, template: false, debug: false, tagging: nil)
18
20
  @page = page
19
21
  @resources = resources
20
22
  @template = template
21
23
  @debug = debug
24
+ @tagging = tagging
25
+ @marked = 0
22
26
  end
23
27
 
24
28
  def save
@@ -81,11 +85,22 @@ module Stationery
81
85
 
82
86
  # A clickable area opening `target`: a URL, or `#name` for an anchor in
83
87
  # this document. Annotation rectangles live in absolute, untransformed
84
- # page space, so they ignore clips and path transforms.
85
- def link(x, y, w, h, target)
88
+ # page space, so they ignore clips and path transforms. `tag:` is the
89
+ # Link element the annotation belongs to in a tagged PDF.
90
+ def link(x, y, w, h, target, tag: nil)
86
91
  target = target.to_s
87
92
  rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
88
- @page.annotations << (target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target })
93
+ annotation = target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target }
94
+ @page.annotations << annotation
95
+ own(annotation, tag) if tag
96
+ end
97
+
98
+ # An interactive form field's widget (a Forms::Field) over the rectangle.
99
+ def widget(field, x, y, w, h, tag: nil)
100
+ rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
101
+ annotation = { rect:, widget: field }
102
+ @page.annotations << annotation
103
+ adopt(annotation, tag, rect) if tag
89
104
  end
90
105
 
91
106
  # Names the point `y` on this page as a link target.
@@ -94,9 +109,10 @@ module Stationery
94
109
  end
95
110
 
96
111
  # Leaves room for the page number `anchor` lands on; Structure fills it in
97
- # and adds the `link:` area ([x, y, w, h]) when the anchor exists.
98
- def number_slot(anchor, x:, baseline:, width:, style:, link: nil)
99
- @page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link)
112
+ # and adds the `link:` area ([x, y, w, h]) when the anchor exists. `tags:`
113
+ # are the [link, number] elements they belong to in a tagged PDF.
114
+ def number_slot(anchor, x:, baseline:, width:, style:, link: nil, tags: nil)
115
+ @page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link, tags)
100
116
  end
101
117
 
102
118
  def num(value) = PDF::Serializer.number(num_value(value))
@@ -141,7 +157,7 @@ module Stationery
141
157
  ops = []
142
158
  ops << "/#{@page.use(:ExtGState, @resources.opacity(opacity))} gs" if opacity && opacity < 1
143
159
  yield ops
144
- emit("q", *ops, "Q")
160
+ artifact { emit("q", *ops, "Q") }
145
161
  end
146
162
 
147
163
  def emit(*ops)
@@ -24,7 +24,7 @@ module Stationery
24
24
  superclass.config.transform_values(&:dup)
25
25
  else
26
26
  { page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
27
- templates: [], regions: [], strict: false }
27
+ templates: [], regions: [], strict: false, tagged: false }
28
28
  end
29
29
  end
30
30
 
@@ -55,6 +55,13 @@ module Stationery
55
55
  config[:strict] = value
56
56
  end
57
57
 
58
+ # Writes a tagged (accessible) PDF: a structure tree of headings,
59
+ # paragraphs and figures, and headers and footers marked as artifacts.
60
+ # Set `metadata lang:` and give images `alt:` text.
61
+ def tagged(value = true) # rubocop:disable Style/OptionalBooleanParameter
62
+ config[:tagged] = value
63
+ end
64
+
58
65
  # Encrypts every render with the standard security handler; see
59
66
  # PDF::Encryption::StandardSecurity for the options.
60
67
  def encrypt(**)
@@ -80,26 +87,29 @@ module Stationery
80
87
  end
81
88
  end
82
89
 
83
- attr_reader :warnings
90
+ # `fields` is every form field's name and value from the last render.
91
+ attr_reader :warnings, :fields
84
92
 
85
93
  def page_options = self.class.config[:page]
86
94
  def metadata = self.class.config[:metadata]
87
95
 
88
- def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt])
96
+ def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
97
+ tagged: self.class.config[:tagged])
89
98
  encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
99
+ tagging = Tagging::Tree.new if tagged
90
100
  warnings = Warnings.new
91
101
  book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
92
102
  call(builder = Builder.new(book:, text: self.class.config[:text]))
93
103
  resources = Resources.new
94
- regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
95
- paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:)
96
- pages = paginator.paginate(builder.root)
97
- PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:).apply(pages)
98
- outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:))
104
+ pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
105
+ outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
106
+ tagging&.audit(pages, warnings, lang: metadata[:lang])
99
107
  @warnings = warnings
108
+ @fields = Forms::AcroForm.values(pages)
100
109
  raise WarningsError, warnings if strict && warnings.any?
101
110
 
102
- write(PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:).render, target)
111
+ assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, lang: metadata[:lang])
112
+ write(assembler.render, target)
103
113
  end
104
114
 
105
115
  # Used by page templates to build nodes into their own root.
@@ -120,6 +130,14 @@ module Stationery
120
130
 
121
131
  private
122
132
 
133
+ def paginate(root, book:, resources:, warnings:, debug:, tagging:)
134
+ regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
135
+ paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:, tagging:)
136
+ paginator.paginate(root).tap do |pages|
137
+ PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:, tagging:).apply(pages)
138
+ end
139
+ end
140
+
123
141
  def region_measure(book)
124
142
  page = Page.new(**page_options)
125
143
  lambda do |region, number|
@@ -129,7 +147,7 @@ module Stationery
129
147
  end
130
148
 
131
149
  def info
132
- metadata.to_h do |key, value|
150
+ metadata.except(:lang).to_h do |key, value|
133
151
  [INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
134
152
  end
135
153
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Interactive form fields. Each takes a name (dotted names group fields,
5
+ # "address.city") and lays out like a box, or at a fixed page position with
6
+ # `at: [x, y]`.
7
+ module Elements
8
+ # A text input. `width:` is :full or points; `multiline:`, `max_length:`,
9
+ # `comb:` (a cell count, or true with max_length:), `read_only:`,
10
+ # `required:`, `font_size:`, `border:`, `background:` and `radius:`.
11
+ def text_field(name, value: "", width: :full, height: 22, at: nil, **)
12
+ field_node(Forms::Field.new(:text, name, value: value.to_s, **), width:, height:, at:)
13
+ end
14
+
15
+ # A check box `size` points square, with an optional label to its right.
16
+ def checkbox(name, checked: false, size: 12, label: nil, at: nil, **)
17
+ field = Forms::Field.new(:checkbox, name, value: checked == true, **)
18
+ field_node(field, width: size, height: size, at:, label:)
19
+ end
20
+
21
+ # One choice of the radio group `name`; its `value` becomes the group's
22
+ # value when checked.
23
+ def radio(name, value, checked: false, size: 12, label: nil, at: nil, **)
24
+ field = Forms::Field.new(:radio, name, value: value.to_s, checked:, **)
25
+ field_node(field, width: size, height: size, at:, label:)
26
+ end
27
+
28
+ # A drop-down (combo box) of `options`; `editable: true` also accepts
29
+ # typed values.
30
+ def select(name, options:, value: nil, width: :full, height: 22, at: nil, **)
31
+ field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s), **)
32
+ field_node(field, width:, height:, at:)
33
+ end
34
+
35
+ # An empty signature field for the signer to fill, drawn as a rule over
36
+ # the label.
37
+ def signature_field(name, width: :full, height: 40, label: "Signature", at: nil, **)
38
+ field = Forms::Field.new(:signature, name, label: label.to_s, **)
39
+ field_node(field, width:, height:, at:)
40
+ end
41
+
42
+ private
43
+
44
+ def field_node(field, width:, height:, at:, label: nil)
45
+ label &&= field_label(label)
46
+ node = Layout::Field.new(field, height:, width:, label:)
47
+ @_builder.add(at ? Layout::Positioned.new(node, x: at[0], y: at[1]) : node)
48
+ end
49
+
50
+ def field_label(label)
51
+ style = @_builder.style
52
+ Layout::Text.new([Text::Run.new(label.to_s, style)], context: @_builder.context(style))
53
+ end
54
+ end
55
+ end