Guide

# Elements

Every element available inside view_template, with its signature, options and a small example.

## At a glance

The element table from the README.

| Element | What it does |
| --- | --- |
| `text(string, **style)` | A paragraph. Plain strings are literal. |
| `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`; decodes numeric (`&#39;`, `&#x27;`) and HTML 4 named entities once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `&lt;b&gt;` stays literal text. |
| `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
| `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:, float:, margin:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). `rotate: -3` turns the painted box around its centre (layout box unchanged, never splits; a `link:` keeps its unrotated rectangle). `shadow: true` or `{ offset: [0, 4], blur: 8, color:, opacity: 0.15 }` paints a soft drop shadow under it, taking no space. `overflow: :hidden` clips the content to the rounded outline. `float: :left` or `:right` with a `width:` takes it to that side, `margin:` away from the text that wraps beside it: see [Floats](#floats). |
| `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. |
| `columns(count:, gap:, balance:, rule:) { }` | One flow poured through `count` columns, newspaper style: column 1 top to bottom, then column 2. Breaks where a page would (between lines with `orphans:`/`widows:`, never inside `break_inside: :avoid`, `keep_with_next` honoured). `balance: true` (default) ends the columns at nearly the same height where the content ends and fills them evenly, what cannot be shared going to the earlier columns (ten lines in three columns are 4, 3 and 3); `false` fills each before the next. Continues across pages; `page_break` inside ends the page; `rule: true \| { color:, width: }` draws a line between columns. |
| `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. `rows` is an Array or any Enumerable; an Enumerator (lazy or not) given a width for every column is read as pages reach it, selected from its start only: see [Large documents](#large-documents). |
| `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:, float:, margin:)` | JPEG, PNG or lossless WebP, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. Drawn at more than twice `max_ppi:` (300) it is reported as oversized; `downscale: true` resamples a PNG or WebP to that resolution instead. |
| `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:` (or the `color` an element sets). 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); `use`, `symbol` sprites and nested `svg` viewports (`viewBox`, `preserveAspectRatio`); `clipPath` (both `clipPathUnits`). |
| `barcode(data, type:, level:, module_size:, width:, height:, color:, quiet_zone:, native:, align:, alt:)` | A barcode drawn as vector bars, encoded in Ruby: `type: :code128` (the default; printable ASCII, digit runs in code set C), `:ean13` (12 digits and the check digit, or 13 checked), `:qr` (byte mode, `level: :l, :m, :q, :h`, versions 1 to 40, UTF-8 marked with an ECI) or `:datamatrix` (ECC 200 in ASCII encodation, digit pairs in one codeword, squares from 10 × 10 to 144 × 144). `module_size:` points a module (1, or 2 for a square one) or as many as fit `width:`; a linear one is `height:` (36) tall. The quiet zone is part of its size unless `quiet_zone: false`, and it shrinks to fit the space. Monochrome renders put it on the dot grid, a whole number of dots a module. `native: true` has `to_zpl` write the printer's own command for it: see [Label printers](#label-printers-to_zpl). A figure in a tagged PDF, its `alt:` by default the kind and the data. |
| `wrap(gap:, row_gap:, align:) { }` | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
| `stack(gap:, align:) { }` | A base with layers painted over it: ordinary children set the height, `layer` children float over them and take no space. Moves to the next page whole. |
| `layer(top:, right:, bottom:, left:, width:, height:, **box) { }` | Inside `stack`: a box placed by insets from the stack's edges, in points or as a fraction (`0.4`, `1/3r`) of its width/height; negative insets overhang. Takes every box option (`rotate:`, `shadow:`, `radius:`, …). |
| `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. |
| `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. |
| `li(string, **style)`, `li(gap:) { }` | A list item: a paragraph, or a block of any elements (including nested lists). |
| `rule(height:, color:)`, `spacer(height)`, `page_break` | Dividers and spacing. |
| `group(keep_together: true, align:) { }` | Keep a block on one page. |
| `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. |
| `text_style(**style) { }` | Default text style for a block. |
| `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links; `rotate(degrees, around:) { }` and `transform([a, b, c, d, e, f]) { }` blocks. |
| `text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, min_font_size:, max_font_size:, align:, color:, border:, background:, radius:, tooltip:, at:)` | An interactive text input (AcroForm). `width:` is `:full` or points; `font_size:` is points, `:auto` or `:fit`; `align:` is `:left`, `:center` or `:right`; dotted names (`"address.city"`) group fields. See [Forms](#forms). |
| `checkbox(name, checked:, size:, label:, at:)` | An interactive check box, with an optional label drawn to its right. |
| `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`. |
| `select(name, options:, value:, width:, height:, editable:, font_size:, min_font_size:, max_font_size:, align:, color:, at:)` | A drop-down (combo box); `editable: true` also accepts typed values. |
| `signature_field(name, width:, height:, label:, at:)` | An empty signature field for the signer to fill, drawn as a rule over the label. |
| `html(source, styles:, gap:, images:, base_path:, bookmarks:, links:, max_depth:)` | Rich text from HTML (ActionText/Trix, CMS output): paragraphs, headings, lists, quotes, code, rules, tables, images, inline marks and links, styled by a CSS subset (`<style>` rules and inline `style`). See [HTML and Markdown](#html-and-markdown). |
| `markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:, max_depth:)` | The same from CommonMark (plus GFM tables and strikethrough). |

Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`, `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `kerning` (default `true`), `ligatures` (default `true`), `features` (OpenType feature tags, e.g. `%i[smcp onum]`), `hyphenate` (`true` for English, or `"de"`, `"sv"`; default off), `align` (`:left`, `:center`, `:right`, `:justify`), `leading`, `orphans` and `widows` (the fewest lines a page break may leave behind and carry over; default 1, a paragraph that cannot meet them moves whole). `align: :justify` stretches the spaces of wrapped lines to the full width; the last line, lines ending in a newline and lines without spaces stay left-aligned (tabs are never stretched). Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).

## text

A paragraph: plain, markup, or Ruby-built runs.

`text(content = nil, markup: false, keep_with_next:, break_inside:, anchor:, bookmark:, **style)`

Plain strings are always literal. `markup: true` reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">` and decodes numeric (`&#39;`, `&#x27;`) and all 252 HTML 4 named entities, each once: escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags, and `&lt;b&gt;` stays the literal text `<b>`. A block builds styled runs in Ruby with `plain`, `br`, `b`, `i`, `u`, `strikethrough`, `sub`, `sup`, `color`, `size`, `font` and `link`.

```ruby
text "Invoice INV-7", size: 22, weight: :bold
text "Net <b>30</b> days &mdash; <link href='mailto:hi@acme.test'>hi@acme.test</link>", markup: true
text { b "Total"; plain " due"; sup "1" }
text { |t| t.color "#4F46E5", @invoice.total }   # a block argument keeps your own self
```

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `font` | String | Inter / default_text | A registered family name. |
| `size` | Numeric | default_text | Points. |
| `weight` | :regular, :bold | :regular | A face without its own file is synthesised. |
| `style` | :italic | — | Oblique is synthesised when the family has no italic file. |
| `color` | Color | "#000000" | `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100). |
| `align` | :left, :center, :right, :justify | :left | `:justify` widens spaces on wrapped lines; the last line, lines ending in a newline and lines without spaces stay left. |
| `leading` | Numeric | 0 | Extra points between lines. |
| `orphans / widows` | Integer | 1 | The fewest lines a page break may leave at the foot of a page and carry to the next; a paragraph that cannot meet them moves whole. See [Page breaks in text](https://stationery.zoolutions.llc/docs/layout-rules#page-breaks-in-text). |
| `letter_spacing` | Numeric | 0 | Extra points between characters. |
| `underline / strikethrough` | Boolean | false | Decoration lines. |
| `link` | String | — | A URL, or `"#name"` for an [internal link](https://stationery.zoolutions.llc/docs/links). |
| `opacity` | 0..1 | 1 | Text transparency. |
| `kerning` | Boolean | true | GPOS / kern-table pair kerning. |
| `ligatures` | Boolean | true | GSUB `liga` standard ligatures (off with letter spacing). |
| `hyphenate` | true, "en", "de", "sv" | off | Breaks a word that does not fit at a point the language's patterns allow, drawing a hyphen; a soft hyphen (U+00AD, `&shy;`) names the points yourself. See [Fonts](https://stationery.zoolutions.llc/docs/fonts#hyphenation). |

## text_style

Default text style for a block.

`text_style(**style) { … }` sets the text defaults (font, size, colour, weight, align, leading, …) for everything built inside the block.

```ruby
text_style(size: 8, color: "#6B7280") do
  text "Terms"
  text "Payment within 30 days."
end
```

## box

Padding, background, border, radius — and page-aware splitting.

`box(padding:, background:, border:, radius:, width:, height:, overflow:, valign:, opacity:, at:, float:, margin:, link:, outset:, break_inside:, decoration:, rotate:, shadow:, align:, gap:, keep_with_next:, anchor:, bookmark:) { … }`

```ruby
box(background: "#F3F4F6", radius: 6, padding: [8, 12], border: { color: "#E5E7EB", width: 0.5 }) do
  text "AMOUNT DUE", size: 8, weight: :bold
  text "€ 7 330,00", size: 19, weight: :bold
end
```

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `padding` | Box | 0 | One value, `[y, x]`, `[top, x, bottom]`, `[top, right, bottom, left]` or `{ top:, right:, bottom:, left:, x:, y: }`. |
| `background` | Color | nil | Fill colour. |
| `border` | Hash | nil | `{ width: 1, color: "#000000", sides: %i[top right bottom left] }`, merged over those defaults. |
| `radius` | Numeric or [tl, tr, br, bl] | 0 | Corner radius. |
| `width` | points, fraction, :auto | full width | `0.5` is half the available width; `:auto` is the content's natural width. |
| `height` | Numeric | nil | A fixed height. Fixed-height boxes never split. |
| `overflow` | :visible, :hidden, :truncate, :shrink_to_fit | :visible | What text does when a fixed height is too small. `:hidden` also clips the content to the rounded outline (`radius:`) and still splits across pages. |
| `valign` | :top, :middle, :bottom | :top | Content placement inside a fixed height. |
| `at` | [x, y] | nil | Pins the box to a page position, outside the flow. |
| `float` | :left, :right | nil | Takes the box to that side of the flow it is in; what follows wraps beside it. Needs a `width:`. See [Floats](#floats). |
| `margin` | Numeric or Box | 0 | With `float:`, the space kept between the box and the text around it: a number on the sides that face the text, or sides named as for `padding:`. |
| `link` | String | nil | Makes the whole box clickable (a URL or `"#anchor"`). |
| `outset` | Box | 0 | Bleeds the background past the box, e.g. into the page margins. |
| `break_inside` | nil, :auto, :avoid | nil | See [Layout rules](https://stationery.zoolutions.llc/docs/layout-rules#break-inside). |
| `decoration` | :slice, :clone | :slice | At a page cut: drop padding and border, or keep the padding. |
| `rotate` | degrees | 0 | Turns the painted box clockwise around its centre, like CSS `rotate()`. The layout rectangle is unchanged, the box never splits, and a `link:` keeps its unrotated rectangle (annotations live in page space). |
| `shadow` | true or Hash | nil | A soft drop shadow under the box: `{ offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }`, merged over those defaults. Painted as stacked, fading rounded rectangles (an artifact), it takes no layout space and is skipped on a fragment cut by a page break. |
| `align / gap` | Symbol / Numeric | :left / 0 | Horizontal alignment of the children and space between them. |

A tilted, white-framed photo with a shadow — the pieces of a collage:

```ruby
box(width: 180, padding: 4, background: "#FFFFFF", radius: 12, shadow: true, rotate: -3) do
  image "beach.jpg", width: 172, height: 129
end
```

## row and column

Columns side by side.

- `row(gap: 0, align: :top, break_inside: nil) { … }`
- `column(width: nil, align: nil, gap: 0, break_inside: nil, **box_options) { … }`

`width:` is points, a fraction (`0.5`), `:auto` or `nil` (an equal share of what is left). A column takes every box option. `align:` on the row (`:top`, `:middle`, `:bottom`) places columns of different heights. Rows split across pages like a box, every column at the same break; a row holding a fixed-height column never splits.

```ruby
row(gap: 24, align: :middle) do
  column(width: 0.5) { text "Invoice INV-7", size: 22, weight: :bold }
  column(width: :auto) { image "logo.png", height: 34 }
  column { text "Due 26 October", align: :right }
end
```

## columns

One flow through several columns.

`columns(count: 2, gap: 12, balance: true, rule: nil, align: nil) { … }`

The block's content is one flow, poured through `count` columns newspaper style: column 1 top to bottom, then column 2. (A `row` is the other thing: columns side by side, each with content of its own.) Every column is `(width - gap * (count - 1)) / count` wide, and a column ends where a page would: between children, between the lines of a paragraph (honouring `orphans:` and `widows:`), inside boxes and tables by their own rules, never inside `break_inside: :avoid`. A heading with `keep_with_next: true` stays in the column of what follows it.

```ruby
text "The sea wall is whole again", size: 30, weight: :bold
columns(count: 2, gap: 22, rule: { color: "#D1D5DB" }) do
  text "What it cost", weight: :bold, keep_with_next: true
  text body, align: :justify, orphans: 2, widows: 2
  image "bay.png", width: 1.0
end
text "A note across the page, below the tallest column."
```

- **`balance: true`** ends the columns at nearly the same height wherever the content ends: on the last page of the block and before a `page_break` inside it. The height is the shortest at which everything fits, and the columns are filled evenly in it: the first takes what fits, and what it leaves is balanced through the columns after it in the same way. Ten lines in three columns are 4, 3 and 3, what cannot be shared going to the earlier columns. The break rules hold, so a heading, a box or `orphans:` and `widows:` can keep the columns further apart than a line, and a column that ends above a block that cannot split may stay shorter than the one after it. **`balance: false`** fills each column to the height available before the next starts.
- **Across pages.** A block that does not fit fills the height left on the page, every column full, and continues on the next page; the last page is balanced. Below other content it starts only when every column takes something in the height left, and otherwise moves to the next page. A `page_break` inside ends the page. What follows the block starts below its tallest column.
- **`rule:`** `true` or `{ color:, width: }` draws a line in the middle of each gap, an artifact in a tagged PDF.
- **`count: 1`** lays out like a `group`. `count:` must be an Integer of at least 1 and `gap:` a number of at least 0, or `ArgumentError` is raised.
- **Nesting.** `columns` works inside a `box`, a `column` and another `columns`.
- **Tagged PDF.** Reading order is column 1, then column 2; a paragraph split across columns is one `P`, as it is across pages. Links, anchors, bookmarks and `table_of_contents` page numbers work inside.
- A child too tall for a column at the top of a page is kept and reported as an overflow warning.

`examples/newsletter.rb` is a complete page. In `html`, `column-count`, `columns: <n>` and `column-gap` map to it.

## group

Keep a block together.

`group(gap: 0, align: nil, keep_together: false, keep_with_next: nil, anchor: nil, bookmark: nil) { … }`

A vertical group of children. `keep_together: true` moves the whole group to the next page rather than splitting it.

```ruby
group(keep_together: true) do
  rule height: 1, color: "#E5E7EB"
  text "Thank you for your business", weight: :bold
end
```

## wrap

Chips and tags.

`wrap(gap: 0, row_gap: gap, align: :left) { … }` — children side by side at their own widths, wrapping onto new rows. It splits between rows.

```ruby
wrap(gap: 6, row_gap: 4) do
  %w[ruby pdf layout].each { |tag| box(width: :auto, padding: [2, 8], radius: 8, background: "#EEF2FF") { text tag } }
end
```

## table

Selections, spans, DSL cells, repeating headers.

`table(rows, widths: nil, width: :auto, header: false, split_rows: false, cell: {}, anchor:, bookmark:) { |t| … }`

- **`rows`** is an Array or any Enumerable of rows. An Enumerator (lazy or not) whose table has a width for every column is read as pages reach it (below).
- **Cells** are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components.
- **`widths:`** per column: points, a fraction of the table (`0.25`), or `nil` for a share of the rest. **`width:`** `:auto`, `:full` or points.
- **`header:`** `true` (one row) or a row count; header rows repeat after every page break.
- **`cell:`** defaults for every cell: `padding` (5), `borders` (all four), `border_width` (0.5), `border_color`, plus `background`, `color`, `weight`, `style`, `size`, `font`, `align`, `valign`, `letter_spacing`, `leading` and `markup`.

#### Selections and zebra stripes

`t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)` and `t.cells` select cells (chainable: `t.row(-1).columns(3..)`); assign one option (`t.columns(1..).align = :right`) or several with `set`. `t.zebra(color:, from: 0, to: nil, every: 2)` stripes rows.

```ruby
table(rows, width: :full, widths: [nil, 45, 80, 85], header: true, cell: { padding: [7, 8], borders: [] }) do |t|
  t.row(0).set(background: "#4F46E5", color: "#FFFFFF", weight: :bold)
  t.columns(1..).align = :right
  t.zebra(from: 1, color: "#F9FAFB")
  t.row(-1).set(weight: :bold, borders: [:top])
end
```

#### Spans

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.

```ruby
table([
  [{ content: "Q1", rowspan: 2 }, "January", "12"],
  ["February", "15"],
  [{ content: "Total", colspan: 2, align: :right, weight: :bold }, "27"]
])
```

#### DSL cells

```ruby
table([
  ["Logo", -> { image "logo.png", height: 16 }],
  ["Status", StatusPill.new("PAID")]
])
```

#### Rows taller than the page

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 — useful for long tables of tall rows.

#### Rows read as pages reach them

Rows given as an Enumerator, lazy or not, to a table with a width for every column (points or fractions) are read as pages reach them: the table holds the rows of the page being filled and the one after them, and none before its first page. With `incremental` a price list of a million rows peaks at 130 MB, where the same rows as an Array peak at 2.8 GB (see [Large documents](https://stationery.zoolutions.llc/docs/performance#large-documents)).

```ruby
orders = Order.preload(:customer).find_each.lazy.map { |o| [o.number, o.customer.name, o.total] }
rows = [%w[Order Customer Total]].each + orders
table(rows, header: true, widths: [80, 0.5, 90]) do |t|
  t.row(0).weight = :bold
  t.columns(2).align = :right
  t.zebra(from: 1, color: "#F4F4F4")
end
```

The header rows, `zebra`, `split_rows`, cells spanning columns and selections by index from the start (`t.row(0)`, `t.rows(1..)`, `t.columns(…)`) work as for an Array, applied to each row as it is read; a proc or component cell is built then, in the text style the table was given in. What needs the end of the table raises an `ArgumentError`: a row counted from the end (`t.row(-1)`, `t.rows(1..-2)`), a cell spanning rows, and a row with more columns than widths. An Enumerator with a column without a width is read whole when the table is built, as an Array is.

## image

JPEG, PNG and lossless WebP.

`image(path_or_io, width: nil, height: nil, fit: nil, align: nil, opacity: nil, float: nil, margin: nil)`

Aspect ratio is preserved when only one of `width`/`height` is given; `fit: [w, h]` scales the image to fit inside that box; with neither, one pixel is one point. An image is never wider than the space it is given. `float: :left` or `:right` takes it to that side, and the text that follows wraps beside it ([Floats](#floats)). Details on [Images and SVG](https://stationery.zoolutions.llc/docs/images-and-svg).

```ruby
image "logo.png", height: 34, align: :right
image StringIO.new(blob.download), fit: [120, 80]
image "bay.jpg", float: :left, width: 0.4, margin: 12, alt: "The bay at dawn"
```

## Floats

Text that wraps around an image or a box.

`float: :left` or `:right` on an `image` or a `box` takes it to that edge of the flow it is written in: the page body, a box, a column or a table cell. What follows it in that flow starts at its top and wraps beside it, and takes the full width again below it, in the middle of a paragraph if need be. `examples/article.rb` is a page built this way.

```ruby
image "bay.jpg", float: :left, width: 0.4, margin: 12, alt: "The bay at dawn"
text story, align: :justify                     # beside the photo, then below it

box(float: :right, width: 170, margin: { left: 16, bottom: 6 }, padding: 10, role: :blockquote) do
  text "The view is everywhere.", size: 13, style: :italic
end
text more                                       # around the pull quote
```

|  | Rule |
| --- | --- |
| `margin:` | A number is kept on the sides that face the text (the inner side and the bottom). A Hash (`{ left: 16, bottom: 6 }`, `x:`, `y:`) or an Array names the sides as `padding:` does. |
| `width:` | A floated box needs one: points, a fraction of the flow's width or `:auto`. An image has its own size. |
| Text | Paragraphs, headings and the text inside a `group` or an `html` block wrap: every line takes the width left at its own top and is aligned and justified in it. A paragraph whose widest word does not fit beside the floats starts below them. |
| List items and boxes | A list item and a `box` that takes the width it is given wrap what they hold, as a block does in CSS: they keep the full width, and their lines are narrow beside the float and wide below it. A box with a `background:`, a `border:`, a `shadow:` or a `link:` paints it across the full width, under the float: the float is painted after the boxes beside it, so it sits on top of the background, and one with `opacity:` or a `shadow:` shows the background through it. The box's padding lies under the float, as a block's does in CSS; a list item keeps its indent from the float, with the marker beside its first line, over the background of its body if it has one. |
| Other elements | A `box` with a size or a place of its own (`width:`, `height:`, `rotate:`, `overflow:` other than `:visible`, `valign:` other than `:top`), a `row`, `columns`, `table`, `rule`, `image`, `svg` or form field is a block: beside the float in the width that is left, all the way down, when its own width (or the least its content takes) fits there; else below the float. A `spacer` takes its height beside the float; a `page_break` ends the page and the float with it. |
| Several floats | The next goes beside those already there when it fits, else below them, and never above one written before it. Left and right floats share a line with the text between them. |
| Height | The flow that holds a float is at least as tall as the float: what follows the box, column or cell starts below it. |
| Page breaks | A float never splits. When it does not fit what is left of the page, or the content after it could not start beside it there, it moves to the next page with that content. The lines a paragraph, a list item or a box carries over a break are wrapped again at the full width; the part that stays keeps the place its whole was given, beside the floats or below them. |
| Floats taller than the page | Floats written one after the other that are taller than a page together are cut before the first that does not fit: it starts the next page, with what was written after it, in a box, a list item, a column and a table cell as in the page body. Floats that fit a page together stay together: when one of them does not fit what is left of the page, they all go to the next. Only a float taller than a page by itself runs over it, and is reported as an `Overflow`. What does not fit below the floats at the top of a page goes to the next page and leaves them behind. |
| Tagged PDF | The float is read where it was written, painted after the boxes beside it or not: an image is a `Figure`, a box has its `role:`, and a floated box without one beside a box with a background is a `Div`, which keeps its content in place. |

Anything but `:left` and `:right` raises `ArgumentError`, as do `margin:` without `float:` and `float:` together with `at:`. Text wraps along the float's rectangle, never along a shape.

## svg

Vector icons and drawings.

`svg(source_or_path, width: nil, height: nil, color: "#000000", align: nil)` — markup String or a path to a `.svg` file. `currentColor` takes `color:`.

```ruby
svg "icons/check.svg", width: 14, color: "#0F766E"
```

## barcode

Code 128, EAN-13, QR codes and Data Matrix as vector bars.

`barcode(data, type: :code128, level: nil, module_size: nil, width: nil, height: nil, color: "#000000", quiet_zone: true, native: nil, align: nil, alt: nil)` encodes `data` in Ruby and draws it as one filled path: sharp at any zoom and on any printer.

```ruby
barcode "SX0042771903", module_size: 1.5, height: 44          # Code 128
barcode "400638133393", type: :ean13, height: 40              # the check digit is added
barcode "https://track.example/SX0042771903", type: :qr, level: :q, width: 60
barcode "SX0042771903", type: :datamatrix, module_size: 2
```

| Type | Data | Size |
| --- | --- | --- |
| `:code128` | printable ASCII; runs of digits in code set C | 11 modules a character, quiet zone 10 |
| `:ean13` | 12 digits (the check digit is added) or 13 (checked) | 95 modules, quiet zone 11 and 7 |
| `:qr` | any String, as bytes (UTF-8 marked with an ECI); `level: :l, :m (default), :q, :h` | the smallest of versions 1 to 40, quiet zone 4 |
| `:datamatrix` | any String, as bytes, in ECC 200's ASCII encodation (two digits a codeword) | the smallest square from 10 × 10 to 144 × 144, quiet zone 1 |

`module_size:` is the width of the narrowest bar (a square one's module) in points, 1 by default (2 for a square one), or `width:` sets it from the whole width, quiet zones included. A barcode never takes more than the width it is given: its module is made smaller to fit. In a monochrome render it is put on the printer's dot grid, a whole number of dots a module. The digits under an EAN-13 are not drawn; write them with `text`. `native: true` asks `to_zpl` to have a label printer draw it with its own command, see [Label printers](https://stationery.zoolutions.llc/docs/pages).

All of them were decoded with ZBar from pictures of what they draw: Code 128 and EAN-13 samples, and QR codes filled to capacity at versions 1 to 40 and every level. Every Data Matrix size, filled to capacity, was read by dmtxread (libdmtx) and is module for module the symbol libdmtx writes for the same data in ASCII encodation.

## stack and layer

Overlapping content: a collage, a badge on a photo, a stamp on a card.

`stack(gap: 0, align: nil) { … }` is a container whose ordinary children form the base and set the height. `layer(top:, right:, bottom:, left:, width:, height:, **box_options) { … }` inside it is a box painted over the base, placed by insets from the stack's edges and taking no space. Insets, `width:` and `height:` are points, or a fraction of the stack's width (horizontal) or height (vertical) when given as a Float between -1 and 1 or a Rational (`1/3r`); negative insets overhang. A layer takes every `box` option, so `rotate:`, `shadow:`, `padding:`, `background:` and `radius:` make tilted, framed snapshots. A stack never splits: it moves to the next page whole.

Overhanging layers paint past the stack, so wrap it in a `box(padding:)` at least as big as the overhang to keep them inside the page margins (what `examples/postcard.rb` does):

```ruby
box(padding: 18) do
  stack do
    box(width: :auto, radius: 16, shadow: true) do
      image "sea.png", width: 228, height: 171, fit: :cover, radius: 16, alt: "The bay at dawn"
    end
    layer(bottom: -18, left: -18, width: 0.4, rotate: -3, padding: 4, background: "#FFFFFF", radius: 12, shadow: true) do
      image "hills.png", width: 83, height: 62, fit: :cover, radius: 8, alt: "Hills above the village"
    end
    layer(top: -18, right: -18, width: 1/3r, rotate: 2, padding: 4, background: "#FFFFFF", radius: 12, shadow: true) do
      image "stone.png", width: 68, height: 68, fit: :cover, radius: 8, alt: "A sandstone wall"
    end
  end
end
```

Without `left:`/`top:` a layer sits at the stack's top-left; without `width:` it takes its content's natural width (at most the stack's). In a tagged PDF the base and the layers attach in paint order.

## canvas

Draw directly.

`canvas(height:, at: nil, width: nil) { |canvas, rect| … }` reserves `height` points and hands the block the canvas — clipped to the reserved rectangle — and that rectangle (`rect.x`, `rect.y`, `rect.width`, `rect.height`, top-left page coordinates).

Canvas methods: `fill_rect(x, y, w, h, color:)`, `rounded_rect(x, y, w, h, radius:, fill:, stroke:, line_width:, dash:)` (per-corner radii), `circle(cx, cy, r, fill:, stroke:)`, `line(x1, y1, x2, y2, color:, width:, dash:, cap:)`, `path(fill:, stroke:, …) { |p| p.move_to; p.line_to; p.curve_to; p.close }`, `clip(x, y, w, h, radius:) { … }`, `image(image, x:, y:, width:, height:)` and `link(x, y, w, h, target)`.

```ruby
canvas(height: 40) do |canvas, rect|
  bars.each_with_index do |width, index|
    x = rect.x + bars.first(index).sum
    canvas.fill_rect(x, rect.y, width, rect.height, color: "#111827") if index.even?
  end
end
```

`rotate(degrees, around: [x, y]) { … }` paints the block turned clockwise around a page point, as CSS `rotate()` does; `transform([a, b, c, d, e, f]) { … }` applies any affine matrix given in the same top-left space. Shapes, images and text inside follow the transform. Link and form widget rectangles are annotations in untransformed page space, so a `link` inside a rotated block keeps its unrotated rectangle.

```ruby
canvas(height: 120) do |canvas, rect|
  canvas.rotate(-3, around: [rect.x + 60, rect.y + 60]) do
    canvas.image(photo, x: rect.x, y: rect.y, width: 120, height: 120)
  end
end
```

## rule, spacer and page_break

Dividers and spacing.

- `rule(height: 1, color: "#000000", width: nil, radius: 0)` — a filled horizontal bar.
- `spacer(height)` — vertical space, dropped when it lands on a page break.
- `page_break` — forces what follows onto a new page.

```ruby
rule height: 3, color: "#4F46E5"
spacer 18
page_break
```

## ul, ol and li

Bulleted and numbered lists.

- `ul(style: nil, gap: 4, indent: nil, marker_gap: 6, marker_color: nil) { … }` — `style:` is `:disc`, `:circle`, `:square`, `:dash` or any String; unstyled nested lists cycle disc → circle → square.
- `ol(format: :decimal, start: 1, suffix: ".", gap: 4, indent: nil, marker_gap: 6, marker_color: nil) { … }` — `format:` is `:decimal`, `:alpha`, `:upper_alpha`, `:roman`, `:upper_roman` or a Proc `(n) -> String`. Markers right-align so bodies line up.
- `li(string, **text_style)` or `li(gap: 0) { … }` — a paragraph, or a block of any elements, including nested lists.

Any node added inside a list (not only `li`) becomes an item. Items split across pages; the marker stays with the first line.

```ruby
ol(format: :upper_roman, marker_color: "#0F766E") do
  li "Introduction"
  li do
    text "Highlights"
    ul(style: :dash) { li "Revenue up 18%"; li "Two new markets" }
  end
end
```

## anchor, bookmark and table_of_contents

Internal links and the PDF outline.

- `anchor(name)` — a named link target (`link: "#name"`); it moves to the next page with what follows it. `anchor:` is also an option on `text`, `box`, `group` and `table`.
- `bookmark(title, level: 1, open: false)` — a standalone outline entry; `bookmark:` (a title or `{ title:, level:, open: }`) is also an option on `text`, `box`, `group` and `table`.
- `table_of_contents(levels: 1.., leader: :dots, indent: 12, number_width: nil, gap: 4, **text_style)` — one linked row per bookmark with its page number.

```ruby
text "Contents", size: 18
table_of_contents(levels: 1..2, leader: :line)
text "Introduction", size: 16, bookmark: "Introduction", anchor: "intro"
```

The full story is on [Links, bookmarks and contents](https://stationery.zoolutions.llc/docs/links).

## text_field, checkbox, radio, select and signature_field

Interactive form fields.

`text_field`, `checkbox`, `radio`, `select` and `signature_field` make a PDF fillable: each takes a name and lays out like a box, or sits at `at: [x, y]`. Their options, naming, fonts and accessibility have a page of their own: [Forms](https://stationery.zoolutions.llc/docs/forms).

## html and markdown

User content rendered with the elements above.

`html` and `markdown` render user content with the elements above; their parsers load on first use.

```ruby
def view_template
  html @post.body.to_s,                                   # ActionText
       images: ->(src) { blob_path_for(src) },            # path, IO or nil (skipped with a warning)
       styles: { h1: { size: 22 }, a: { color: "#0F766E" }, code: { font: "JetBrains Mono" } }
  markdown File.read("NOTES.md"), base_path: "docs", bookmarks: true
end
```

- Images come from `images:` (called with the `src`) or from files under `base_path:`; sources that resolve nowhere, point outside `base_path` or are remote URLs are skipped with a `SkippedImage` warning. Nothing is ever fetched over the network.
- `styles:` is deep-merged into the defaults: `h1`–`h6` (`scale:` of the text size, or `size:` in points; bold, `keep_with_next`), `p`, `a` (colour, underline), `code` (`font:`; register a monospace family for inline and block code, otherwise the text font is used), `pre` and `blockquote` (box options), `hr` (rule options), `table` (`cell:` options, `header:` text style), `ul` and `ol` (list options: `gap:`, `indent:`, `marker_gap:`, `marker_color:`, plus `style:` for `ul` and `format:`/`suffix:` for `ol`), `li` (text style) and `img` (`max_width:`, and `float_margin:` for a floated image without a CSS margin).
- Links are written only for `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`); anything else (`javascript:`, `data:`, a relative path) keeps its text without a link and is reported as a `DroppedLink` warning. `links: %w[http https]` changes the list, `links: :all` keeps every href from a trusted source.
- `gap:` spaces the blocks (default 6); `bookmarks: true` adds h1–h3 to the PDF outline.
- `html` reads a subset of CSS from `<style>` elements and inline `style` attributes (see [What CSS is read](#what-css-is-read)); what it does not read is reported once as an `UnsupportedCss` warning. `markdown` reads none.
- `max_depth:` (default 64) is how deep the source may nest: HTML elements inside one another, or Markdown block quotes and lists. What lies deeper is flattened into the deepest element kept, so its text stays and its structure goes, and a `NestingLimit` warning says how deep the source went. Block quotes and lists also stop indenting after twelve levels, where they would leave their text no width; that is reported the same way.

#### What CSS is read

`html` applies `<style>` rules and inline `style` attributes on top of `styles:`. The cascade is the usual one: the element's defaults from `styles:`, then stylesheet rules by specificity (id over class over element, the later rule winning a tie), then the inline style, then the element's own mark (`<b>`, `<i>`, `<u>`, `<s>`).

Selectors are an element name, `.class`, `#id`, any compound of them (`p.lead`, `td.n.total`), comma lists and `*`. Selectors with combinators or pseudo-classes (`ul li`, `a > b`, `a:hover`) are ignored and reported. At-rules are skipped, except that rules inside `@media print` and `@media all` are read.

| Property | Values | Applies to |
| --- | --- | --- |
| `color` | `#rgb`, `#rrggbb`, `rgb()`, `rgba()` (alpha ignored), the common colour names | text, inherited |
| `font-size` | `px` (0.75 pt), `pt`, `em`, `%`, `xx-small` to `xx-large`, `smaller`, `larger`; relative sizes multiply the size around them | text, inherited |
| `font-weight` | `bold`, `normal`, `100`–`900` (600 and up is bold) | text, inherited |
| `font-style` | `italic`, `oblique`, `normal` | text, inherited |
| `text-decoration` | `underline`, `line-through`, `none` | text, inherited |
| `text-align` | `left`, `center`, `right`, `justify` | paragraphs, headings, cells, images; inherited from a container |
| `background-color` | as `color`, or `transparent` | `p`, `div` and other containers, `blockquote`, `pre`, `table`, `td`, `th` |
| `padding`, `padding-top` … `padding-left` | one to four lengths in `px` or `pt` | the same |
| `margin`, `margin-top` … `margin-left` | one to four lengths in `px` or `pt`: top and bottom are added to `gap:`, left and right indent the block. A negative margin is drawn as none | blocks, and a floated `img` |
| `border` | `1px solid #ccc` in any order, `none` | `table` (every cell), `td`, `th` |
| `width` | `px`, `pt`, `%`, `auto` | `img`, `table`, and `td`/`th` (column widths, when every cell of the first row has one) |
| `float` | `left`, `right`, `none` | `img` only: the text that follows wraps beside it. Its `margin`, with `margin-top` … `margin-left` over it, is kept around it, else `styles: { img: { float_margin: 8 } }` towards the text |
| `page-break-before`, `page-break-after`, `break-before`, `break-after` | `always`, `page`, `auto` | blocks |
| `page-break-inside`, `break-inside` | `avoid`, `auto` | blocks |
| `column-count`, `columns` | a number of columns, `auto` (a column width is not read) | `div` and other containers, `p`, headings, lists, tables, `blockquote`, `pre` |
| `column-gap` | a length in `px` or `pt`, `normal` | the same |

`margin` and its sides are read in the order of the cascade: a side wins over a `margin` declared before it or by a lesser rule, and a `margin` takes back the sides declared before it. `<font color size>`, `<center>` and `<img align="left|right">` (a float) are read the same way. Everything else (`display`, `float` on anything but an image, `position`, `font-family`, `line-height`, `em` lengths outside `font-size`, `auto` margins, a negative `margin-left` or `margin-right`, `url()` values, inline backgrounds) is ignored and named in the `UnsupportedCss` warning, so `strict` catches content that expects more than this. No value is ever fetched: `url()` and `@import` are dropped.

```ruby
html <<~HTML
  <style>
    .note { background-color: #FEF3C7; padding: 8pt; margin: 6pt 0; break-inside: avoid }
    td.amount { text-align: right; width: 25% }
    h2 { page-break-before: always; color: #0F766E }
  </style>
  <div class="note"><p><b>Note.</b> Prices include VAT.</p></div>
HTML
```

## Untrusted input

What html and markdown do with content you did not write.

`html` and `markdown` are meant for content you did not write (a CMS body, a comment, an ActionText field). Nothing in that content can reach outside the document, and parsing it is bounded: nesting is capped and time grows with the input's length. How much input you accept, and so how many pages it renders to, is yours to bound:

- **No requests.** Remote images are never fetched; an image is read only from `images:` or from under `base_path:`, and a path that climbs out of it is skipped (`SkippedImage`).
- **No surprising links.** Only `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`) become links (`DroppedLink` for the rest), so a `javascript:` or `file:` href is plain text.
- **No scripts, no fetched styles.** `script`, `template` and `title` content is dropped and raw HTML inside Markdown stays literal text. CSS is read only for the fixed list of properties above: `url()` and `@import` are dropped unread, and nothing in a style can position content outside the flow or hide it.
- **Bounded nesting.** Five thousand nested `<div>`s, block quotes or lists render flattened, with a `NestingLimit` warning, instead of exhausting the stack (the `svg` element guards its own nesting the same way, at 128 levels). Emphasis nests without recursion, at most 64 brackets wait for their `]`, and a link label is at most 999 characters, so parsing takes time in proportion to the input.
- **Size is yours to bound.** There is no limit on how much text is rendered: a megabyte of input is a few hundred pages. Cap the length of what you accept before you render it, and use `strict` (or read `document.warnings`) when a warning should stop the document.