Examples
Nineteen complete documents from the gem's examples/ directory, rendered live by this site.
Running them yourself#
Every example is a single Ruby file with a preview class method, rendered in CI and covered by an integration spec. The PDFs linked below are rendered on request by the same gem checkout this site runs on.
bundle exec rake examples # renders every example next to its source
stationery render examples/flyer.rb # or one of them
ruby -Ilib examples/flyer.rb # each file also runs on its ownThey ship with the gem, with the images they read, so an application that has only the gem has them too. Inter is the one font the gem ships, so there the invoice examples are set in Inter rather than the Open Sans of the repository:
stationery examples # their names and what each shows
stationery examples invoice # the path of one; --source prints its code
ls "$(bundle show stationery)/examples" # or: gem contents stationeryThe source of each is under its preview, and in the Markdown of this page. The Cookbook walks through the code of five of them, method by method.
Event flyer#
An A4 event flyer: a cover photo bled to the page edges, a date pill, a stats band, prose from HTML, a tilted photo collage, a price table, accommodation cards, a gallery grid, testimonials and a call to action with a link.
The closest thing to a real marketing document: a cover photo bled to the page edges, a date pill, a stats band, prose from html with list styles, a tilted photo collage built from stack and layer, a price table, accommodation cards, a gallery grid of fit: :cover photos, blockquote testimonials and a call to action whose whole pill is a link. Tagged, bookmarked, four pages.
The source: examples/flyer.rb
# frozen_string_literal: true
# An A4 event flyer: a cover photo bled to the page edges, a date pill, a
# stats band, prose from HTML, a tilted photo collage, a price table,
# accommodation cards, a gallery grid, testimonials and a call to action with
# a link. Run it to write examples/flyer.pdf:
#
# ruby -Ilib examples/flyer.rb
# ruby -Ilib exe/stationery render examples/flyer.rb
require "stationery"
class ExampleFlyer < Stationery::Document
ASSETS = File.expand_path("assets", __dir__)
PAGE = "#FAF8F2"
BAND = "#F1EEE7"
HAIRLINE = "#E3DDD3"
PRIMARY = "#345644"
PRIMARY_CONTENT = "#F3FBF6"
ACCENT = "#C1983A"
INK = "#3A2A20"
MUTED = "#74685F"
FAINT = "#9A9189"
TINT = "#E6E8E1"
MARGIN = 48
COVER_HEIGHT = 220
OVERHANG = 24
SVG_OPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' \
'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
ICONS = {
calendar: '<path d="M4 6h16v14H4z M4 10h16 M8 3v4 M16 3v4"/>',
pin: '<path d="M12 21s-6-5.5-6-11a6 6 0 0 1 12 0c0 5.5-6 11-6 11z"/><circle cx="12" cy="10" r="2.2"/>',
users: '<circle cx="9" cy="8" r="3.2"/>' \
'<path d="M3 20a6 6 0 0 1 12 0 M15.5 5.5a3 3 0 0 1 0 5.5 M17 13.5a6 6 0 0 1 4 6"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>',
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
quote: '<path d="M7 16c-1.5 0-3-1.5-3-3.5S5.5 8 8 7l1 1.5c-1.5.7-2 1.6-2 2.5 1.5 0 2.5 1 2.5 2.5S8.5 16 7 16z ' \
'M16 16c-1.5 0-3-1.5-3-3.5S14.5 8 17 7l1 1.5c-1.5.7-2 1.6-2 2.5 1.5 0 2.5 1 2.5 2.5S17.5 16 16 16z"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 3v2 M12 19v2 M3 12h2 M19 12h2 M5.6 5.6l1.4 1.4 M17 17l1.4 1.4 ' \
'M5.6 18.4L7 17 M17 7l1.4-1.4"/>'
}.freeze
LIFE = <<~HTML
<p>We believe a good month feels natural rather than scheduled. Breakfast is long, the sea is a short walk
away and the evenings belong to whoever brings a guitar. Food is not included: guests shop at the Tuesday
market, cook for themselves or for everyone, and eat out in the harbour when the mood takes them.</p>
<p>Most days follow a loose rhythm:</p>
<ul>
<li>Morning movement on the terrace before it gets warm</li>
<li>Focused work or rest until the early afternoon</li>
<li>Swims, hikes and boat trips when the light softens</li>
<li>A shared table and a circle on two evenings a week</li>
</ul>
<p>Every session adapts to who turns up. Nothing is compulsory, and nobody keeps score.</p>
HTML
page size: :a4, margin: MARGIN
default_text size: 10, color: INK, leading: 3
metadata title: "Harbourside — a month on Ilha Serena", author: "stationery example", lang: "en"
tagged
# A cream page, painted under every page.
page_template(layer: :background) do |page|
canvas(at: [0, 0], width: page.width, height: page.height) do |c, rect|
c.fill_rect(rect.x, rect.y, rect.width, rect.height, color: PAGE)
end
end
footer(gap: 12) do |page|
text "Harbourside · harbourside.example · Page #{page.number} of #{page.count}",
size: 7, color: FAINT, align: :center
end
def self.preview = new
def view_template
hero
stats
section("Life at Harbourside", band: true)
html LIFE, styles: { p: { color: MUTED }, ul: { gap: 3, marker_color: ACCENT, indent: 14 } }
spacer 10
collage
section("What's included")
included
section("Prices", band: true)
prices
section("Where you sleep")
accommodation
section("Impressions", band: true)
gallery
section("What guests say")
testimonials
call_to_action
end
private
# ---- hero ----------------------------------------------------------------
# The cover photo covers the page's full width and bleeds off the top; the
# positioned box takes no flow height, so a spacer reserves the space.
def hero
box(at: [0, 0], width: page_width) do
image photo("sea"), width: page_width, height: COVER_HEIGHT, fit: :cover, alt: "The harbour at first light"
end
spacer COVER_HEIGHT - MARGIN + 18
date_pill
spacer 14
text "Harbourside", size: 30, weight: :bold, color: PRIMARY, align: :center, heading: 1
spacer 4
text "A month of community living on Ilha Serena", size: 12, color: MUTED, align: :center
spacer 14
meta_row
end
def date_pill
group(align: :center) do
box(width: :auto, background: PRIMARY, radius: 10, padding: [4, 12]) do
row(gap: 5, align: :middle) do
column(width: :auto) { icon(:calendar, size: 10, color: PRIMARY_CONTENT) }
column(width: :auto) { text "3 – 31 May 2027", size: 9, color: PRIMARY_CONTENT }
end
end
end
end
def meta_row
row do
[[:pin, "Porto Velho, Ilha Serena"], [:users, "12 – 20 guests"], [:clock, "7 to 28 nights"]].each do |name, label|
column do
row(gap: 5, align: :middle) do
column(width: 14, padding: [0, 0, 0, 4]) { icon(name, size: 10) }
column { text label, size: 9, color: MUTED }
end
end
end
end
end
# ---- stats band ----------------------------------------------------------
def stats
spacer 22
box(background: BAND, radius: 12, padding: [14, 10]) do
row do
[[:clock, "Duration", "7 – 28 nights"], [:sun, "Investment", "from € 690"],
[:users, "Group size", "12 – 20 guests"]].each do |name, label, value|
column(align: :center) { stat(name, label, value) }
end
end
end
end
def stat(name, label, value)
icon_disc(name)
spacer 6
text label, size: 8, color: FAINT, align: :center
spacer 2
text value, size: 11, weight: :bold, color: PRIMARY, align: :center
end
def icon_disc(name, diameter: 26)
group(align: :center) do
box(width: diameter, height: diameter, radius: diameter / 2.0, background: TINT, valign: :middle) do
icon(name, size: 13, align: :center)
end
end
end
# ---- sections ------------------------------------------------------------
# A section heading, on a full-bleed tinted strip for every other section,
# bookmarked in the outline and kept with the start of its section.
def section(title, band: false)
spacer 18
group(keep_with_next: 120, bookmark: title) do
if band
box(background: BAND, outset: [0, MARGIN, 0, MARGIN], padding: [12, 0]) { heading(title) }
else
heading(title)
end
spacer 10
end
end
def heading(title) = text(title, size: 19, weight: :bold, color: PRIMARY, align: :center, heading: 2)
def collage
group(align: :center) do
box(padding: OVERHANG, width: :auto) do
stack do
box(width: :auto, radius: 16, shadow: true) do
image photo("hills"), width: 300, height: 225, fit: :cover, radius: 16, alt: "Hills above the harbour"
end
layer(bottom: -OVERHANG, left: -OVERHANG, width: 0.4, rotate: -3, **frame) do
image photo("stone"), width: 112, height: 84, fit: :cover, radius: 8, alt: "The old stone quay"
end
layer(top: -OVERHANG, right: -OVERHANG, width: 1/3r, rotate: 2, **frame) do
image photo("bay"), width: 92, height: 92, fit: :cover, radius: 8, alt: "The bay from the terrace"
end
end
end
end
end
def frame = { padding: 4, background: "#FFFFFF", radius: 12, shadow: true }
def included
items = ["Your room for the whole stay", "Morning movement, six days a week",
"Two evening circles every week", "A boat day and two guided hikes",
"A shared kitchen and the Tuesday market run", "Fast Wi-Fi and a quiet room to work in"]
items.each_slice(2) do |pair|
row(gap: 14) do
pair.each do |item|
column(width: 0.5) do
row(gap: 6) do
column(width: 10, padding: [3, 0, 0, 0]) { icon(:check, size: 9, color: ACCENT) }
column { text item, size: 10, leading: 2 }
end
end
end
end
spacer 7
end
end
def prices
text "Prices are per person and include everything listed above. Children under twelve stay free in " \
"their parents' room.", color: MUTED, align: :center
spacer 10
rows = [["Stay", "Regular", "Early bird\n<font size='7'>until 31 January</font>", "Returning guests"],
["7 nights", "€ 690", "€ 620", "€ 590"], ["14 nights", "€ 1,290", "€ 1,160", "€ 1,090"],
["21 nights", "€ 1,790", "€ 1,610", "€ 1,520"], ["28 nights", "€ 2,190", "€ 1,970", "€ 1,860"]]
table(rows, width: :full, header: true,
cell: { markup: true, size: 9.5, borders: [:bottom], border_color: HAIRLINE, padding: [6, 8] }) do |t|
t.row(0).set(background: PRIMARY, color: PRIMARY_CONTENT, weight: :bold)
t.columns(1..).align = :right
t.zebra(from: 2, color: BAND)
end
spacer 8
text "Returning guests are anyone who has stayed with us before, in any house.", size: 8.5, color: FAINT,
align: :center
end
def accommodation
[["The Harbour House", "6 rooms · sleeps 10", "ridge",
"The old harbourmaster's house: a big kitchen, a walled garden and rooms that look over the boats."],
["The Terrace Apartments", "2 apartments · sleeps 6", "wall",
"Two quiet apartments above the square, each with its own kitchen and a terrace that catches the " \
"afternoon sun."]].each do |name, capacity, picture, blurb|
group(keep_together: true) do
row(gap: 14, align: :middle) do
column(width: 150) { image photo(picture), width: 150, height: 96, fit: :cover, radius: 10, alt: name }
column do
text name, size: 13, weight: :bold, color: PRIMARY, heading: 3
text capacity, size: 9, color: ACCENT
spacer 4
text blurb, color: MUTED
end
end
end
spacer 14
end
end
def gallery
%w[sea hills stone bay ridge dunes].each_slice(3) do |names|
row(gap: 8) do
names.each do |name|
column(width: 1 / 3.0) do
image photo(name), width: 161, height: 96, fit: :cover, radius: 8, alt: "Impression"
end
end
end
spacer 8
end
end
def testimonials
[["Three weeks in, I stopped checking the time. That has never happened to me on a holiday.", "Mara, Vienna"],
["Came for the sea, stayed for the Tuesday market and the people around the table.", "Jonas, Malmö"]]
.each do |quote, author|
group(keep_together: true) do
box(role: :blockquote, background: BAND, radius: 12, padding: [14, 18]) do
icon(:quote, size: 14, color: ACCENT, align: :center)
spacer 6
text %("#{quote}"), style: :italic, color: MUTED, align: :center
spacer 4
text author, size: 9, color: PRIMARY, align: :center
end
end
spacer 10
end
end
# A pill the whole shape of which is clickable, and the address spelled out
# for the printed copy.
def call_to_action
url = "https://harbourside.example/apply"
spacer 22
group(keep_together: true) do
box(background: BAND, outset: [0, MARGIN, 0, MARGIN], padding: [16, 0]) do
text "Ready to come?", size: 19, weight: :bold, color: PRIMARY, align: :center, heading: 2
spacer 6
text "Applications open until the house is full. We read every one and reply within a week.",
color: MUTED, align: :center
spacer 12
group(align: :center) do
box(width: :auto, height: 26, background: PRIMARY, radius: 13, padding: [0, 28], valign: :middle,
link: url) do
text "Apply for a call", size: 11, color: PRIMARY_CONTENT, align: :center
end
end
spacer 8
text(size: 9, align: :center) { |t| t.link(url) { t.color(MUTED, "or write to [email protected]") } }
end
end
end
# ---- helpers -------------------------------------------------------------
def page_width = Stationery::Page.new(size: :a4).width
def photo(name) = File.join(ASSETS, "#{name}.png")
# Decorative line icons; the label beside each one carries the meaning.
def icon(name, size: 12, color: PRIMARY, align: nil)
svg "#{SVG_OPEN}#{ICONS.fetch(name)}</svg>", width: size, height: size, color:, align:, alt: false
end
end
if $PROGRAM_NAME == __FILE__
flyer = ExampleFlyer.preview
flyer.to_pdf(File.expand_path("flyer.pdf", __dir__))
warn flyer.warnings.map(&:message) if flyer.warnings.any?
puts "wrote examples/flyer.pdf"
endInvoice#
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.
The source: examples/invoice.rb
# frozen_string_literal: true
# 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. Run it to write examples/invoice.pdf:
#
# ruby -Ilib examples/invoice.rb
# ruby -Ilib exe/stationery render examples/invoice.rb
require "stationery"
class ExampleInvoice < Stationery::Document
FONTS = File.expand_path("../spec/fixtures/fonts", __dir__)
LOGO = File.expand_path("assets/logo.png", __dir__)
ACCENT = "#4F46E5"
INK = "#1F2937"
MUTED = "#6B7280"
HAIRLINE = "#E5E7EB"
ZEBRA = "#F9FAFB"
CALLOUT = "#F3F4F6"
# Open Sans lives with the test suite, so an installed gem sets the invoice
# in the Inter it bundles.
FAMILY = File.exist?(File.join(FONTS, "OpenSans-Regular.ttf")) ? "Open Sans" : "Inter"
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
def self.preview
items = [["Brand workshop", 1, 2400.0], ["Logo design, three concepts", 1, 3200.0],
["Illustration set (12)", 12, 180.0], ["Print-ready files", 1, 450.0], ["Rush delivery", 1, 300.0]]
new(number: "INV-2026-042", items:, customer: "Müller & Söhne GmbH", due: "26 October 2026")
end
def initialize(number:, items:, customer:, due:)
super()
@number = number
@items = items
@customer = customer
@due = due
end
def view_template
header
spacer 18
summary
spacer 22
parties
spacer 26
line_items
spacer 28
footer
end
private
def header
row(align: :middle) do
column(width: 0.5) { text "Invoice #{@number}", size: 22, weight: :bold }
column(width: 0.5, align: :right) { image LOGO, height: 34, align: :right }
end
spacer 10
rule height: 3, color: ACCENT
end
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
def amount_due
box(background: CALLOUT, radius: 6, padding: 12) do
text "AMOUNT DUE", size: 8, weight: :bold, color: MUTED, letter_spacing: 0.5
text money(total), size: 19, weight: :bold, color: ACCENT
spacer 6
row { column(width: :auto) { status_pill("OPEN", "#3B82F6") } }
end
end
def status_pill(label, color)
box(background: color, radius: 7, padding: [2, 9]) { text label, size: 7, weight: :bold, color: "#FFFFFF" }
end
def parties
row(gap: 24) do
column(width: 0.5) do
party("FROM", ["<b>Acme Studio AB</b>", "Storgatan 1", "111 22 Stockholm", "[email protected]"])
end
column(width: 0.5) { party("BILL TO", ["<b>#{@customer}</b>", "Hauptstraße 5", "10115 Berlin"]) }
end
end
def party(label, lines)
text label, size: 8, weight: :bold, color: MUTED, letter_spacing: 0.5
spacer 3
text lines.join("\n"), markup: true, leading: 1
end
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
def footer
group(keep_together: true) do
rule height: 1, color: HAIRLINE
spacer 14
text "Thank you for your business", size: 9, weight: :bold
spacer 3
text "Pay to IBAN SE45 5000 0000 0583 9825 7466 within 30 days. Questions? " \
"<link href='mailto:[email protected]'><color rgb='#{ACCENT}'>[email protected]</color></link>",
markup: true, size: 8.5, color: MUTED, leading: 2
end
end
def subtotal = @items.sum { |_, qty, price| qty * price }
def vat = (subtotal * 0.25).round(2)
def total = subtotal + vat
def money(amount)
whole, cents = format("%.2f", amount).split(".")
"€#{whole.reverse.scan(/\d{1,3}/).join(" ").reverse},#{cents}"
end
end
if $PROGRAM_NAME == __FILE__
ExampleInvoice.preview.to_pdf(File.expand_path("invoice.pdf", __dir__))
puts "wrote examples/invoice.pdf"
endFactur-X e-invoice#
A Factur-X (ZUGFeRD) e-invoice: the example invoice as PDF/A-3b with its EN 16931 invoice XML embedded, so accounting software books what people read.
The invoice above as an e-invoice: one factur_x line makes it PDF/A-3b, embeds the EN 16931 invoice XML built from the same line items and identifies it in XMP, so accounting software books what people read. Validated with veraPDF and Mustang in CI; see PDF/A and PDF/UA.
The source: examples/e_invoice.rb
# frozen_string_literal: true
# A Factur-X (ZUGFeRD) e-invoice: the example invoice as PDF/A-3b with its
# EN 16931 invoice XML embedded, so accounting software books what people read.
# Run it to write examples/e_invoice.pdf:
#
# ruby -Ilib examples/e_invoice.rb
# ruby -Ilib exe/stationery render examples/e_invoice.rb
require "date"
require "stationery"
require_relative "invoice"
class ExampleEInvoice < ExampleInvoice
ISSUED = Date.new(2026, 9, 26)
VAT_RATE = 25
SELLER = { name: "Acme Studio AB", street: "Storgatan 1", postcode: "111 22", city: "Stockholm", country: "SE",
email: "[email protected]", vat_id: "SE556677889901", iban: "SE4550000000058398257466" }.freeze
BUYER = { street: "Hauptstraße 5", postcode: "10115", city: "Berlin", country: "DE", vat_id: "DE123456789" }.freeze
metadata title: "E-invoice", author: SELLER[:name], lang: "en"
# The XML comes from the document being rendered, so every invoice embeds
# its own. `factur_x` also makes the file PDF/A-3b and identifies the
# invoice in the XMP metadata.
factur_x(profile: :en16931) { cross_industry_invoice }
private
# SAMPLE CODE. Stationery embeds the invoice XML, it does not write it: in
# an application the XML comes from your invoicing code or a library made
# for it. This is the smallest Cross Industry Invoice that satisfies
# EN 16931 for the lines above: one VAT rate, no allowances or charges.
def cross_industry_invoice
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
<rsm:ExchangedDocumentContext>
<ram:GuidelineSpecifiedDocumentContextParameter>
<ram:ID>urn:cen.eu:en16931:2017</ram:ID>
</ram:GuidelineSpecifiedDocumentContextParameter>
</rsm:ExchangedDocumentContext>
<rsm:ExchangedDocument>
<ram:ID>#{xml(@number)}</ram:ID>
<ram:TypeCode>380</ram:TypeCode>
<ram:IssueDateTime>#{xml_date(ISSUED)}</ram:IssueDateTime>
</rsm:ExchangedDocument>
<rsm:SupplyChainTradeTransaction>
#{@items.each_with_index.map { |item, index| xml_line(index + 1, *item) }.join.chomp}
<ram:ApplicableHeaderTradeAgreement>
<ram:SellerTradeParty>
<ram:Name>#{xml(SELLER[:name])}</ram:Name>
#{xml_address(SELLER)}
<ram:URIUniversalCommunication>
<ram:URIID schemeID="EM">#{xml(SELLER[:email])}</ram:URIID>
</ram:URIUniversalCommunication>
#{xml_vat_id(SELLER)}
</ram:SellerTradeParty>
<ram:BuyerTradeParty>
<ram:Name>#{xml(@customer)}</ram:Name>
#{xml_address(BUYER)}
#{xml_vat_id(BUYER)}
</ram:BuyerTradeParty>
<ram:BuyerOrderReferencedDocument>
<ram:IssuerAssignedID>PO-7781</ram:IssuerAssignedID>
</ram:BuyerOrderReferencedDocument>
</ram:ApplicableHeaderTradeAgreement>
<ram:ApplicableHeaderTradeDelivery>
<ram:ActualDeliverySupplyChainEvent>
<ram:OccurrenceDateTime>#{xml_date(ISSUED)}</ram:OccurrenceDateTime>
</ram:ActualDeliverySupplyChainEvent>
</ram:ApplicableHeaderTradeDelivery>
<ram:ApplicableHeaderTradeSettlement>
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
<ram:SpecifiedTradeSettlementPaymentMeans>
<ram:TypeCode>58</ram:TypeCode>
<ram:PayeePartyCreditorFinancialAccount>
<ram:IBANID>#{SELLER[:iban]}</ram:IBANID>
</ram:PayeePartyCreditorFinancialAccount>
</ram:SpecifiedTradeSettlementPaymentMeans>
<ram:ApplicableTradeTax>
<ram:CalculatedAmount>#{xml_amount(vat)}</ram:CalculatedAmount>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:BasisAmount>#{xml_amount(subtotal)}</ram:BasisAmount>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>#{VAT_RATE}</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradePaymentTerms>
<ram:DueDateDateTime>#{xml_date(Date.parse(@due))}</ram:DueDateDateTime>
</ram:SpecifiedTradePaymentTerms>
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
<ram:LineTotalAmount>#{xml_amount(subtotal)}</ram:LineTotalAmount>
<ram:TaxBasisTotalAmount>#{xml_amount(subtotal)}</ram:TaxBasisTotalAmount>
<ram:TaxTotalAmount currencyID="EUR">#{xml_amount(vat)}</ram:TaxTotalAmount>
<ram:GrandTotalAmount>#{xml_amount(total)}</ram:GrandTotalAmount>
<ram:DuePayableAmount>#{xml_amount(total)}</ram:DuePayableAmount>
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
</ram:ApplicableHeaderTradeSettlement>
</rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>
XML
end
def xml_line(number, name, quantity, price)
<<~XML.gsub(/^/, " ")
<ram:IncludedSupplyChainTradeLineItem>
<ram:AssociatedDocumentLineDocument>
<ram:LineID>#{number}</ram:LineID>
</ram:AssociatedDocumentLineDocument>
<ram:SpecifiedTradeProduct>
<ram:Name>#{xml(name)}</ram:Name>
</ram:SpecifiedTradeProduct>
<ram:SpecifiedLineTradeAgreement>
<ram:NetPriceProductTradePrice>
<ram:ChargeAmount>#{xml_amount(price)}</ram:ChargeAmount>
</ram:NetPriceProductTradePrice>
</ram:SpecifiedLineTradeAgreement>
<ram:SpecifiedLineTradeDelivery>
<ram:BilledQuantity unitCode="C62">#{quantity}</ram:BilledQuantity>
</ram:SpecifiedLineTradeDelivery>
<ram:SpecifiedLineTradeSettlement>
<ram:ApplicableTradeTax>
<ram:TypeCode>VAT</ram:TypeCode>
<ram:CategoryCode>S</ram:CategoryCode>
<ram:RateApplicablePercent>#{VAT_RATE}</ram:RateApplicablePercent>
</ram:ApplicableTradeTax>
<ram:SpecifiedTradeSettlementLineMonetarySummation>
<ram:LineTotalAmount>#{xml_amount(quantity * price)}</ram:LineTotalAmount>
</ram:SpecifiedTradeSettlementLineMonetarySummation>
</ram:SpecifiedLineTradeSettlement>
</ram:IncludedSupplyChainTradeLineItem>
XML
end
def xml_address(party)
<<~XML.gsub(/^/, " ").chomp
<ram:PostalTradeAddress>
<ram:PostcodeCode>#{xml(party[:postcode])}</ram:PostcodeCode>
<ram:LineOne>#{xml(party[:street])}</ram:LineOne>
<ram:CityName>#{xml(party[:city])}</ram:CityName>
<ram:CountryID>#{party[:country]}</ram:CountryID>
</ram:PostalTradeAddress>
XML
end
def xml_vat_id(party)
<<~XML.gsub(/^/, " ").chomp
<ram:SpecifiedTaxRegistration>
<ram:ID schemeID="VA">#{party[:vat_id]}</ram:ID>
</ram:SpecifiedTaxRegistration>
XML
end
def xml_date(date) = %(<udt:DateTimeString format="102">#{date.strftime("%Y%m%d")}</udt:DateTimeString>)
def xml_amount(amount) = format("%.2f", amount)
def xml(text) = text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">")
end
if $PROGRAM_NAME == __FILE__
ExampleEInvoice.preview.to_pdf(File.expand_path("e_invoice.pdf", __dir__))
puts "wrote examples/e_invoice.pdf"
endAnnual report#
A multi-page annual report: cover, contents, bookmarks, lists, a table and internal links.
A header that skips the cover, a footer on every page, a contents page from table_of_contents, headings with bookmarks and keep_with_next, nested lists, a table with a colspan total row and a callout that splits across pages.
The source: examples/report.rb
# frozen_string_literal: true
# A multi-page annual report: cover, contents, bookmarks, lists, a table and
# internal links. Run it to write examples/report.pdf:
#
# ruby -Ilib examples/report.rb
# ruby -Ilib exe/stationery render examples/report.rb
require "stationery"
class ExampleReport < Stationery::Document
ACCENT = "#0F766E"
INK = "#1F2937"
MUTED = "#6B7280"
HAIRLINE = "#E5E7EB"
ZEBRA = "#F9FAFB"
TINT = "#ECFDF5"
CHECK = <<~SVG
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="currentColor"/>
<path d="M7 12.5l3.2 3.2L17 9" fill="none" stroke="#FFFFFF" stroke-width="2.2" stroke-linecap="round"
stroke-linejoin="round"/></svg>
SVG
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
def self.preview
new(figures: [["Revenue", "SEK 1.84 bn", "+12% on 2025"], ["Operating margin", "9.6%", "+1.4 points"],
["Deliveries", "4.2 million", "98.7% on time"]],
regions: [["Nordics", "Road", 612.4, 671.0], ["Nordics", "Rail", 188.2, 214.9],
["DACH", "Road", 402.7, 455.3], ["Benelux", "Sea", 211.9, 236.4],
["UK & Ireland", "Sea", 158.0, 162.7], ["Baltics", "Road", 92.5, 101.8]])
end
def initialize(figures:, regions:)
super()
@figures = figures
@regions = regions
end
def view_template
cover
page_break
text "Contents", size: 20, weight: :bold, bookmark: "Contents", heading: 2
spacer 14
table_of_contents(levels: 1..2, size: 10, color: INK, gap: 6, indent: 16)
page_break
text_style(align: :justify, leading: 3) do
introduction
highlights
financial_review
operations
risks
page_break
appendix
end
end
private
def cover
spacer 150
text "NORTHWIND FREIGHT AB", size: 9, weight: :bold, color: ACCENT, letter_spacing: 1.5
spacer 10
text "Annual report 2026", size: 36, weight: :bold, heading: 1
spacer 6
text "Moving more with less: a year of growth, electrification and on-time delivery", size: 13, color: MUTED
spacer 28
rule height: 3, color: ACCENT
spacer 28
box(background: TINT, radius: 8, padding: [18, 20]) do
row(gap: 20) do
@figures.each do |label, value, note|
column do
text label.upcase, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 0.6
spacer 4
text value, size: 20, weight: :bold, color: ACCENT
text note, size: 8.5, color: MUTED
end
end
end
end
end
def heading(title, bookmark:, anchor: nil)
spacer 18
text title, size: 16, weight: :bold, color: ACCENT, bookmark:, anchor:, keep_with_next: 60, heading: 2
spacer 8
end
def subheading(title)
spacer 10
text title, size: 11.5, weight: :bold, bookmark: { title: title.sub(/\A[\d.]+ /, ""), level: 2 },
keep_with_next: 40, heading: 3
spacer 4
end
def paragraphs(*texts)
texts.each_with_index do |body, index|
spacer 7 if index.positive?
text body, markup: true
end
end
def introduction
heading "1. Introduction", bookmark: "Introduction", anchor: "intro"
paragraphs "Northwind Freight closed 2026 with the strongest result in its twenty-year history. Revenue grew " \
"by twelve per cent to SEK 1.84 billion, driven by new contract logistics customers in Germany " \
"and a full year of the Gothenburg rail shuttle. At the same time we cut emissions per delivered " \
"tonne by eighteen per cent, ahead of the target we set ourselves three years ago.",
"This report covers the financial year from 1 January to 31 December 2026 and the parent company " \
"together with its five subsidiaries. Figures are in millions of Swedish kronor unless stated " \
"otherwise, and comparisons are with the previous year. Definitions of the key figures are " \
"collected at the end of the report."
spacer 7
text "See the appendix for definitions and methodology.", link: "#appendix", color: ACCENT, underline: true
subheading "1.1 About this report"
paragraphs "The report was prepared by the finance team and reviewed by the board's audit committee. " \
"Sustainability data follows the Greenhouse Gas Protocol and was verified by an independent " \
"auditor. Where estimates were necessary, for example for subcontracted haulage, the method is " \
"described next to the figure so that readers can judge its reliability."
subheading "1.2 A word from the chief executive"
paragraphs "Twelve months ago we promised to grow without growing our footprint. I am proud that we kept that " \
"promise: every tonne we moved this year produced less carbon than the year before, and our " \
"customers noticed. Three of our five largest contracts were renewed early, two of them with " \
"electric delivery written into the terms.",
"None of this happens without the 2,300 people who load, drive, plan and repair every day. Sick " \
"leave fell for the second year running and our safety record is the best in the industry. " \
"Thank you for another year of hard, careful work.",
"<i>Karin Lindqvist, Chief Executive Officer</i>"
end
def highlights
heading "2. Highlights of the year", bookmark: "Highlights"
[["Rail shuttle", "Five weekly departures between Gothenburg and Duisburg replaced 9,400 truck journeys."],
["Electric fleet", "112 battery-electric tractors in daily service, up from 38 a year earlier."],
["Customer satisfaction", "Net promoter score rose from 41 to 52 in every business area."]].each do |title, body|
spacer 6
row(gap: 10) do
column(width: 16) { svg CHECK, width: 14, color: ACCENT, alt: false }
column { text "<b>#{title}.</b> #{body}", markup: true, align: :left }
end
end
subheading "2.1 Growth drivers"
ul(gap: 5, marker_color: ACCENT) do
li "Contract logistics for three new automotive suppliers in southern Germany."
li(gap: 4) do
text "Intermodal capacity on the corridors that matter most to our customers:"
ul(gap: 3) do
li "Gothenburg to Duisburg, five departures a week"
li "Malmö to Rotterdam, trial service from October"
end
end
li "Higher utilisation of the Jönköping hub after the new sorting line went live in March."
end
end
def financial_review
heading "3. Financial review", bookmark: "Financial review"
paragraphs "Net revenue increased to SEK 1,842 million (1,666). Organic growth was ten per cent; the rest came " \
"from the acquisition of Baltic Parcel in February. Operating profit rose to SEK 177 million, " \
"giving an operating margin of 9.6 per cent (8.2). Cash flow from operations was SEK 231 million."
subheading "3.1 Revenue by region"
revenue_table
subheading "3.2 Outlook"
paragraphs "We expect the freight market to remain soft in the first half of 2027 and to recover later in the " \
"year. Our priorities are unchanged: grow contract logistics, fill the rail shuttle and continue " \
"the move to electric trucks, which already lowers our cost per kilometre on regional routes."
end
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
def operations
heading "4. Operations", bookmark: "Operations"
paragraphs "Our network handled 4.2 million deliveries, of which 98.7 per cent arrived within the agreed " \
"window. The main operational changes during the year were, in order of impact:"
spacer 6
ol(gap: 4, marker_color: ACCENT) do
li "Commissioning the automated sorting line in Jönköping."
li "Moving night-time linehaul between Stockholm and Malmö to electric tractors."
li "Consolidating three Danish terminals into the new Kolding cross-dock."
end
subheading "4.1 Sustainability"
paragraphs "Emissions from our own fleet fell to 38,200 tonnes of CO<sub>2</sub>e. Subcontracted haulage remains " \
"the larger share, and from 2027 new carrier contracts include reporting requirements."
spacer 12
callout
end
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
def risks
heading "5. Risks and uncertainties", bookmark: "Risks"
paragraphs "The board reviews the group's risks twice a year. The most significant are listed below together " \
"with how we manage them; none of them changed materially during 2026."
spacer 6
ul(gap: 5, marker_color: ACCENT) do
li "<b>Demand.</b> Freight volumes follow industrial production. Long contracts with index clauses " \
"cover about 60 per cent of revenue.", markup: true
li "<b>Energy prices.</b> Diesel and electricity are hedged twelve months ahead for three quarters of " \
"expected use.", markup: true
li "<b>Drivers.</b> The industry is short of qualified drivers. Our academy trained 140 new drivers this " \
"year, and staff turnover fell to 11 per cent.", markup: true
end
end
def appendix
heading "6. Appendix", bookmark: "Appendix", anchor: "appendix"
subheading "6.1 Definitions"
[["Operating margin", "Operating profit as a percentage of net revenue."],
["On-time delivery", "Deliveries made within the time window agreed with the customer."],
["Emission intensity", "Grams of CO<sub>2</sub>e per tonne-kilometre, well to wheel."]].each do |term, meaning|
text "<b>#{term}.</b> #{meaning}", markup: true
spacer 4
end
subheading "6.2 Five-year summary"
five_year_summary
spacer 12
text %(Return to the <link href="#intro"><color rgb="#{ACCENT}">introduction</color></link>.), markup: true
end
def five_year_summary
rows = [["SEK million", "2022", "2023", "2024", "2025", "2026"],
["Net revenue", "1,288", "1,402", "1,531", "1,666", "1,842"],
["Operating profit", "97", "104", "118", "137", "177"],
["Operating margin", "7.5%", "7.4%", "7.7%", "8.2%", "9.6%"],
["Employees", "1,940", "2,010", "2,120", "2,210", "2,300"],
["On-time delivery", "96.1%", "96.8%", "97.4%", "98.1%", "98.7%"]]
table(rows, width: :full, widths: [nil, 62, 62, 62, 62, 62], header: true,
cell: { padding: [5, 8], borders: [:bottom], border_color: HAIRLINE, size: 9, align: :left }) do |t|
t.row(0).set(weight: :bold, border_color: INK)
t.columns(1..).align = :right
t.column(-1).weight = :bold
end
end
def amount(value) = format("%.1f", value)
def change(before, after) = format("%+.1f%%", (after - before) / before * 100)
end
if $PROGRAM_NAME == __FILE__
report = ExampleReport.preview
report.to_pdf(File.expand_path("report.pdf", __dir__))
warn report.warnings.map(&:message) if report.warnings.any?
puts "wrote examples/report.pdf"
endLetter#
A one-page business letter with a vector letterhead.
A one-page letter with a vector letterhead drawn from inline SVG, a text_style block for the contact column and a legal footer.
The source: examples/letter.rb
# frozen_string_literal: true
# A one-page business letter with a vector letterhead. Run it to write
# examples/letter.pdf:
#
# ruby -Ilib examples/letter.rb
# ruby -Ilib exe/stationery render examples/letter.rb
require "stationery"
class ExampleLetter < Stationery::Document
ACCENT = "#B45309"
INK = "#1C1917"
MUTED = "#78716C"
HAIRLINE = "#E7E5E4"
LOGO = <<~SVG
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<rect width="40" height="40" rx="9" fill="#B45309"/>
<path d="M11 28V12l9 10 9-10v16" fill="none" stroke="#FFFFFF" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="20" cy="31" r="2.2" fill="#FDE68A"/>
</svg>
SVG
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
def self.preview
new(recipient: ["Ms Anna Hallberg", "Hallberg Bygg AB", "Kungsgatan 12", "753 21 Uppsala"],
date: "27 September 2026", reference: "Proposal MS-2026-118")
end
def initialize(recipient:, date:, reference:)
super()
@recipient = recipient
@date = date
@reference = reference
end
def view_template
letterhead
spacer 56
row do
column(width: 0.6) { text @recipient.join("\n"), leading: 2 }
column(width: 0.4) { text "Västerås, #{@date}\nOur ref. #{@reference}", color: MUTED, align: :right, leading: 2 }
end
spacer 40
text "Proposal for the redesign of your customer portal", size: 12, weight: :bold
spacer 16
body
spacer 28
signature
end
private
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
def body
text_style(align: :justify, leading: 4) do
[
"Dear Ms Hallberg,",
"Thank you for meeting us in Uppsala last week and for walking us through the way your site managers " \
"use the customer portal today. It was clear that the portal holds the right information but makes it " \
"hard to find: most of the questions your office answers by phone are already answered somewhere in it.",
"We propose a redesign in <b>three phases over twelve weeks</b>. The first phase maps the ten most " \
"common tasks with your customers and staff; the second turns them into a clickable prototype that we " \
"test on site; the third delivers the finished design and supports your developers while they build it.",
"The fixed price for all three phases is <b>SEK 480,000</b> excluding VAT, invoiced at the end of each " \
"phase. The full proposal, with the schedule and the people involved, is available at " \
"<link href='https://malaren.studio/p/MS-2026-118'><color rgb='#{ACCENT}'><u>malaren.studio/p/MS-2026-118</u>" \
"</color></link>.",
"We would be glad to start in the week of 2 November. If you have any questions, or would like to " \
"adjust the scope, please call me directly on the number above."
].each_with_index do |paragraph, index|
spacer 10 if index.positive?
text paragraph, markup: true
end
end
end
def signature
group(keep_together: true) do
text "Kind regards,"
spacer 30
text "Erik Sandberg", weight: :bold
text "Creative Director, Mälaren Studio AB", color: MUTED, size: 9.5
end
end
end
if $PROGRAM_NAME == __FILE__
letter = ExampleLetter.preview
letter.to_pdf(File.expand_path("letter.pdf", __dir__))
warn letter.warnings.map(&:message) if letter.warnings.any?
puts "wrote examples/letter.pdf"
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, page numbers in the header, a 120-row table with split_rows: true and a repeating header, and a barcode drawn on a canvas.
The source: examples/packing_slip.rb
# frozen_string_literal: true
# A landscape packing slip: a long table that repeats its header on every
# page and a barcode drawn on a canvas. Run it to write examples/packing_slip.pdf:
#
# ruby -Ilib examples/packing_slip.rb
# ruby -Ilib exe/stationery render examples/packing_slip.rb
require "stationery"
class ExamplePackingSlip < Stationery::Document
ACCENT = "#1D4ED8"
INK = "#111827"
MUTED = "#6B7280"
HAIRLINE = "#E5E7EB"
ZEBRA = "#F3F4F6"
PANEL = "#F9FAFB"
PRODUCTS = ["Hex bolt M8 × 40, zinc plated", "Washer M8, stainless A2", "Cable tie 200 mm, black",
"Wall plug 8 mm", "Wood screw 5 × 60, Torx", "Hinge 90 mm, brushed steel",
"Shelf bracket 200 × 250 mm, white powder coat, including screws and wall plugs for concrete",
"Drawer runner 450 mm, soft close", "Threaded rod M10 × 1000", "Nylon lock nut M10",
"Corner brace 40 mm", "Cabinet handle 128 mm, matt black aluminium, for doors up to 22 mm thick"].freeze
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 self.preview
items = Array.new(120) do |i|
sku = "NW-#{10_000 + (i * 37)}"
bin = "#{"ABCDEF"[i % 6]}-#{((i % 14) + 1).to_s.rjust(2, "0")}"
[sku, PRODUCTS[i % PRODUCTS.size], bin, ((i * 7) % 24) + 1, (((i * 13) % 40) + 5) / 10.0]
end
new(order: "NW-2026-004817", items:,
ship_to: ["Hallberg Bygg AB", "Goods reception, gate 4", "Industrigatan 8", "753 23 Uppsala", "Sweden"],
bill_to: ["Hallberg Bygg AB", "Accounts payable", "Box 1204", "751 42 Uppsala", "Sweden"])
end
def initialize(order:, items:, ship_to:, bill_to:)
super()
@order = order
@items = items
@ship_to = ship_to
@bill_to = bill_to
end
def view_template
row(gap: 16) do
column(width: 0.35) { address("SHIP TO", @ship_to) }
column(width: 0.35) { address("BILL TO", @bill_to) }
column { barcode }
end
spacer 18
items_table
end
private
def address(label, lines)
box(background: PANEL, border: { color: HAIRLINE, width: 1 }, radius: 6, padding: [10, 12]) do
text label, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 0.6
spacer 4
text "<b>#{lines.first}</b>\n#{lines.drop(1).join("\n")}", markup: true, leading: 1.5
end
end
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
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
def kilograms(value) = format("%.1f", value)
end
if $PROGRAM_NAME == __FILE__
slip = ExamplePackingSlip.preview
slip.to_pdf(File.expand_path("packing_slip.pdf", __dir__))
warn slip.warnings.map(&:message) if slip.warnings.any?
puts "wrote examples/packing_slip.pdf"
endShipping label#
A 4 × 6 in shipping label for a thermal label printer, in black and white only, with a Code 128 and a QR code, written as a PDF and as ZPL whose barcodes the printer draws itself (`native: true`).
A 4 × 6 in label in black and white only, sized with page size: "4in x 6in" and mm(), declared monochrome dpi: 203, with a Code 128 and a QR code from barcode, and written both as a PDF and, with to_zpl, as ZPL for a thermal label printer, which draws the two barcodes itself (native: true): stationery render examples/shipping_label.rb --zpl. See Label printers.
The source: examples/shipping_label.rb
# frozen_string_literal: true
# A 4 × 6 in shipping label for a thermal label printer, in black and white
# only, with a Code 128 and a QR code, written as a PDF and as ZPL whose
# barcodes the printer draws itself (`native: true`). Run it to write
# examples/shipping_label.pdf and examples/shipping_label.zpl:
#
# ruby -Ilib examples/shipping_label.rb
# ruby -Ilib exe/stationery render examples/shipping_label.rb --zpl --dpi 203
require "stationery"
class ExampleShippingLabel < Stationery::Document
page size: "4in x 6in", margin: mm(4)
max_pages 1 # one label: what does not fit warns, and to_zpl writes no second label
monochrome dpi: 203
default_text size: 10
metadata title: "Shipping label", creator: "stationery example"
def self.preview
new(parcel: "SX 0042 7719 03", service: "EXPRESS", weight: "2.4 kg", route: "R-07",
from: ["Northwind Supplies", "Unit 5, Harbour Road", "40115 Gothenburg", "Sweden"],
to: ["Ada Example", "Example Workshop Ltd", "12 Sample Street", "3011 Testville", "Netherlands"])
end
def initialize(parcel:, service:, weight:, route:, from:, to:)
super()
@parcel = parcel
@service = service
@weight = weight
@route = route
@from = from
@to = to
end
def view_template
row(gap: 8) do
column(width: 0.62) { address("FROM", @from, size: 9) }
column { service }
end
spacer 8
rule height: 2
spacer 10
address("SHIP TO", @to, size: 14)
spacer 12
rule height: 2
spacer 10
row(gap: 8, align: :center) do
column(width: 0.36) { detail("ROUTE", @route) }
column(width: 0.36) { detail("WEIGHT", @weight) }
column { barcode "https://track.example/#{code}", type: :qr, module_size: 1.4, align: :right, native: true }
end
spacer 10
box(border: { width: 3 }, padding: 10) do
text "PARCEL", size: 8, weight: :bold
spacer 6
barcode code, module_size: 1.5, height: 44, align: :center, native: true
spacer 4
text @parcel, size: 14, weight: :bold, letter_spacing: 1, align: :center
end
spacer 10
text "Keep dry · This side up", size: 9, align: :center
end
private
def code = @parcel.delete(" ")
def address(label, lines, size:)
text label, size: 8, weight: :bold, letter_spacing: 0.6
spacer 3
text "<b>#{lines.first}</b>\n#{lines.drop(1).join("\n")}", size:, markup: true, leading: 1.5
end
def service
box(background: "#000000", padding: [10, 6]) do
text @service, size: 16, weight: :bold, color: "#FFFFFF", align: :center
end
end
def detail(label, value)
text label, size: 8, weight: :bold, letter_spacing: 0.6
spacer 2
text value, size: 20, weight: :bold
end
end
if $PROGRAM_NAME == __FILE__
label = ExampleShippingLabel.preview
label.to_pdf(File.expand_path("shipping_label.pdf", __dir__))
label.to_zpl(File.expand_path("shipping_label.zpl", __dir__))
warn label.warnings.map(&:message) if label.warnings.any?
puts "wrote examples/shipping_label.pdf and examples/shipping_label.zpl"
endReceipt#
A till receipt on an 80 mm roll, in black and white only: a logo, the items with their VAT rates, VAT by rate, the total and the payment, a QR code of the receipt online, and a cut line drawn on a `canvas` above a coupon.
A till receipt on an 80 mm roll (page size: [mm(80), mm(195)], as long as its content, where a receipt printer cuts after the last line), declared monochrome dpi: 203, snap: true: a logo cut to one bit, the items with their VAT codes, VAT by rate, the total and the payment, a QR code from barcode type: :qr and a dashed cut line with scissors drawn on a canvas above a coupon.
The source: examples/receipt.rb
# frozen_string_literal: true
# A till receipt on an 80 mm roll, in black and white only: a logo, the
# items with their VAT rates, VAT by rate, the total and the payment, a QR
# code of the receipt online, and a cut line drawn on a `canvas` above a
# coupon. Run it to write examples/receipt.pdf:
#
# ruby -Ilib examples/receipt.rb
# ruby -Ilib exe/stationery render examples/receipt.rb --png
require "stationery"
class ExampleReceipt < Stationery::Document
LOGO = File.expand_path("assets/logo.png", __dir__)
RATES = { "A" => 25, "B" => 12 }.freeze
# A receipt printer feeds the roll for as long as there is something to
# print and cuts it after the last line. A PDF page has a length, so this
# one is as long as its content: 80 mm wide (72 mm of it printable) and
# 195 mm long. A printer driver set to the roll prints it as it is.
#
# Receipt printers print one bit at 203 dpi. `snap: true` makes every colour
# black or white and puts rules on the printer's dots; `dither: :threshold`
# cuts the colour logo at half, so its letters stay clean instead of
# speckled. They are driven in ESC/POS, not ZPL: `to_zpl` is for label
# printers, and ESC/POS is not something Stationery writes. Print the PDF,
# or send `to_png` to a driver that takes a picture.
page size: [mm(80), mm(195)], margin: [mm(4), mm(4), mm(6), mm(4)]
max_pages 1 # one slip of paper: what does not fit warns instead of starting a second
monochrome dpi: 203, snap: true, dither: :threshold
default_text size: 8.5, leading: 1.5
metadata title: "Receipt", creator: "stationery example"
def self.preview
items = [["Sourdough loaf", 1, 4.20, "B"], ["Oat milk 1 l", 2, 1.95, "B"], ["Espresso beans 500 g", 1, 12.90, "B"],
["Cinnamon bun", 3, 2.50, "B"], ["Beeswax candle", 1, 9.80, "A"], ["Linen tea towel", 1, 6.60, "A"]]
new(number: "TR-0417-2291", at: "29 Sep 2026 10:42", till: "Till 2 · Sam", items:,
payment: { method: "Card **** 4242", reference: "Auth 318204" })
end
def initialize(number:, at:, till:, items:, payment:)
super()
@number = number
@at = at
@till = till
@items = items
@payment = payment
end
def view_template
header
dashes
line_items
dashes
totals
dashes
vat
spacer 6
payment
spacer 10
online
spacer 10
cut_line
spacer 8
coupon
end
private
def header
image LOGO, height: 30, align: :center
spacer 6
text "Acme Pantry\n12 Sample Street, 3011 Testville\nVAT no. XX 0123 4567 89", align: :center, size: 8
spacer 6
row do
column { text "#{@number}\n#{@till}", size: 8 }
column { text @at, size: 8, align: :right }
end
end
def line_items
rows = @items.map do |name, qty, price, rate|
[qty > 1 ? "#{name}\n #{qty} × #{money(price)}" : name, money(qty * price), rate]
end
table(rows, width: :full, widths: [nil, 56, 12], cell: { borders: [], padding: [1, 0] }) do |t|
t.columns(1..).align = :right
end
end
def totals
table([["Subtotal", money(total - vat_total)], ["VAT", money(vat_total)]],
width: :full, widths: [nil, 70], cell: { borders: [], padding: [1, 0] }) do |t|
t.column(1).align = :right
end
spacer 3
row do
column { text "TOTAL", size: 13, weight: :bold }
column { text money(total), size: 13, weight: :bold, align: :right }
end
end
# Prices include VAT, as a till's do: the VAT in a gross amount at r % is
# gross × r / (100 + r).
def vat
rows = [%w[VAT Net VAT Gross]]
by_rate.each do |code, gross|
rate = RATES.fetch(code)
tax = (gross * rate / (100 + rate)).round(2)
rows << ["#{code} #{rate}%", money(gross - tax), money(tax), money(gross)]
end
table(rows, width: :full, cell: { borders: [], padding: [1, 0], size: 7.5 }) do |t|
t.columns(1..).align = :right
t.row(0).weight = :bold
end
end
def payment
row do
column { text "#{@payment[:method]}\n#{@payment[:reference]}", size: 8 }
column { text "Paid #{money(total)}", size: 8, weight: :bold, align: :right }
end
end
def online
barcode "https://receipts.example/r/#{@number}", type: :qr, module_size: 2, align: :center
spacer 3
text "Your receipt online, and returns within 30 days", size: 7.5, align: :center
spacer 6
text "Thank you!", size: 11, weight: :bold, align: :center
end
# A dashed line across the paper with a pair of scissors at its start, drawn
# on a canvas as tall as the scissors.
def cut_line
canvas(height: 12) do |c, rect|
y = rect.y + 6
c.line(rect.x + 16, y, rect.x + rect.width, y, color: "#000000", width: 0.75, dash: [3, 2])
c.circle(rect.x + 3, y - 3, 2.2, stroke: "#000000", line_width: 0.8)
c.circle(rect.x + 3, y + 3, 2.2, stroke: "#000000", line_width: 0.8)
c.line(rect.x + 5, y - 2, rect.x + 13, y + 2.5, color: "#000000", width: 0.8)
c.line(rect.x + 5, y + 2, rect.x + 13, y - 2.5, color: "#000000", width: 0.8)
end
end
def coupon
box(border: { width: 1 }, padding: [6, 8]) do
text "10% OFF YOUR NEXT VISIT", size: 10, weight: :bold, align: :center
spacer 2
text "Code PANTRY10 · valid until 31 Oct 2026", size: 7.5, align: :center
end
end
def dashes
spacer 5
rule height: 0.75
spacer 5
end
def by_rate
@items.group_by(&:last).sort.to_h { |code, items| [code, items.sum { |_, qty, price| qty * price }] }
end
def total = @items.sum { |_, qty, price| qty * price }.round(2)
def vat_total
by_rate.sum { |code, gross| (gross * RATES.fetch(code) / (100 + RATES.fetch(code))).round(2) }
end
def money(amount) = format("€%.2f", amount).tr(".", ",")
end
if $PROGRAM_NAME == __FILE__
receipt = ExampleReceipt.preview
receipt.to_pdf(File.expand_path("receipt.pdf", __dir__))
warn receipt.warnings.map(&:message) if receipt.warnings.any?
puts "wrote examples/receipt.pdf"
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, a comb postcode, a select box, a radio group, check boxes with labels, a multiline note and a signature field. Fill it in any viewer; every field and option is on Forms.
The source: examples/form.rb
# frozen_string_literal: true
# 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. Run it to write examples/form.pdf:
#
# ruby -Ilib examples/form.rb
# ruby -Ilib exe/stationery render examples/form.rb
require "stationery"
class ExampleForm < Stationery::Document
ACCENT = "#0F766E"
INK = "#111827"
MUTED = "#6B7280"
HAIRLINE = "#E5E7EB"
PLANS = { "basic" => "Basic · SEK 290 / year", "pro" => "Pro · SEK 590 / year",
"team" => "Team · SEK 1,490 / year" }.freeze
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
def self.preview = new(name: "Astrid Lindqvist", email: "[email protected]", plan: "pro")
def initialize(name: "", email: "", plan: nil)
super()
@name = name
@email = email
@plan = plan
end
def view_template
text "Membership application", size: 20, weight: :bold, color: ACCENT
text "Fill in the form on screen, sign it and send it back to us.", color: MUTED
spacer 20
applicant
spacer 18
membership
spacer 18
consent
end
private
def heading(title)
text title, size: 11, weight: :bold, keep_with_next: true
rule height: 0.5, color: HAIRLINE
spacer 8
end
def labelled(label)
column do
text label, size: 8, color: MUTED
spacer 3
yield
end
end
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
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
def consent
heading "Consent"
checkbox "terms", label: "I accept the terms of membership and the workshop safety rules."
spacer 4
checkbox "newsletter", checked: true, label: "Send me the monthly newsletter."
spacer 24
row(gap: 24, align: :bottom) do
column { signature_field "signature", label: "Signature of the applicant" }
column(width: 150) { labelled("Date") { text_field "signed_on" } }
end
end
end
if $PROGRAM_NAME == __FILE__
form = ExampleForm.preview
form.to_pdf(File.expand_path("form.pdf", __dir__))
warn form.warnings.map(&:message) if form.warnings.any?
puts "wrote examples/form.pdf"
endPrice card#
A shop-window price card a person fills in on screen and prints: the product's name centred and shrunk to fit its line, the price large, centred and red, the price per unit right-aligned in grey and a select of units. The name and price are fitted by the gem and the size it chose is written to the file (`font_size: :fit`), so every viewer shows them at that size. The fields have no border and no background, so the printed card shows the values alone; a viewer that redraws a field after an edit keeps its alignment, colour and size (/Q and /DA).
A shop-window price card filled in on screen and printed: fields without a frame whose values are centred (align: :center), coloured (color:) and sized to fit (font_size: :fit, the size written to the file), written as /Q and /DA so a viewer redraws an edit the same way. See Forms.
The source: examples/price_card.rb
# frozen_string_literal: true
# A shop-window price card a person fills in on screen and prints: the
# product's name centred and shrunk to fit its line, the price large,
# centred and red, the price per unit right-aligned in grey and a select of
# units. The name and price are fitted by the gem and the size it chose is
# written to the file (`font_size: :fit`), so every viewer shows them at
# that size. The fields have no border and no background, so the printed
# card shows the values alone; a viewer that redraws a field after an edit
# keeps its alignment, colour and size (/Q and /DA). Run it to write
# examples/price_card.pdf:
#
# ruby -Ilib examples/price_card.rb
# ruby -Ilib exe/stationery render examples/price_card.rb
require "stationery"
class ExamplePriceCard < Stationery::Document
INK = "#0F172A"
SALE = "#DC2626"
MUTED = "#64748B"
HAIRLINE = "#E2E8F0"
UNITS = ["per piece", "per kg", "per litre", "per pack"].freeze
BARE = { border: nil, background: nil }.freeze
page size: :a5, layout: :landscape, margin: 36
default_text size: 12, color: INK
metadata title: "Price card", author: "Corner Coffee Shop", creator: "stationery example", lang: "en"
footer do
rule height: 0.5, color: HAIRLINE
spacer 6
text "All prices include 25% VAT · Corner Coffee Shop, Stortorget 4, Lund", size: 8, color: MUTED,
align: :center
end
def self.preview
new(product: "Espresso machine Deluxe 3000 with milk frother", price: "4 990 kr",
unit_price: "Was 5 990 kr · save 1 000 kr", unit: "per piece")
end
def initialize(product: "", price: "", unit_price: "", unit: UNITS.first)
super()
@product = product
@price = price
@unit_price = unit_price
@unit = unit
end
def view_template
text "Corner Coffee Shop", size: 16, weight: :bold, align: :center
spacer 4
rule height: 1.5, color: INK
spacer 10
label "Product"
text_style(weight: :bold) do
text_field "product", value: @product, height: 44, font_size: :fit, max_font_size: 32, align: :center,
tooltip: "Product", **BARE
end
rule height: 0.5, color: HAIRLINE
spacer 10
label "Price"
text_style(weight: :bold) do
text_field "price", value: @price, height: 150, font_size: :fit, max_font_size: 120, align: :center,
color: SALE, tooltip: "Price", **BARE
end
spacer 6
row(gap: 12) do
column(width: 150) do
select "unit", options: UNITS, value: @unit, height: 22, align: :center, tooltip: "Unit", **BARE
end
column do
text_field "note", value: @unit_price, height: 22, align: :right, color: MUTED, tooltip: "Note", **BARE
end
end
end
private
def label(title) = text(title.upcase, size: 8, color: MUTED, letter_spacing: 1, align: :center)
end
if $PROGRAM_NAME == __FILE__
card = ExamplePriceCard.preview
card.to_pdf(File.expand_path("price_card.pdf", __dir__))
warn card.warnings.map(&:message) if card.warnings.any?
puts "wrote examples/price_card.pdf"
endMagazine article#
A magazine page: justified text wrapping around a floated photo, a pull quote floated to the right and a snapshot floated by CSS inside `html`.
Floats: a photo floated left with justified text wrapping beside it and continuing below, a pull quote floated right between two paragraphs, and an image floated by CSS inside html. Tagged: the photos are figures and the quote a block quote, read where they were written.
The source: examples/article.rb
# frozen_string_literal: true
# A magazine page: justified text wrapping around a floated photo, a pull
# quote floated to the right and a snapshot floated by CSS inside `html`.
# Run it to write examples/article.pdf:
#
# ruby -Ilib examples/article.rb
# ruby -Ilib exe/stationery render examples/article.rb
require "stationery"
class ExampleArticle < Stationery::Document
ASSETS = File.expand_path("assets", __dir__)
INK = "#1F2937"
MUTED = "#6B7280"
ACCENT = "#B45309"
WASH = "#FEF3C7"
PARAGRAPHS = [
"The road to the bay ends where the asphalt does. From there a track of red earth runs between dry-stone " \
"walls, past a shuttered farm and a row of prickly pears, and then the land simply stops: a shelf of pale " \
"rock, the water beyond it green over sand and blue over weed. Nobody built anything here. The nearest " \
"village is an hour away on foot, and it has never been in a hurry to come closer.",
"We walked out in the cool of the morning with bread, tomatoes and two litres of water each. The shepherd " \
"we met at the last gate counted us, counted the bottles, and let us through with a nod. By ten the sun " \
"was over the ridge and the cicadas had started; by eleven the only shade on the whole shore was the one " \
"we carried with us, a square of canvas on four driftwood poles.",
"What strikes a visitor from the north is how little the place asks of you. There is no path to follow " \
"and no view to find, because the view is everywhere. You swim, you dry, you read a page and lose your " \
"place. The afternoon wind arrives at three, as it has every day since May, and turns the bay from glass " \
"to hammered pewter in the time it takes to look up.",
"On the way back the shepherd was waiting with a bag of figs. He would not take money for them. He asked " \
"instead whether the water had been cold, and when we said no, not at all, he laughed and said that it " \
"would be by November, and that November was the month to come."
].freeze
NOTES = <<~HTML
<h2>Getting there</h2>
<p><img src="stone.png" alt="A sandstone wall beside the track" style="float: right; width: 96pt;
margin: 0 0 6pt 12pt">Leave the car at the last farm and carry what you need: there is no water on the
shore and no shade before the evening. The track is easy underfoot, but it is an hour each way and the
walls beside it keep the wind out. Start before eight, turn back by five, and close every gate behind
you.</p>
HTML
page size: :a4, margin: [64, 72]
default_text size: 10.5, color: INK, leading: 3, hyphenate: :en
metadata title: "An hour from the road", author: "stationery example", lang: "en"
tagged
def self.preview = new
def view_template
text "TRAVEL", size: 8, weight: :bold, color: ACCENT, letter_spacing: 1.5
text "An hour from the road", size: 28, weight: :bold, heading: 1, leading: 0
text "A bay in the south of Sardinia that the asphalt never reached", size: 12, color: MUTED
spacer 8
rule height: 2, color: ACCENT, width: 48
spacer 14
story
spacer 6
html NOTES, base_path: ASSETS, styles: { h2: { color: ACCENT, scale: 1.3 } }
end
private
# The photo floats left at the top of the first paragraph; the pull quote
# floats right where it is written, between the second and the third.
def story
group(gap: 9) do
image photo("sea"), float: :left, width: 0.42, margin: { right: 14, bottom: 8 }, radius: 6,
alt: "The bay from the last gate, early in the morning"
PARAGRAPHS.first(2).each { |paragraph| text paragraph, align: :justify }
pull_quote "There is no path to follow and no view to find, because the view is everywhere."
PARAGRAPHS.drop(2).each { |paragraph| text paragraph, align: :justify }
end
end
def pull_quote(words)
box(float: :right, width: 170, margin: { left: 16, bottom: 6 }, padding: [10, 12], background: WASH,
border: { sides: [:left], width: 3, color: ACCENT }, role: :blockquote) do
text words, size: 13, style: :italic, color: ACCENT, leading: 2, hyphenate: nil
end
end
def photo(name) = File.join(ASSETS, "#{name}.png")
end
if $PROGRAM_NAME == __FILE__
article = ExampleArticle.preview
article.to_pdf(File.expand_path("article.pdf", __dir__))
warn article.warnings.map(&:message) if article.warnings.any?
puts "wrote examples/article.pdf"
endPostcard#
An A5 landscape postcard: a photo collage of one large picture with two tilted, white-framed snapshots overlapping its corners, beside a greeting.
An A5 landscape card: the photo collage on its own — a rounded, shadowed base photo with two tilted, white-framed snapshots over its corners — beside a greeting.
The source: examples/postcard.rb
# frozen_string_literal: true
# An A5 landscape postcard: a photo collage of one large picture with two
# tilted, white-framed snapshots overlapping its corners, beside a greeting.
# Run it to write examples/postcard.pdf:
#
# ruby -Ilib examples/postcard.rb
# ruby -Ilib exe/stationery render examples/postcard.rb
require "stationery"
class ExamplePostcard < Stationery::Document
ASSETS = File.expand_path("assets", __dir__)
PAPER = "#FFFBEB"
INK = "#1F2937"
MUTED = "#6B7280"
ACCENT = "#B45309"
OVERHANG = 18
page size: :a5, layout: :landscape, margin: 36
default_text size: 10.5, color: INK
metadata title: "Greetings from Sardinia", author: "stationery example", lang: "en"
tagged
# Cream card stock: a background layer painted under every page.
page_template(layer: :background) do |page|
canvas(at: [0, 0], width: page.width, height: page.height) do |c, rect|
c.fill_rect(rect.x, rect.y, rect.width, rect.height, color: PAPER)
end
end
def self.preview = new
def view_template
spacer 30
row(gap: 20, align: :middle) do
column(width: 0.55) { collage }
column { greeting }
end
end
private
# The site-style collage: a base photo with rounded corners and a shadow, a
# 4:3 snapshot hanging off the bottom-left corner and a square one off the
# top-right, each tilted in its white frame. The stack's layers overhang by
# OVERHANG points, so the box around it keeps them on the page.
def collage
box(padding: OVERHANG) do
stack do
box(width: :auto, radius: 16, shadow: true) do
image photo("sea"), width: 228, height: 171, fit: :cover, radius: 16, alt: "The bay at dawn"
end
layer(bottom: -OVERHANG, left: -OVERHANG, width: 0.4, rotate: -3, **frame) do
image photo("hills"), width: 83, height: 62, fit: :cover, radius: 8, alt: "Hills above the village"
end
layer(top: -OVERHANG, right: -OVERHANG, width: 1/3r, rotate: 2, **frame) do
image photo("stone"), width: 68, height: 68, fit: :cover, radius: 8, alt: "A sandstone wall"
end
end
end
end
def frame = { padding: 4, background: "#FFFFFF", radius: 12, shadow: true }
def photo(name) = File.join(ASSETS, "#{name}.png")
def greeting
text "Greetings from Sardinia", size: 22, weight: :bold, color: ACCENT, heading: 1
spacer 6
text "October 2026", size: 9, color: MUTED
spacer 14
text "The sea is still warm enough to swim before breakfast, the hills smell of myrtle and the village " \
"square fills up every evening. Wish you were here.", leading: 3
spacer 14
text "With love, the whole crew", style: :italic, color: MUTED
end
end
if $PROGRAM_NAME == __FILE__
postcard = ExamplePostcard.preview
postcard.to_pdf(File.expand_path("postcard.pdf", __dir__))
warn postcard.warnings.map(&:message) if postcard.warnings.any?
puts "wrote examples/postcard.pdf"
endPrice list#
A wholesale price list of 2,000 articles over forty-odd pages: one long table read from an Enumerator of records as pages reach them, its header row repeated on every page, written incrementally so each page lets go of its content once it is painted.
2,000 articles over forty-four pages: a table read from a lazy Enumerator of records as pages reach them, every column given a width (a seeded Random, so the file is the same every time), its header row repeated on every page, a header and a footer with page numbers, and incremental, so each page's content is written as soon as it is painted.
The source: examples/price_list.rb
# frozen_string_literal: true
# A wholesale price list of 2,000 articles over forty-odd pages: one long
# table read from an Enumerator of records as pages reach them, its header
# row repeated on every page, written incrementally so each page lets go of
# its content once it is painted. Run it to write examples/price_list.pdf:
#
# ruby -Ilib examples/price_list.rb
# ruby -Ilib exe/stationery render examples/price_list.rb
require "stationery"
class ExamplePriceList < Stationery::Document
ACCENT = "#B45309"
INK = "#1C1917"
MUTED = "#78716C"
ZEBRA = "#FAF7F2"
Article = Data.define(:number, :name, :unit, :pack, :price)
RANGES = { "Coffee" => %w[Arabica Robusta Espresso Decaf Blend], "Tea" => %w[Assam Darjeeling Sencha Rooibos Chai],
"Cocoa" => %w[Dark Milk White Spiced Raw] }.freeze
FORMS = [["whole beans", "kg", 6], ["ground", "kg", 6], ["capsules", "box of 50", 12], ["loose leaf", "kg", 4],
["bags", "box of 100", 10], ["powder", "kg", 8]].freeze
page size: :a4, margin: [40, 40, 36, 40]
default_text size: 8.5, color: INK
metadata title: "Price list 2027", author: "Example Provisions AB", creator: "stationery example"
# Headers and footers are painted once the page count is known: with
# `incremental` the body of each page is written as soon as it is painted
# instead of being held until then.
incremental
header(gap: 12) do
row(align: :bottom) do
text "Example Provisions · Price list 2027", size: 11, weight: :bold, color: ACCENT
text "Prices in SEK excluding VAT", size: 7.5, color: MUTED, align: :right
end
spacer 4
rule height: 1, color: ACCENT
end
footer { |page| text "Page #{page.number} of #{page.count}", size: 7.5, color: MUTED, align: :center }
# 2,000 articles, the same every time: a seeded Random, so the list (and
# the file) does not change between renders.
def self.preview = new(articles(2_000))
def self.articles(count)
Enumerator.new do |yielder|
random = Random.new(2027)
count.times do |i|
range, kinds = RANGES.to_a[i % RANGES.size]
form, unit, pack = FORMS[random.rand(FORMS.size)]
name = "#{kinds[random.rand(kinds.size)]} #{range.downcase}, #{form}"
yielder << Article.new(format("EP-%05d", 10_000 + (i * 7)), name, unit, pack, random.rand(40.0..900.0).round(2))
end
end
end
def initialize(articles)
super()
@articles = articles
end
def view_template
text "Price list 2027", size: 24, weight: :bold
text "Valid from 1 January 2027 until further notice. Orders of 20 packs or more are delivered free.",
color: MUTED
spacer 14
price_table
end
private
# An Enumerator of rows, to a table whose every column has a width, is
# read as pages reach them: no more than a page of articles is held at a
# time (`Article.find_each.lazy.map { … }` reads a database the same way).
# The header row is the first and repeats after every page break.
def price_table
header = ["Article", "Description", "Unit", "Pack", "Per unit", "Per pack"]
rows = [header].each + @articles.lazy.map { |article| article_row(article) }
table(rows, header: true, widths: [62, 217, 62, 36, 66, 72], cell: { padding: [2.5, 6], borders: [] }) do |t|
t.row(0).set(background: ACCENT, color: "#FFFFFF", weight: :bold)
t.columns(3..).align = :right
t.columns(0).rows(1..).color = MUTED
t.zebra(from: 1, color: ZEBRA)
end
end
def article_row(article)
[article.number, article.name, article.unit, article.pack.to_s, money(article.price),
money(article.price * article.pack)]
end
def money(amount)
whole, cents = format("%.2f", amount).split(".")
"#{whole.reverse.scan(/\d{1,3}/).join(" ").reverse},#{cents}"
end
end
if $PROGRAM_NAME == __FILE__
list = ExamplePriceList.preview
list.to_pdf(File.expand_path("price_list.pdf", __dir__))
warn list.warnings.map(&:message) if list.warnings.any?
puts "wrote examples/price_list.pdf"
endContract#
A services agreement over several pages: numbered clauses whose headings stay with their text, a box for both parties' initials in the footer of every page, and signature fields for a digital signature.
Numbered clauses and sub-clauses whose headings keep 50 points of their text with them (keep_with_next: 50), a footer with a text field for each party's initials on every page, and a signature field per party, which to_pdf(sign: { certificate:, key:, field: "signature.provider" }) signs. See Digital signatures.
The source: examples/contract.rb
# frozen_string_literal: true
# A services agreement over several pages: numbered clauses whose headings
# stay with their text, a box for both parties' initials in the footer of
# every page, and signature fields for a digital signature. Run it to write
# examples/contract.pdf:
#
# ruby -Ilib examples/contract.rb
# ruby -Ilib exe/stationery render examples/contract.rb
#
# Signing happens when the file is written, with a certificate and its key;
# `field:` names the signature field that carries the signature:
#
# ExampleContract.preview.to_pdf("contract.pdf",
# sign: { certificate: File.read("provider.crt"), key: File.read("provider.key"),
# field: "signature.provider", reason: "Agreed", location: "Gothenburg" })
require "stationery"
class ExampleContract < Stationery::Document
INK = "#111827"
MUTED = "#6B7280"
HAIRLINE = "#D1D5DB"
CLAUSES = [
["Definitions", ["“Services” means the development, hosting and support of the Client's delivery-tracking " \
"application described in Schedule 1.",
"“Business Day” means a day other than a Saturday, a Sunday or a public holiday in Sweden.",
"Headings are for convenience only and do not affect how this Agreement is read."]],
["Term", ["This Agreement starts on the Effective Date and runs for twenty-four months.",
"It renews for further periods of twelve months unless either party gives written notice at " \
"least ninety days before the end of the current period."]],
["Services", ["The Provider shall perform the Services with reasonable skill and care, in line with good " \
"industry practice, and through personnel who are suitably qualified.",
"The Provider shall keep the application available 99.5 per cent of each calendar month, " \
"measured as set out in Schedule 2, excluding maintenance announced five Business Days ahead.",
"Changes to the Services are agreed in writing through the change procedure in Schedule 3."]],
["Fees and payment", ["The Client shall pay the fees set out in Schedule 4, monthly in arrears, within " \
"thirty days of the date of each invoice.",
"Fees are exclusive of value added tax, which the Client pays at the rate in force.",
"Late payments carry interest at the reference rate of the Riksbank plus eight " \
"percentage points."]],
["Intellectual property", ["Each party keeps the rights it owned before the Effective Date.",
"Rights in work made for the Client under this Agreement pass to the Client when " \
"it has been paid for. The Provider keeps its general tools and know-how."]],
["Confidentiality", ["Each party shall keep the other's confidential information secret and use it only " \
"to perform this Agreement.",
"This clause survives the end of the Agreement for five years."]],
["Data protection", ["Where the Provider processes personal data for the Client, it does so as processor " \
"under the data processing agreement in Schedule 5, which forms part of this Agreement."]],
["Liability", ["Neither party is liable for indirect loss, loss of profit or loss of data, except in the " \
"case of gross negligence or wilful misconduct.",
"Each party's total liability in a contract year is limited to the fees paid in that year."]],
["Termination", ["Either party may terminate this Agreement with immediate effect if the other commits a " \
"material breach and does not remedy it within thirty days of written notice.",
"On termination the Provider shall hand over the Client's data in a common machine-readable " \
"format and then delete it."]],
["Governing law and disputes", ["This Agreement is governed by Swedish law.",
"Disputes are finally settled by arbitration under the Rules for Expedited " \
"Arbitrations of the SCC Arbitration Institute, seated in Gothenburg."]]
].freeze
page size: :a4, margin: [56, 64, 48, 64]
default_text size: 10, color: INK, leading: 2.5
metadata title: "Services agreement", author: "Example Software AB", creator: "stationery example"
# Every page carries a box for each party's initials: a text field per
# party and page, named after the page (`initials.provider.3`) so that each
# is a field of its own. The frame is a box around the field, so it prints
# in viewers that redraw empty fields without their border.
footer(gap: 16) do |page|
row(align: :middle, gap: 12) do
column { text "Services agreement · page #{page.number} of #{page.count}", size: 8, color: MUTED }
column(width: :auto) { text "Initials", size: 8, color: MUTED }
%w[provider client].each do |party|
column(width: 44) do
box(border: { color: MUTED, width: 0.75 }, radius: 3) do
text_field "initials.#{party}.#{page.number}", height: 20, tooltip: "Initials of the #{party}",
border: nil, background: nil
end
end
end
end
end
def self.preview
new(provider: ["Example Software AB", "Org. no. 556000-0000", "Storgatan 1, 411 00 Gothenburg"],
client: ["Sample Freight Ltd", "Company no. 00000000", "1 Test Street, Testville"],
date: "1 October 2026")
end
def initialize(provider:, client:, date:)
super()
@provider = provider
@client = client
@date = date
end
def view_template
text "Services agreement", size: 22, weight: :bold
spacer 4
text "Effective Date: #{@date}", color: MUTED
spacer 16
parties
spacer 10
CLAUSES.each_with_index { |(title, paragraphs), index| clause(index + 1, title, paragraphs) }
signatures
end
private
def parties
text "This Agreement is made between:"
spacer 6
row(gap: 24) do
column { party("THE PROVIDER", @provider) }
column { party("THE CLIENT", @client) }
end
end
def party(label, lines)
text label, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 0.6
text "<b>#{lines.first}</b>\n#{lines.drop(1).join("\n")}", markup: true, size: 9.5
end
# "3. Services" and its sub-clauses 3.1, 3.2, …: the heading keeps at least
# 50 points of what follows on its page, so it never ends one alone.
def clause(number, title, paragraphs)
spacer 12
text "#{number}. #{title}", size: 11.5, weight: :bold, keep_with_next: 50, bookmark: title
spacer 4
paragraphs.each_with_index do |paragraph, index|
row(gap: 8) do
column(width: 30) { text "#{number}.#{index + 1}", color: MUTED }
column { text paragraph, align: :justify, hyphenate: true }
end
spacer 4
end
end
# Kept together, so both parties sign on one page.
def signatures
spacer 24
group(keep_together: true) do
text "Signed by the parties on the dates written below their signatures.", keep_with_next: true
spacer 30
row(gap: 40) do
column { signature_field "signature.provider", label: "For #{@provider.first}", height: 48 }
column { signature_field "signature.client", label: "For #{@client.first}", height: 48 }
end
end
end
end
if $PROGRAM_NAME == __FILE__
contract = ExampleContract.preview
contract.to_pdf(File.expand_path("contract.pdf", __dir__))
warn contract.warnings.map(&:message) if contract.warnings.any?
puts "wrote examples/contract.pdf"
endCertificate#
A certificate of completion on a landscape A4 page: a frame and rosettes drawn on a canvas under the content, centred type, and two signature fields for the people who sign it.
Landscape A4 with a frame and rosettes drawn on a canvas in a background page_template, centred type, an SVG seal, and two signature fields for the people who sign it.
The source: examples/certificate.rb
# frozen_string_literal: true
# A certificate of completion on a landscape A4 page: a frame and rosettes
# drawn on a canvas under the content, centred type, and two signature fields
# for the people who sign it. Run it to write examples/certificate.pdf:
#
# ruby -Ilib examples/certificate.rb
# ruby -Ilib exe/stationery render examples/certificate.rb
require "stationery"
class ExampleCertificate < Stationery::Document
GOLD = "#B08D57"
INK = "#1F2937"
MUTED = "#6B7280"
PAPER = "#FFFBF2"
SEAL = <<~SVG
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="48" fill="#B08D57"/>
<circle cx="50" cy="50" r="40" fill="none" stroke="#FFFBF2" stroke-width="1.5" stroke-dasharray="3 2"/>
<path d="M50 24l7.6 15.4 17 2.5-12.3 12 2.9 16.9L50 62.8l-15.2 8 2.9-16.9-12.3-12 17-2.5z" fill="#FFFBF2"/>
</svg>
SVG
page size: :a4, layout: :landscape, margin: [112, 110, 72, 110]
default_text color: INK, align: :center
metadata title: "Certificate of completion", author: "Harbour Institute of Sailing", creator: "stationery example"
# The background is painted under the content of every page: a page
# template on the background layer, with a canvas the size of the page.
page_template(layer: :background) do |page|
canvas(height: page.height, width: page.width, at: [0, 0]) { |c, _rect| frame(c, page.width, page.height) }
end
def self.preview
new(name: "Robin Example", course: "Coastal Skipper, practical course",
hours: 64, date: "12 September 2026", number: "HIS-2026-0419")
end
def initialize(name:, course:, hours:, date:, number:)
super()
@name = name
@course = course
@hours = hours
@date = date
@number = number
end
def view_template
text "HARBOUR INSTITUTE OF SAILING", size: 10, weight: :bold, color: GOLD, letter_spacing: 3
spacer 18
text "Certificate of completion", size: 34, weight: :bold
spacer 18
text "This is to certify that", size: 12, color: MUTED, style: :italic
spacer 10
text @name, size: 30, weight: :bold, color: GOLD
spacer 12
text "has completed the #{@course}, #{@hours} hours afloat and ashore,\n" \
"and has shown the seamanship the course asks for.", size: 12, leading: 4
spacer 60
signatures
end
private
# Two signature fields with the seal between them; the signers sign in
# their PDF viewer, or with `to_pdf(sign: { …, field: "director" })`.
def signatures
row(gap: 40, align: :bottom) do
column { signature_field "director", label: "Anna Example, Director of Training", height: 44 }
column(width: 70) { svg SEAL, width: 70, align: :center }
column { signature_field "examiner", label: "Jonas Sample, Examiner", height: 44 }
end
spacer 16
text "Awarded #{@date} · Certificate #{@number}", size: 8.5, color: MUTED
end
# A double frame with rosettes in the corners, in page coordinates from
# the top left.
def frame(canvas, width, height)
canvas.fill_rect(0, 0, width, height, color: PAPER)
canvas.rounded_rect(24, 24, width - 48, height - 48, radius: 4, stroke: GOLD, line_width: 2.5)
canvas.rounded_rect(34, 34, width - 68, height - 68, radius: 2, stroke: GOLD, line_width: 0.75)
[[34, 34], [width - 34, 34], [34, height - 34], [width - 34, height - 34]].each do |x, y|
rosette(canvas, x, y)
end
canvas.line((width / 2) - 60, 88, (width / 2) + 60, 88, color: GOLD, width: 0.75)
end
def rosette(canvas, x, y)
canvas.circle(x, y, 14, fill: PAPER, stroke: GOLD, line_width: 1)
8.times do |i|
angle = i * Math::PI / 4
canvas.line(x, y, x + (12 * Math.cos(angle)), y + (12 * Math.sin(angle)), color: GOLD, width: 0.6)
end
canvas.circle(x, y, 4, fill: GOLD)
end
end
if $PROGRAM_NAME == __FILE__
certificate = ExampleCertificate.preview
certificate.to_pdf(File.expand_path("certificate.pdf", __dir__))
warn certificate.warnings.map(&:message) if certificate.warnings.any?
puts "wrote examples/certificate.pdf"
endRésumé#
A one-page résumé in two columns of unequal width: a tinted sidebar with contact links and skills, and the experience written as HTML, the way it comes out of a rich-text editor.
Two columns of unequal width (column(width: 0.3) and the rest): a tinted sidebar with mailto:, tel: and web links and a wrap of skill chips, and the experience written as HTML with a <style> rule, read by html with its links.
The source: examples/resume.rb
# frozen_string_literal: true
# A one-page résumé in two columns of unequal width: a tinted sidebar with
# contact links and skills, and the experience written as HTML, the way it
# comes out of a rich-text editor. Run it to write examples/resume.pdf:
#
# ruby -Ilib examples/resume.rb
# ruby -Ilib exe/stationery render examples/resume.rb
require "stationery"
class ExampleResume < Stationery::Document
ACCENT = "#2563EB"
INK = "#111827"
MUTED = "#6B7280"
SIDEBAR = "#F1F5F9"
# What the person wrote in the application's editor: `html` reads it with
# the styles given below, and turns its links into links.
EXPERIENCE = <<~HTML
<style>.when { color: #6B7280; font-size: 8.5pt }</style>
<h2>Profile</h2>
<p>Engineer who likes the unglamorous parts of logistics software: the queues, the retries and the
documents at the end of them. Ten years of Ruby, most of them with a pager.</p>
<h2>Experience</h2>
<h3>Lead engineer · Harbour Logistics, Gothenburg</h3>
<p class="when">2022 – today</p>
<p>Leads a team of six building the routing platform that plans 40,000 deliveries a day.</p>
<ul>
<li>Moved route planning from a nightly batch to <b>live replanning</b>, cutting late deliveries by a third.</li>
<li>Introduced contract tests between the eleven services the platform is made of.</li>
<li>Mentors two engineers a year through the company's <a href="https://harbour-logistics.example/graduates">graduate programme</a>.</li>
</ul>
<h3>Software engineer · Northwind Freight, Malmö</h3>
<p class="when">2018 – 2022</p>
<p>Built the customer portal and the <i>proof of delivery</i> service, from the first commit to
two million documents a month.</p>
<ul>
<li>Replaced a PDF service in Java with one in Ruby that renders in a tenth of the time.</li>
<li>Ran the on-call rotation for the portal and wrote its runbooks.</li>
</ul>
<h3>Junior developer · Example Studio, Lund</h3>
<p class="when">2016 – 2018</p>
<p>Web shops and booking systems for small businesses in southern Sweden.</p>
<h2>Education</h2>
<h3>MSc Computer Science · Lund University</h3>
<p class="when">2011 – 2016</p>
<p>Thesis on incremental layout of long documents, graded with distinction.</p>
HTML
STYLES = {
h2: { size: 13, color: ACCENT }, h3: { size: 10.5 }, p: { color: INK },
a: { color: ACCENT }, ul: { gap: 3, marker_color: ACCENT }
}.freeze
page size: :a4, margin: [44, 44, 40, 44]
default_text size: 9.5, color: INK, leading: 2
metadata title: "Résumé of Kim Example", author: "Kim Example", creator: "stationery example"
def self.preview
new(name: "Kim Example", title: "Lead software engineer",
contact: [["[email protected]", "mailto:[email protected]"], ["+46 70 000 00 00", "tel:+46700000000"],
["kim.example.com", "https://kim.example.com"], ["Gothenburg, Sweden", nil]],
skills: %w[Ruby Rails PostgreSQL Kafka Kubernetes Terraform TypeScript],
languages: [%w[Swedish native], %w[English fluent], %w[German good]])
end
def initialize(name:, title:, contact:, skills:, languages:)
super()
@name = name
@title = title
@contact = contact
@skills = skills
@languages = languages
end
def view_template
text @name, size: 28, weight: :bold
text @title, size: 12, color: ACCENT
spacer 18
# A sidebar of 30 % and a body that takes the rest: `width:` is a share
# of the row, and a column without one takes what is left.
row(gap: 24) do
column(width: 0.3) { sidebar }
column { html EXPERIENCE, styles: STYLES, gap: 5 }
end
end
private
def sidebar
box(background: SIDEBAR, radius: 6, padding: 14) do
label "CONTACT"
@contact.each do |shown, target|
text shown, link: target, color: target ? ACCENT : INK, size: 9
spacer 2
end
spacer 12
label "SKILLS"
wrap(gap: 4, row_gap: 4) do
@skills.each do |skill|
box(width: :auto, background: "#FFFFFF", radius: 8, padding: [2, 7]) { text skill, size: 8 }
end
end
spacer 12
label "LANGUAGES"
@languages.each { |language, level| text "<b>#{language}</b>, #{level}", markup: true, size: 9 }
end
end
def label(title)
text title, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 0.8
spacer 5
end
end
if $PROGRAM_NAME == __FILE__
resume = ExampleResume.preview
resume.to_pdf(File.expand_path("resume.pdf", __dir__))
warn resume.warnings.map(&:message) if resume.warnings.any?
puts "wrote examples/resume.pdf"
endAccessible report#
A survey report written for screen readers as a PDF/UA-1 file: headings in order, a chart and a photo as figures with their `alt:` text, a table whose header cells a reader announces with every value, and a language and title of its own. `rake verify:conformance` validates it with veraPDF.
PDF/UA-1 (conformance :pdf_ua1): headings in order, a photo and an SVG bar chart as figures with their alt: text, a table whose header row is tagged as header cells, a list, a title and a language. Validated as PDF/UA-1 and PDF/A-3b with veraPDF in CI.
The source: examples/accessible_report.rb
# frozen_string_literal: true
# A survey report written for screen readers as a PDF/UA-1 file:
# headings in order, a chart and a photo as figures with their `alt:` text,
# a table whose header cells a reader announces with every value, and a
# language and title of its own. `rake verify:conformance` validates it with
# veraPDF. Run it to write examples/accessible_report.pdf:
#
# ruby -Ilib examples/accessible_report.rb
# ruby -Ilib exe/stationery render examples/accessible_report.rb
require "stationery"
class ExampleAccessibleReport < Stationery::Document
PHOTO = File.expand_path("assets/hills.png", __dir__)
ACCENT = "#1D4ED8"
INK = "#111827"
MUTED = "#4B5563"
HAIRLINE = "#D1D5DB"
ZEBRA = "#F3F4F6"
page size: :a4, margin: [56, 60, 48, 60]
default_text size: 10.5, color: INK, leading: 3
# PDF/UA-1 asks for a title and a language, and turns tagging on. What it
# cannot keep (a figure without `alt:`, a heading level skipped) raises
# instead of writing a file that claims more than it is.
metadata title: "Cycling to school: survey 2026", author: "Example County Council", lang: "en",
creator: "stationery example"
conformance :pdf_ua1
footer { |page| text "Page #{page.number} of #{page.count}", size: 8, color: MUTED, align: :center }
def self.preview
new(schools: [["Northfield", 412, 38, 44], ["Riverside", 288, 51, 57], ["Hillcrest", 530, 22, 31],
["Harbour", 196, 44, 49], ["Meadow Lane", 351, 29, 41]])
end
def initialize(schools:)
super()
@schools = schools
end
def view_template
text "Cycling to school", size: 26, weight: :bold, heading: 1
text "Results of the 2026 travel survey in five primary schools", size: 13, color: MUTED
spacer 18
summary
findings
method_section
end
private
def heading(title, level: 2)
spacer 14
text title, size: level == 2 ? 16 : 12.5, weight: :bold, color: ACCENT, heading: level, keep_with_next: 60
spacer 6
end
def summary
heading "Summary"
text "More children cycle to school than a year ago in every school surveyed. Across the five schools the " \
"share rose from 35 to 45 per cent after the new cycle paths opened in March. The largest change was " \
"at Hillcrest, where a safe crossing on the main road was the most requested improvement."
spacer 10
image PHOTO, width: 1.0, height: 120, fit: :cover, radius: 4,
alt: "Green hills under a yellow morning sky: the valley the new cycle paths run through"
end
def findings
heading "Findings"
heading "Share of pupils who cycle", level: 3
svg chart, width: 475, color: ACCENT,
alt: "Bar chart of the share of pupils who cycle, 2025 and 2026. Northfield 38 to 44 per cent, " \
"Riverside 51 to 57, Hillcrest 22 to 31, Harbour 44 to 49, Meadow Lane 29 to 41."
spacer 4
text "Light bars 2025, dark bars 2026.", size: 8.5, color: MUTED
heading "By school", level: 3
school_table
end
# A header row is tagged as TH cells with a column scope, so a screen
# reader names the column with every value it reads.
def school_table
rows = [["School", "Pupils", "Cycling 2025", "Cycling 2026", "Change"]]
@schools.each do |name, pupils, before, after|
rows << [name, pupils.to_s, "#{before}%", "#{after}%", format("%+d points", after - before)]
end
table(rows, header: true, width: :full, widths: [nil, 60, 80, 80, 70],
cell: { padding: [5, 8], borders: [:bottom], border_color: HAIRLINE, size: 9.5 }) do |t|
t.row(0).set(weight: :bold, border_color: INK)
t.columns(1..).align = :right
t.zebra(from: 1, color: ZEBRA)
end
end
def method_section
heading "Method"
text "Every pupil in years three to six answered five questions in class in the first week of May, as in " \
"2025. Answers were collected on paper and entered twice by different people. The response rate " \
"was 94 per cent; absent pupils were not followed up."
spacer 8
ul(gap: 4, marker_color: ACCENT) do
li "Cycling means cycling for at least part of the way on three days or more of the week."
li "Pupils who walk and cycle on different days were counted as cycling."
li "Figures are rounded to whole percentage points."
end
end
# The bars as SVG, 2025 light and 2026 in the accent colour (currentColor).
def chart
bars = @schools.each_with_index.map do |(name, _, before, after), index|
x = 40 + (index * 90)
<<~BAR
<rect x="#{x}" y="#{150 - (before * 2)}" width="30" height="#{before * 2}" fill="#BFDBFE"/>
<rect x="#{x + 32}" y="#{150 - (after * 2)}" width="30" height="#{after * 2}" fill="currentColor"/>
<text x="#{x + 31}" y="168" font-size="11" text-anchor="middle" fill="#4B5563">#{name}</text>
BAR
end
<<~SVG
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 490 175">
<line x1="30" y1="150" x2="490" y2="150" stroke="#9CA3AF"/>
#{bars.join}
</svg>
SVG
end
end
if $PROGRAM_NAME == __FILE__
report = ExampleAccessibleReport.preview
report.to_pdf(File.expand_path("accessible_report.pdf", __dir__))
warn report.warnings.map(&:message) if report.warnings.any?
puts "wrote examples/accessible_report.pdf"
endMultilingual notice#
A notice to residents in English, German and Swedish side by side, each column justified and hyphenated by the patterns of its own language, with a Japanese paragraph when a CJK font is at hand.
English, German and Swedish side by side, each column justified and hyphenated by the patterns of its language (hyphenate: true, "de", "sv"). With CJK_FONT naming a font that has Japanese, a Japanese paragraph is drawn from it through font_fallbacks and breaks between ideographs; Inter and the font packs have no CJK glyphs.
The source: examples/multilingual_notice.rb
# frozen_string_literal: true
# A notice to residents in English, German and Swedish side by side, each
# column justified and hyphenated by the patterns of its own language, with
# a Japanese paragraph when a CJK font is at hand. Run it to write
# examples/multilingual_notice.pdf:
#
# ruby -Ilib examples/multilingual_notice.rb
# ruby -Ilib exe/stationery render examples/multilingual_notice.rb
#
# Inter, the font the gem bundles, has Latin, Greek and Cyrillic but no CJK
# ideographs, and no font pack has them either. Name a font that has them to
# add the Japanese paragraph; it is registered as a fallback, so it draws only
# the characters Inter lacks, and the lines break between ideographs:
#
# CJK_FONT=/path/to/NotoSansJP-Regular.ttf ruby -Ilib examples/multilingual_notice.rb
require "stationery"
class ExampleMultilingualNotice < Stationery::Document
ACCENT = "#0E7490"
INK = "#111827"
MUTED = "#6B7280"
PANEL = "#ECFEFF"
CJK_FONT = ENV.fetch("CJK_FONT", nil)
# Each language with the hyphenation patterns to break its words by:
# `true` is American English, "de" German and "sv" Swedish.
NOTICES = [
["English", true, "Water will be turned off",
["The water supply to every flat in the building will be turned off on Tuesday 14 October between " \
"08:00 and 16:00 while the main distribution pipes in the basement are replaced.",
"Please fill a few containers with drinking water beforehand and keep the taps closed during the " \
"work. Discoloured water may run for a short while afterwards; let the cold tap run until it is clear."]],
["Deutsch", "de", "Das Wasser wird abgestellt",
["Am Dienstag, dem 14. Oktober, wird die Wasserversorgung aller Wohnungen im Gebäude zwischen 08:00 " \
"und 16:00 Uhr unterbrochen, da die Hauptverteilungsleitungen im Keller erneuert werden.",
"Bitte füllen Sie vorher einige Behälter mit Trinkwasser und lassen Sie die Wasserhähne während der " \
"Arbeiten geschlossen. Danach kann das Wasser kurzzeitig verfärbt sein; lassen Sie das kalte Wasser " \
"laufen, bis es klar ist."]],
["Svenska", "sv", "Vattnet stängs av",
["Tisdagen den 14 oktober stängs vattnet av i alla lägenheter i huset mellan klockan 08.00 och 16.00, " \
"medan huvudledningarna i källaren byts ut.",
"Fyll gärna några kärl med dricksvatten i förväg och håll kranarna stängda under arbetet. Efteråt kan " \
"vattnet vara missfärgat en kort stund; spola kallvatten tills det är klart."]]
].freeze
JAPANESE = "10月14日(火)8時から16時まで、地下の給水管の交換工事のため、建物内のすべての住戸で断水します。" \
"事前に飲料水をご用意いただき、工事中は蛇口を閉めておいてください。工事後、しばらく濁った水が出ることが" \
"ありますので、透明になるまで水を流してください。"
page size: :a4, margin: [52, 48, 44, 48]
default_text font: "Inter", size: 10.5, color: INK, leading: 3
metadata title: "Notice to residents", author: "Example Housing Cooperative", creator: "stationery example"
# A font for what Inter lacks: drawn only where Inter has no glyph.
if CJK_FONT
font_family "CJK", regular: CJK_FONT
font_fallbacks "CJK"
end
def self.preview = new
def view_template
text "NOTICE · HINWEIS · MEDDELANDE", size: 9, weight: :bold, color: ACCENT, letter_spacing: 2
spacer 6
text "Harbour View Housing Cooperative, 1–5 Example Street", size: 10, color: MUTED
spacer 8
rule height: 2, color: ACCENT
spacer 16
row(gap: 18) do
NOTICES.each do |language, hyphenate, title, paragraphs|
column { notice(language, hyphenate, title, paragraphs) }
end
end
spacer 18
japanese
spacer 18
contact
end
private
def notice(language, hyphenate, title, paragraphs)
text language.upcase, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 1
spacer 4
text title, size: 15, weight: :bold, color: ACCENT
spacer 6
paragraphs.each do |paragraph|
text paragraph, align: :justify, hyphenate: hyphenate
spacer 6
end
end
def japanese
box(background: PANEL, radius: 6, padding: 12) do
text "JAPANESE", size: 7.5, weight: :bold, color: MUTED, letter_spacing: 1
spacer 4
if CJK_FONT
text JAPANESE, leading: 4 # Inter for the digits, the CJK font for the rest
else
text "Japanese: set CJK_FONT to a font with Japanese glyphs to include this paragraph. Inter, the " \
"font in the gem, has none; see the comment at the top of examples/multilingual_notice.rb.",
size: 8.5, color: MUTED
end
end
end
def contact
rule color: "#E5E7EB"
spacer 6
text "Questions · Fragen · Frågor: [email protected] · +46 31 000 00 00",
size: 8.5, color: MUTED, align: :center
end
end
if $PROGRAM_NAME == __FILE__
notice = ExampleMultilingualNotice.preview
notice.to_pdf(File.expand_path("multilingual_notice.pdf", __dir__))
warn notice.warnings.map(&:message) if notice.warnings.any?
puts "wrote examples/multilingual_notice.pdf"
end


















