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#

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.

examples/invoice.rb — class configuration
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
examples/invoice.rb — #summary
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
examples/invoice.rb — #line_items
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.

examples/report.rb — class configuration
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
examples/report.rb — #revenue_table
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
examples/report.rb — #callout
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.

examples/letter.rb — class configuration
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
examples/letter.rb — #letterhead
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 "[email protected]"
        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.

examples/packing_slip.rb — class configuration
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 }
examples/packing_slip.rb — #barcode
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
examples/packing_slip.rb — #items_table
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.

examples/form.rb — class configuration
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 · [email protected]", size: 7.5, color: MUTED,
                                                                              align: :center
end
examples/form.rb — #applicant
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
examples/form.rb — #membership
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