Reference

# Cookbook

Five complete, runnable documents from the gem's examples/ directory — each rendered in CI and covered by an integration spec.

## Running the examples

```shell
bundle exec rake examples                 # renders every example next to its source
stationery render examples/report.rb      # or one of them
ruby -Ilib examples/invoice.rb            # each file also runs on its own
```

## Invoice

An invoice: a logo beside an amount-due callout, a summary table without borders, a line-item table with a coloured header, zebra rows and a totals row, in a font family of its own.

A row with a logo, an amount-due callout with a status pill, a summary table without borders, and a line-item table with a coloured header, right-aligned money columns, zebra rows and a bold totals row. Full source: [examples/invoice.rb](https://github.com/zoolutions/stationery/blob/main/examples/invoice.rb).

```ruby
page size: :a4, margin: [40, 44, 56, 44]
if FAMILY == "Open Sans"
  font_family "Open Sans",
              regular: File.join(FONTS, "OpenSans-Regular.ttf"), bold: File.join(FONTS, "OpenSans-Bold.ttf"),
              italic: File.join(FONTS, "OpenSans-Italic.ttf"),
              bold_italic: File.join(FONTS, "OpenSans-BoldItalic.ttf")
end
default_text font: FAMILY, size: 9, color: INK
metadata title: "Invoice", creator: "stationery example"

page_template do |page|
  box(at: [page.margin[3], page.height - 34], width: page.content_box.width) do
    text "Page #{page.number} of #{page.count}", size: 7, color: MUTED, align: :right
  end
end
```

```ruby
def summary
  row(gap: 24) do
    column do
      table([["Invoice date", "26 September 2026"], ["Due date", @due], ["Reference", "PO-7781"]],
            widths: [90, nil], cell: { borders: [], padding: [1, 12, 2, 0], color: MUTED }) do |t|
        t.column(0).weight = :bold
        t.column(0).color = INK
      end
    end
    column(width: 190) { amount_due }
  end
end
```

```ruby
def line_items
  rows = [%w[Description Qty Price VAT Amount]]
  @items.each { |name, qty, price| rows << [name, qty.to_s, money(price), "25%", money(qty * price)] }
  rows << ["", "", "", "Subtotal", money(subtotal)]
  rows << ["", "", "", "VAT 25%", money(vat)]
  rows << ["", "", "", "Total", money(total)]
  count = @items.size

  table(rows, width: :full, widths: [nil, 45, 80, 60, 85], header: true,
              cell: { padding: [7, 8], borders: [] }) do |t|
    t.row(0).set(background: ACCENT, color: "#FFFFFF", weight: :bold)
    t.columns(1..).align = :right
    t.zebra(from: 2, to: count, color: ZEBRA)
    t.row(count + 1).columns(3..).set(borders: [:top], border_color: HAIRLINE, border_width: 1)
    t.row(-1).set(weight: :bold, size: 11)
  end
end
```

## Annual report

A multi-page annual report: cover, contents, bookmarks, lists, a table and internal links.

A header that skips the cover (`on: :rest`), a footer on every page, a contents page built by `table_of_contents`, headings with bookmarks and `keep_with_next`, a table with a `colspan` total row and a callout box that splits across pages with `break_inside: :auto`. Full source: [examples/report.rb](https://github.com/zoolutions/stationery/blob/main/examples/report.rb).

```ruby
page size: :a4, margin: [48, 56, 40, 56]
default_text size: 10.5, color: INK
metadata title: "Annual report 2026", author: "Northwind Freight AB", creator: "stationery example", lang: "en"
tagged

header(on: :rest, gap: 18) do |page|
  row(align: :bottom) do
    text "Annual report 2026", size: 8, weight: :bold, color: ACCENT
    text "Page #{page.number} of #{page.count}", size: 8, color: MUTED, align: :right
  end
end

footer(gap: 14) do
  rule height: 0.5, color: HAIRLINE
  spacer 5
  text "Confidential. Prepared for the shareholders of Northwind Freight AB; do not distribute.",
       size: 7, color: MUTED, align: :center
end
```

```ruby
def revenue_table
  rows = [%w[Region Mode 2025 2026 Change]]
  @regions.each do |region, mode, before, after|
    rows << [region, mode, amount(before), amount(after), change(before, after)]
  end
  before = @regions.sum { |row| row[2] }
  after = @regions.sum { |row| row[3] }
  rows << [{ content: "Group total", colspan: 2 }, amount(before), amount(after), change(before, after)]

  table(rows, width: :full, widths: [nil, 70, 70, 70, 60], header: true,
              cell: { padding: [6, 8], borders: [], size: 9, align: :left }) do |t|
    t.row(0).set(background: ACCENT, color: "#FFFFFF", weight: :bold)
    t.columns(2..).align = :right
    t.zebra(from: 1, to: @regions.size, color: ZEBRA)
    t.row(-1).set(weight: :bold, borders: [:top], border_color: INK, border_width: 1)
  end
  spacer 4
  text "Amounts in SEK million.", size: 8, color: MUTED
end
```

```ruby
def callout
  box(background: TINT, radius: 8, padding: 16, border: { color: ACCENT, width: 0.75 }, break_inside: :auto) do
    text "What we learned from electrifying linehaul", size: 11, weight: :bold, color: ACCENT
    spacer 6
    paragraphs "Charging, not range, set the pace. Trucks that could have run 300 kilometres waited for chargers " \
               "that were shared with the local bus operator, so we now book depot power in advance.",
               "Drivers adapted quickly. After two weeks of training, energy use per kilometre was within five " \
               "per cent of the manufacturer's figure, and sick leave in the pilot group fell.",
               "Maintenance costs were lower than planned. Fewer moving parts meant 40 per cent fewer workshop " \
               "hours per vehicle, although tyres wear faster because of the heavier battery packs.",
               "Residual values are the open question. The second-hand market for electric tractors is still " \
               "thin, so we depreciate them over six years instead of eight until prices settle.",
               "Next year we will extend the programme to the Oslo and Helsinki terminals and publish the " \
               "full cost comparison with diesel operation in the sustainability report."
  end
end
```

## Letter

A one-page business letter with a vector letterhead.

A one-page letter: a vector letterhead drawn from inline SVG, a `text_style` block for the contact column and a legal footer. Full source: [examples/letter.rb](https://github.com/zoolutions/stationery/blob/main/examples/letter.rb).

```ruby
page size: :a4, margin: [48, 64, 40, 64]
default_text size: 10.5, color: INK
metadata title: "Letter to Hallberg Bygg AB", author: "Mälaren Studio AB", creator: "stationery example"

footer do
  rule height: 0.5, color: HAIRLINE
  spacer 6
  text "Mälaren Studio AB · Org. nr 559123-4567 · VAT SE559123456701 · Registered office Västerås, Sweden",
       size: 7.5, color: MUTED, align: :center
end
```

```ruby
def letterhead
  row(align: :middle, gap: 12) do
    column(width: 40) { svg LOGO, width: 40 }
    column do
      text "Mälaren Studio", size: 15, weight: :bold
      text "Stora Gatan 21, 722 12 Västerås", size: 8.5, color: MUTED
    end
    column(width: 160) do
      text_style(size: 8.5, color: MUTED, align: :right, leading: 1.5) do
        text "+46 21 123 45 67"
        text "hello@malaren.studio"
        text "malaren.studio"
      end
    end
  end
  spacer 12
  rule height: 2, color: ACCENT
end
```

## Packing slip

A landscape packing slip: a long table that repeats its header on every page and a barcode drawn on a canvas.

Landscape A4, a header with page numbers, a 120-row table with `split_rows: true` and a repeating header, and a barcode drawn on a `canvas`. Full source: [examples/packing_slip.rb](https://github.com/zoolutions/stationery/blob/main/examples/packing_slip.rb).

```ruby
page size: :a4, layout: :landscape, margin: [36, 40, 32, 40]
default_text size: 9, color: INK
metadata title: "Packing slip", creator: "stationery example"

header(gap: 14) do |page|
  row(align: :bottom) do
    text "Packing slip · Order NW-2026-004817", size: 13, weight: :bold
    text "Page #{page.number} of #{page.count}", size: 8, color: MUTED, align: :right
  end
  spacer 6
  rule height: 1.5, color: ACCENT
end

footer { text "Check the contents against this slip within 5 days of delivery.", size: 7, color: MUTED }
```

```ruby
def barcode
  digits = @order.delete("^0-9").chars.map(&:to_i)
  canvas(height: 40) do |canvas, rect|
    x = rect.x
    digits.cycle(3).each_with_index do |digit, index|
      width = 1 + (digit % 3)
      canvas.fill_rect(x, rect.y, width, rect.height, color: INK) if index.even?
      x += width + 1
    end
  end
  spacer 4
  text @order, size: 8, color: MUTED, letter_spacing: 2
  spacer 8
  text "Shipped 27 September 2026 · 3 parcels · Example Freight", size: 8, color: MUTED
end
```

```ruby
def items_table
  rows = [["SKU", "Description", "Bin", "Qty", "Unit (kg)", "Weight (kg)"]]
  @items.each do |sku, name, bin, qty, weight|
    rows << [sku, name, bin, qty.to_s, kilograms(weight), kilograms(qty * weight)]
  end
  rows << [{ content: "#{@items.size} lines", colspan: 3 }, @items.sum { |item| item[3] }.to_s, "",
           kilograms(@items.sum { |*, qty, weight| qty * weight })]

  table(rows, header: true, split_rows: true, width: :full, widths: [80, 300, nil, 60, 70, 80],
              cell: { padding: [2.5, 8], borders: [], size: 8.5 }) do |t|
    t.row(0).set(background: ACCENT, color: "#FFFFFF", weight: :bold)
    t.columns(0).rows(1..).color = MUTED
    t.columns(3..).align = :right
    t.zebra(from: 1, to: @items.size, color: ZEBRA)
    t.row(-1).set(weight: :bold, color: INK, borders: [:top], border_color: INK, border_width: 1)
  end
end
```

## Application form

A one-page membership application with every kind of form field: text inputs, a multiline note, a comb field, a select box, a radio group, check boxes and a signature field.

An interactive AcroForm: labelled text fields in rows, a comb postcode, a select box, a radio group, check boxes with labels, a multiline note and a signature field. Full source: [examples/form.rb](https://github.com/zoolutions/stationery/blob/main/examples/form.rb).

```ruby
page size: :a4, margin: [48, 56, 40, 56]
default_text size: 9.5, color: INK
metadata title: "Membership application", author: "Nordic Makers Guild", creator: "stationery example",
         lang: "en"

footer do
  rule height: 0.5, color: HAIRLINE
  spacer 6
  text "Nordic Makers Guild · Box 118, 221 00 Lund · members@makers.example", size: 7.5, color: MUTED,
                                                                              align: :center
end
```

```ruby
def applicant
  heading "Applicant"
  row(gap: 12) do
    labelled("Full name") { text_field "applicant.name", value: @name, required: true }
    labelled("Email") { text_field "applicant.email", value: @email, required: true }
  end
  spacer 10
  row(gap: 12) do
    labelled("Street address") { text_field "applicant.address.street" }
    column(width: 90) { labelled("Postcode") { text_field "applicant.address.postcode", comb: 5 } }
    labelled("Country") do
      select "applicant.address.country", options: %w[Sweden Norway Denmark Finland Iceland], value: "Sweden"
    end
  end
end
```

```ruby
def membership
  heading "Membership"
  PLANS.each do |value, label|
    radio "plan", value, checked: value == @plan, label: label
    spacer 4
  end
  spacer 8
  text "Anything we should know? (workshops you are interested in, accessibility needs)", size: 8, color: MUTED
  spacer 3
  text_field "notes", multiline: true, height: 70
end
```