stationery 0.2.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/README.md +143 -12
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas/text.rb +5 -4
  8. data/lib/stationery/canvas.rb +37 -8
  9. data/lib/stationery/document.rb +35 -10
  10. data/lib/stationery/elements/forms.rb +55 -0
  11. data/lib/stationery/elements/lists.rb +15 -5
  12. data/lib/stationery/elements.rb +10 -7
  13. data/lib/stationery/fonts/font.rb +36 -20
  14. data/lib/stationery/fonts/font_book.rb +3 -0
  15. data/lib/stationery/fonts/glyph_run.rb +4 -3
  16. data/lib/stationery/fonts/gpos.rb +11 -9
  17. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  18. data/lib/stationery/fonts/gsub.rb +65 -0
  19. data/lib/stationery/fonts/ligatures.rb +16 -0
  20. data/lib/stationery/fonts/registry.rb +18 -9
  21. data/lib/stationery/fonts/to_unicode.rb +1 -1
  22. data/lib/stationery/fonts/true_type.rb +36 -12
  23. data/lib/stationery/fonts/woff.rb +52 -0
  24. data/lib/stationery/forms/acro_form.rb +115 -0
  25. data/lib/stationery/forms/appearance.rb +119 -0
  26. data/lib/stationery/forms/field.rb +122 -0
  27. data/lib/stationery/forms/metrics.rb +49 -0
  28. data/lib/stationery/layout/box.rb +34 -11
  29. data/lib/stationery/layout/field.rb +51 -0
  30. data/lib/stationery/layout/flow.rb +17 -9
  31. data/lib/stationery/layout/image.rb +4 -2
  32. data/lib/stationery/layout/list_item.rb +15 -6
  33. data/lib/stationery/layout/node.rb +2 -0
  34. data/lib/stationery/layout/paginator.rb +3 -2
  35. data/lib/stationery/layout/row.rb +1 -1
  36. data/lib/stationery/layout/svg.rb +9 -2
  37. data/lib/stationery/layout/table/cell.rb +10 -2
  38. data/lib/stationery/layout/table.rb +42 -10
  39. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  40. data/lib/stationery/layout/table_of_contents.rb +1 -1
  41. data/lib/stationery/layout/text.rb +6 -4
  42. data/lib/stationery/minitest.rb +2 -0
  43. data/lib/stationery/page.rb +5 -2
  44. data/lib/stationery/page_templates.rb +7 -4
  45. data/lib/stationery/pdf/assembler.rb +38 -5
  46. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  47. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  48. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  49. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  50. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  51. data/lib/stationery/pdf/serializer.rb +17 -9
  52. data/lib/stationery/pdf/writer.rb +24 -5
  53. data/lib/stationery/resources.rb +8 -2
  54. data/lib/stationery/rich/renderer.rb +3 -3
  55. data/lib/stationery/structure.rb +15 -9
  56. data/lib/stationery/svg/bounds.rb +76 -0
  57. data/lib/stationery/svg/document.rb +48 -18
  58. data/lib/stationery/svg/gradient.rb +115 -0
  59. data/lib/stationery/svg/painter.rb +70 -0
  60. data/lib/stationery/svg/parser.rb +24 -4
  61. data/lib/stationery/svg/selector.rb +37 -0
  62. data/lib/stationery/svg/shading.rb +34 -0
  63. data/lib/stationery/svg/style.rb +37 -20
  64. data/lib/stationery/svg/stylesheet.rb +47 -0
  65. data/lib/stationery/svg/text.rb +122 -0
  66. data/lib/stationery/svg/transform.rb +11 -0
  67. data/lib/stationery/tagging/element.rb +74 -0
  68. data/lib/stationery/tagging/tree.rb +43 -0
  69. data/lib/stationery/tagging/writer.rb +81 -0
  70. data/lib/stationery/testing/inspector.rb +15 -0
  71. data/lib/stationery/testing/marked_text.rb +60 -0
  72. data/lib/stationery/testing/matchers.rb +25 -0
  73. data/lib/stationery/testing/structure_reader.rb +71 -0
  74. data/lib/stationery/text/paragraph.rb +28 -12
  75. data/lib/stationery/text/style.rb +4 -2
  76. data/lib/stationery/text/wrapper.rb +2 -1
  77. data/lib/stationery/version.rb +1 -1
  78. data/lib/stationery/warnings.rb +8 -0
  79. data/lib/stationery.rb +26 -0
  80. metadata +29 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dabc4e22462e7af56f79f46c814ebb908ee12961948190ae2b73f2f8951478f7
4
- data.tar.gz: 9ef6ee24bfa601829d8b51fb514254a55a9470afd98be1429b2935984f6605df
3
+ metadata.gz: dc2011ee51b715eb946943e60d0512157f2215acd871133433c6edf4f81bb9a9
4
+ data.tar.gz: a5a9c654b4c415ab77ba005eb228ba67fae05f87af1adeb39871ccb3353a00a3
5
5
  SHA512:
6
- metadata.gz: 1e051af6e9e75e979f763b5bd47eed272fef23d754d1e11116abc890669679c71eee3a2a615305ef63c59f6c626ea3567eeba76a2a9407e81c929626e6ec6139
7
- data.tar.gz: d7c7ffab364d4502a568c6496c10413b189bebf2003ffc433e014960fc29fe7cc948097eff9fd292e9e6108aeb5cc51c42ffdba5da18e71d99f2e951b94cfb26
6
+ metadata.gz: 239ad178900f1cce55589e1e6882fda2e713631df0a0d732945eefd0d8df762e9baa0a44efde7ea13fa643e6ee0dd038594886cd09cc14aac2cb0486ef88f05b
7
+ data.tar.gz: 0ac757cdf6a15a654c83b26fa80c5be99e20e9e28e09653c31c299627a5c46c5cfb3965cfa58e58c2c6ade241c3b86f07fbe37a761ad2738e09207ce0711c200
data/CHANGELOG.md CHANGED
@@ -1,6 +1,105 @@
1
1
  # Changelog
2
2
 
3
- ## 0.2.0 (unreleased)
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
+
82
+ ## 0.3.0 (2026-09-27)
83
+
84
+ The Limitations page, shortened: TrueType collections, WOFF, ligatures, splittable
85
+ `min_height:` boxes, SVG gradients, text and stylesheets, and encryption.
86
+
87
+ - 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.
88
+ - 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.
89
+ - 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.
90
+
91
+ - 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.
92
+
93
+ - 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.
94
+
95
+ ### Fonts
96
+
97
+ - 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.
98
+ - 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`.
99
+
100
+ - `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`.
101
+
102
+ ## 0.2.0 (2026-09-27)
4
103
 
5
104
  Everything from the three planned milestones ("works out of the box", "typography and layout",
6
105
  "documents, Rails and testing") shipped together.
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
@@ -80,11 +80,11 @@ 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:, 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. `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). |
84
- | `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits. |
83
+ | `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). |
84
+ | `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. |
85
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
86
  | `image(path_or_io, width:, height:, fit:, align:)` | JPEG or PNG, aspect preserved. |
87
- | `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:`. |
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
89
  | `ul(style:, gap:, indent:, marker_gap:, marker_color:) { li "…" }` | Bulleted list. `style:` is `:disc`, `:circle`, `:square`, `:dash` or any String; unstyled nested lists cycle disc → circle → square. Any node added inside (not only `li`) becomes an item. Items split across pages; the marker stays with the first line. |
90
90
  | `ol(format:, start:, suffix:, gap:, indent:, marker_gap:, marker_color:) { }` | Numbered list. `format:` is `:decimal`, `:alpha`, `:upper_alpha`, `:roman`, `:upper_roman` or a Proc `(n) -> String`; `suffix:` defaults to `"."`. Markers right-align so bodies line up. |
@@ -94,11 +94,16 @@ 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
 
100
105
  Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
101
- `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
106
+ `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`.
102
107
  `align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines
103
108
  ending in a newline and lines without spaces stay left-aligned (tabs are never stretched).
104
109
  Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
@@ -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`,
@@ -228,6 +261,81 @@ render Callout.new(color: "#F3F4F6") { text "Amount due" }
228
261
  (with `#warnings`) instead of writing a PDF that produced any; `to_pdf(strict: false)` opts one
229
262
  render out again.
230
263
 
264
+ ### Encryption
265
+
266
+ ```ruby
267
+ class InvoicePdf < Stationery::Document
268
+ encrypt owner_password: "s3cret", permissions: [:print] # every render
269
+ end
270
+
271
+ InvoicePdf.new(invoice).to_pdf(encrypt: { user_password: "1234", owner_password: "s3cret",
272
+ permissions: %i[print copy] }) # override for one render
273
+ InvoicePdf.new(invoice).to_pdf(encrypt: nil) # plain
274
+ ```
275
+
276
+ - `owner_password:` is required (`ArgumentError` when missing or empty); it opens the file with every
277
+ right. `user_password:` defaults to `""`: the file opens without a prompt, but viewers enforce the
278
+ permissions.
279
+ - `permissions:` is a subset of `%i[print modify copy annotate fill_forms extract_accessible assemble
280
+ print_high]` (default: all).
281
+ - `algorithm:` picks the standard security handler. `:aes_256` (default, PDF 2.0 / Acrobat X+);
282
+ `:aes_128` for older viewers; `:rc4_128` only for legacy readers that need it.
283
+ - Every string and stream is encrypted, the document info included.
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
+
231
339
  ### Debugging
232
340
 
233
341
  `to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed),
@@ -317,7 +425,14 @@ already present are kept unless `--force`. `--from` takes a directory or a
317
425
  ## Fonts and images
318
426
 
319
427
  Fonts are TrueType (`.ttf`) or OpenType/CFF (`.otf`, name-keyed or
320
- CID-keyed) files. Only the glyphs a document uses are embedded (a CFF font
428
+ CID-keyed) files, WOFF 1.0 web fonts (`.woff`, unwrapped in memory), or
429
+ faces of a TrueType collection (`.ttc`): a `#N` suffix
430
+ on the path picks face N, counted from 0 (face 0 without a suffix):
431
+
432
+ ```ruby
433
+ font_family "Brand", regular: "Brand.ttc#0", bold: "Brand.ttc#2"
434
+ ```
435
+ Only the glyphs a document uses are embedded (a CFF font
321
436
  keeps its glyph numbering and subroutines; unused glyphs are blanked), with a
322
437
  ToUnicode map so text copies and searches correctly. A style without its own
323
438
  file (bold, italic) is synthesised.
@@ -379,6 +494,15 @@ turns it off). Pairs that straddle a style or
379
494
  font change are not kerned. Kerning only tightens in practice, so a kerned
380
495
  line is never wider than the same line unkerned.
381
496
 
497
+ Standard ligatures (fi, fl, ffi, …) come from the font's GSUB `liga` feature
498
+ (LigatureSubst lookups, also behind Extension lookups) and are on by default;
499
+ `ligatures: false` on an element or in `default_text` turns them off. Only
500
+ `liga` applies, not `clig` or `dlig`. Ligatures form within a run of one
501
+ style and font, never across a line break, and letter spacing turns them off.
502
+ The PDF's ToUnicode map sends a ligature glyph back to all of its
503
+ characters, so copied and extracted text still reads "office". Fonts
504
+ without a `liga` feature (such as the bundled Inter) are unaffected.
505
+
382
506
  Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
383
507
  mask). Parsed fonts and images are cached per process.
384
508
 
@@ -410,6 +534,8 @@ RSpec.describe InvoicePdf do
410
534
  it { is_expected.to have_pdf_link("mailto:hello@acme.test") }
411
535
  it { is_expected.to have_image_count(1) }
412
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"]]]]) }
413
539
  end
414
540
  ```
415
541
 
@@ -428,6 +554,8 @@ class InvoicePdfTest < Minitest::Test
428
554
  assert_page_count pdf, 2
429
555
  assert_pdf_link pdf, /acme\.test/
430
556
  assert_no_pdf_warnings pdf
557
+ assert_tagged_content pdf
558
+ assert_pdf_structure pdf, [[:Document, [[:H1, "Invoice"], [:P, "INV-7"]]]]
431
559
  end
432
560
  end
433
561
  ```
@@ -436,7 +564,11 @@ The matcher names carry a `pdf_` prefix so they never clash with Capybara's
436
564
  `have_text` and `have_link`. `have_bookmark` / `assert_bookmark` match outline
437
565
  titles. For anything else, `Stationery::Testing::Inspector.new(subject)`
438
566
  exposes `text`, `page_texts`, `page_count`, `links`, `internal_links`,
439
- `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.
440
572
 
441
573
  ## Why not Prawn, Chrome or Typst?
442
574
 
@@ -466,11 +598,10 @@ larger file. `PROFILE=1 bundle exec ruby -Ilib benchmark/profile.rb` prints the
466
598
 
467
599
  ## Limitations
468
600
 
469
- No ligatures, no TrueType collections, variable fonts (including CFF2) or
470
- WOFF; SVG covers the shapes icon sets use
471
- (no text, gradients, patterns, masks or CSS stylesheets); no encryption,
472
- forms or tagged PDF; fixed-height boxes, and rows holding one,
473
- never split across pages.
601
+ No variable fonts (including CFF2) or
602
+ WOFF2 (it needs Brotli; convert to `.ttf` or `.woff`); SVG covers the shapes icon sets use
603
+ (no patterns or masks). A box with a fixed `height:` never splits (use
604
+ `min_height:` for a floor that can); a row splits only when every column can.
474
605
 
475
606
  ## License
476
607
 
@@ -4,7 +4,8 @@ module Stationery
4
4
  # Collects the nodes a component tree describes. Holds the container stack
5
5
  # (where the next node goes) and the text defaults in effect.
6
6
  class Builder
7
- STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning].freeze
7
+ STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity kerning
8
+ ligatures].freeze
8
9
 
9
10
  # Collects a row's columns; anything that is not already a column box is
10
11
  # wrapped in one.
@@ -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,15 +7,16 @@ module Stationery
7
7
  BOLD_STROKE = 0.03
8
8
 
9
9
  # Draws `string` with its baseline at (x, y) in top-left coordinates and
10
- # returns its advance width.
10
+ # returns its advance width. Standard ligatures form unless `ligatures:`
11
+ # is false or letter spacing is set.
11
12
  def text(string, x:, y:, font:, size:, color: "#000000", letter_spacing: 0, rise: 0, opacity: nil,
12
13
  synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false,
13
- kerning: false, word_spacing: 0)
14
+ kerning: false, ligatures: true, word_spacing: 0)
14
15
  return 0 if string.empty?
15
16
 
16
17
  color = Color.parse(color)
17
- run = font.glyph_run(string, kerning:)
18
- run = run.with_word_spacing(string, word_spacing, size) unless word_spacing.zero?
18
+ run = font.glyph_run(string, kerning:, ligatures: ligatures && letter_spacing.zero?)
19
+ run = run.with_word_spacing(word_spacing, size) unless word_spacing.zero?
19
20
  width = run.width(size, letter_spacing:)
20
21
  graphics(opacity:) do |ops|
21
22
  ops << color.fill