Getting started

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 own

They 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 stationery

The 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.

Open the PDF · Source on GitHub

The first page of the event flyer example
The source: examples/flyer.rb
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"
end

Invoice#

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

A row with a logo, an amount-due callout with a status pill, a summary table without borders and a line-item table with a coloured header, right-aligned money columns, zebra rows and a bold totals row.

Open the PDF · Source on GitHub

The first page of the invoice example
The source: examples/invoice.rb
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"
end

Factur-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.

Open the PDF · Source on GitHub

The first page of the factur-x e-invoice example
The source: examples/e_invoice.rb
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
end

if $PROGRAM_NAME == __FILE__
  ExampleEInvoice.preview.to_pdf(File.expand_path("e_invoice.pdf", __dir__))
  puts "wrote examples/e_invoice.pdf"
end

Annual 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.

Open the PDF · Source on GitHub

The first page of the annual report example
The source: examples/report.rb
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"
end

Letter#

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.

Open the PDF · Source on GitHub

The first page of the letter example
The source: examples/letter.rb
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"
end

Packing slip#

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

Landscape A4, page numbers in the header, a 120-row table with split_rows: true and a repeating header, and a barcode drawn on a canvas.

Open the PDF · Source on GitHub

The first page of the packing slip example
The source: examples/packing_slip.rb
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"
end

Shipping 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.

Open the PDF · Source on GitHub

The first page of the shipping label example
The source: examples/shipping_label.rb
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"
end

Receipt#

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.

Open the PDF · Source on GitHub

The first page of the receipt example
The source: examples/receipt.rb
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"
end

Application form#

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

An interactive AcroForm: labelled text fields, 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.

Open the PDF · Source on GitHub

The first page of the application form example
The source: examples/form.rb
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"
end

Price 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.

Open the PDF · Source on GitHub

The first page of the price card example
The source: examples/price_card.rb
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"
end

Magazine 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.

Open the PDF · Source on GitHub

The first page of the magazine article example
The source: examples/article.rb
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"
end

Postcard#

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.

Open the PDF · Source on GitHub

The first page of the postcard example
The source: examples/postcard.rb
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"
end

Newsletter#

A one-page newsletter: a masthead across the page, an article poured through two balanced columns with headings and a photo, and a footer note across the page below the columns.

A masthead across the page, then an article poured through two balanced columns with a rule between them: justified, hyphenated paragraphs with orphans: and widows:, headings kept with what follows, a photo with its caption kept together, and a note across the page below the columns.

Open the PDF · Source on GitHub

The first page of the newsletter example
The source: examples/newsletter.rb
examples/newsletter.rb
# frozen_string_literal: true

# A one-page newsletter: a masthead across the page, an article poured
# through two balanced columns with headings and a photo, and a footer note
# across the page below the columns.
# Run it to write examples/newsletter.pdf:
#
#   ruby -Ilib examples/newsletter.rb
#   ruby -Ilib exe/stationery render examples/newsletter.rb
require "stationery"

class ExampleNewsletter < Stationery::Document
  ASSETS = File.expand_path("assets", __dir__)
  INK = "#1F2937"
  MUTED = "#6B7280"
  ACCENT = "#0F766E"
  LINE = "#D1D5DB"

  ARTICLE = [
    [nil, ["The harbour wall that has kept the winter swell out of the village since 1911 was reopened on " \
           "Saturday, eighteen months after a storm took forty metres of it into the bay. Two hundred people " \
           "walked its length behind the brass band, and the first boat through the gap was the oldest in the " \
           "fleet.",
           "The rebuilt section stands a metre higher than the old one. Its core is concrete, but the face is " \
           "the original sandstone: divers recovered nine blocks in ten from the seabed, and the quarry above " \
           "the village was opened for a month to cut the rest."]],
    ["What it cost", ["The work came to 2.4 million, a third of it raised by the village itself. The harbour " \
                      "dues will rise by four per cent a year until 2031 to repay the loan, a rise the " \
                      "fishing cooperative voted for without a single vote against.",
                      "Nothing was spent on the lighthouse, which needs a new lantern. The council has asked " \
                      "for tenders and expects to decide in the spring."]],
    ["What comes next", ["The inner quay is next. Its timber piles date from the fifties and the survey gives " \
                         "them ten years at most. Plans go on show in the old customs house in March, and " \
                         "the council wants comments before the summer.",
                         "Until then the wall is open to walkers from dawn to dusk. The gate at the landward " \
                         "end is locked in any wind above force seven."]]
  ].freeze

  page size: :a4, margin: 48
  default_text size: 10, color: INK, leading: 2.5
  metadata title: "The Harbour Letter", author: "stationery example", lang: "en"
  tagged

  def self.preview = new

  def view_template
    masthead
    columns(count: 2, gap: 22, rule: { color: LINE }) { article }
    note
  end

  private

  def masthead
    text "THE HARBOUR LETTER", size: 9, weight: :bold, color: ACCENT, letter_spacing: 1.5
    spacer 6
    text "The sea wall is whole again", size: 30, weight: :bold, leading: 0, heading: 1
    spacer 6
    text "Issue 42 · October 2026", size: 9, color: MUTED
    spacer 10
    rule height: 2, color: ACCENT
    spacer 16
  end

  def article
    ARTICLE.each_with_index do |(title, paragraphs), index|
      section_heading(title) if title
      paragraphs.each { |paragraph| body(paragraph) }
      photo if index.zero?
    end
  end

  def section_heading(title)
    text title, size: 13, weight: :bold, color: ACCENT, leading: 0, heading: 2, keep_with_next: true
    spacer 5
  end

  def body(paragraph)
    text paragraph, align: :justify, hyphenate: true, orphans: 2, widows: 2
    spacer 8
  end

  def photo
    group(keep_together: true) do
      image File.join(ASSETS, "bay.png"), width: 1.0, height: 120, fit: :cover, radius: 6,
                                          alt: "The bay seen from the rebuilt wall"
      spacer 4
      text "The bay from the new section of the wall.", size: 8, color: MUTED, style: :italic
    end
    spacer 8
  end

  def note
    spacer 16
    rule color: LINE
    spacer 8
    text "The Harbour Letter is written by volunteers and printed by the cooperative. Letters to the " \
         "editor are welcome at the customs house, where back issues are kept.",
         size: 8.5, color: MUTED, align: :center
  end
end

if $PROGRAM_NAME == __FILE__
  newsletter = ExampleNewsletter.preview
  newsletter.to_pdf(File.expand_path("newsletter.pdf", __dir__))
  warn newsletter.warnings.map(&:message) if newsletter.warnings.any?
  puts "wrote examples/newsletter.pdf"
end

Price 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.

Open the PDF · Source on GitHub

The first page of the price list example
The source: examples/price_list.rb
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"
end

Contract#

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.

Open the PDF · Source on GitHub

The first page of the contract example
The source: examples/contract.rb
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"
end

Certificate#

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.

Open the PDF · Source on GitHub

The first page of the certificate example
The source: examples/certificate.rb
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"
end

Ré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.

Open the PDF · Source on GitHub

The first page of the résumé example
The source: examples/resume.rb
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"
end

Accessible 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.

Open the PDF · Source on GitHub

The first page of the accessible report example
The source: examples/accessible_report.rb
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"
end

Multilingual 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.

Open the PDF · Source on GitHub

The first page of the multilingual notice example
The source: examples/multilingual_notice.rb
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