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 ownInvoice#
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.
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
enddef 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
enddef 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
endAnnual 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.
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
enddef 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
enddef 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
endLetter#
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.
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
enddef 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
endPacking 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.
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 }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
enddef 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
endApplication 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.
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
enddef 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
enddef 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