# Getting started Getting started # Getting started Install the gem, write a document class, call to_pdf. No fonts, binaries or services to set up. ## Install One gem, standard library only. ```ruby gem "stationery" ``` Stationery needs **Ruby 3.4 or newer** and has no runtime dependencies: no Prawn, no headless Chrome, no native extensions and no other processes. In a Rails app require the Railtie instead — see [Rails](https://stationery.zoolutions.llc/docs/rails): ```ruby gem "stationery", require: "stationery/rails" ``` ## Your first document A class, a view_template, to_pdf. A document is a `Stationery::Document` subclass. `view_template` describes the page with [elements](https://stationery.zoolutions.llc/docs/elements); `to_pdf` lays them out, paginates and returns the PDF bytes. ```ruby require "stationery" class Hello < Stationery::Document def view_template = text("Hello, world", size: 24, weight: :bold) end File.binwrite("hello.pdf", Hello.new.to_pdf) ``` No fonts to configure: **Inter** (regular, bold, italic, bold italic) ships inside the gem and is used until you declare a `font_family`. See [Fonts](https://stationery.zoolutions.llc/docs/fonts) for your own families. ## to_pdf Bytes, a file, or any IO. ```ruby pdf = Hello.new.to_pdf # => "%PDF-1.7…" (binary String) Hello.new.to_pdf("hello.pdf") # also writes a path… Hello.new.to_pdf($stdout) # …or anything that responds to #write Hello.new.to_pdf(strict: true) # raise instead of writing when layout warned Hello.new.to_pdf(debug: true) # outline every layout rectangle ``` After a render, `document.warnings` lists everything the engine noticed but did not raise on — see [Warnings and strict mode](https://stationery.zoolutions.llc/docs/warnings). ## A real document Page setup, fonts, a page template, a table. ```ruby class InvoicePdf < Stationery::Document page size: :a4, margin: [40, 44, 56, 44] font_family "Inter", regular: "fonts/Inter-Regular.ttf", bold: "fonts/Inter-Bold.ttf" default_text font: "Inter", size: 9, color: "#1F2937" metadata title: "Invoice" page_template do |page| box(at: [44, page.height - 34], width: page.content_box.width) do text "Page #{page.number} of #{page.count}", size: 7, align: :right end end def initialize(invoice) super() @invoice = invoice end def view_template row(align: :middle) do column(width: 0.5) { text "Invoice #{@invoice.number}", size: 22, weight: :bold } column(width: 0.5, align: :right) { image "logo.png", height: 34, align: :right } end rule height: 3, color: "#4F46E5" spacer 18 box(background: "#F3F4F6", radius: 6, padding: 12, width: 190) do text "AMOUNT DUE", size: 8, weight: :bold, letter_spacing: 0.5 text @invoice.total, size: 19, weight: :bold, color: "#4F46E5" end table(@invoice.rows, width: :full, widths: [nil, 45, 80, 85], header: true, cell: { borders: [] }) do |t| t.row(0).set(background: "#4F46E5", color: "#FFFFFF", weight: :bold) t.columns(1..).align = :right t.zebra(from: 2, color: "#F9FAFB") end end end ``` No cursor arithmetic anywhere: padding, backgrounds, borders, radii, column widths and page breaks are the engine's job. The [Cookbook](https://stationery.zoolutions.llc/docs/cookbook) walks through four complete documents. ## Render from the command line stationery render FILE. The gem ships a `stationery` executable. `render` loads a Ruby file, finds the `Stationery::Document` it defines and writes the PDF next to it: ```shell stationery render hello.rb # writes hello.pdf stationery render invoice.rb --out - > a.pdf ``` A document whose `initialize` needs arguments renders from a `def self.preview` class method that returns an instance built with sample data. Every option is on the [CLI](https://stationery.zoolutions.llc/docs/cli) page. ## The docs for an agent Over MCP, as llms.txt, and every page as Markdown. A coding agent reads these pages as tools over the [Model Context Protocol](https://modelcontextprotocol.io): `search_docs` finds sections, `get_page` reads a page as Markdown and `list_pages` names them all. In Claude Code: ```shell claude mcp add --transport http stationery https://stationery.zoolutions.llc/mcp ``` Any other MCP client (Claude.ai, Cursor, Codex) takes the same URL as a remote server over HTTP: ```json { "mcpServers": { "stationery": { "url": "https://stationery.zoolutions.llc/mcp" } } } ``` It is read-only and needs no key. Without MCP, [/llms.txt](https://stationery.zoolutions.llc/llms.txt) is the index of the pages and [/llms-full.txt](https://stationery.zoolutions.llc/llms-full.txt) all of them in one file, and each page is Markdown at its URL with `.md`, as `/docs/elements.md`. The [Examples](https://stationery.zoolutions.llc/docs/examples) page carries the source of every example, so an agent gets them there too. #### A skill that ships with the gem The gem carries a skill for coding agents: how a document is built, the rules that surprise (a String is literal, a Float up to 1 is a share of the width, a fixed `height:` never splits), how to render a document to pictures and look at them before calling it done, conformance, labels and printing, and a recipe per thing people ask for (an invoice, a report, a form, a flyer, a label, a receipt), each pointing at the example that shows it. It is written from the README and the examples of the version installed, and carries that version: ```shell stationery skill install # ~/.claude/skills/stationery (and Codex's, ~/.agents' when there) stationery skill install --project # ./.claude/skills/stationery, to commit with the app stationery skill status # current, outdated or missing stationery skill print > stationery.md # one file, for an agent that takes one ``` Install it again after updating the gem; `status` says when it is behind. Every option is on the [CLI](https://stationery.zoolutions.llc/docs/cli) page. ## Where next - [Examples](https://stationery.zoolutions.llc/docs/examples) — twenty complete documents with previews and live PDFs. - [Documents and components](https://stationery.zoolutions.llc/docs/documents-and-components) — class-level configuration and reusable pieces. - [Elements](https://stationery.zoolutions.llc/docs/elements) — every element and its options. - [Forms](https://stationery.zoolutions.llc/docs/forms) — fillable fields, their options and how they behave in a viewer. - [Layout rules](https://stationery.zoolutions.llc/docs/layout-rules) — how measuring, splitting and pagination decide where things land. - [Comparison](https://stationery.zoolutions.llc/docs/comparison) — Stationery next to Prawn and sghtmltopdf, feature by feature. --- # Documents and components Getting started # Documents and components A Document is configured at class level and described in view_template; a Component is a reusable piece of one. ## Document configuration Class-level macros, inherited by subclasses. Everything about the PDF as a whole is declared on the class. Subclasses inherit a copy of the parent's configuration, so a base `ApplicationPdf` can hold the house style. ```ruby class ApplicationPdf < Stationery::Document page size: :a4, margin: [40, 44, 56, 44] font_family "Brand", regular: "fonts/Brand-Regular.ttf", bold: "fonts/Brand-Bold.ttf" font_fallbacks "Noto Sans Symbols" default_text font: "Brand", size: 9, color: "#1F2937" metadata author: "Acme Studio AB", creator: "stationery" end class InvoicePdf < ApplicationPdf metadata title: "Invoice" strict end ``` | Macro | Arguments | Default | Description | | --- | --- | --- | --- | | `page` | size:, margin:, layout: | size: :letter, margin: 36, layout: :portrait | Page size (a name such as `:a4`, `:letter`, `:dl` or `:label_4x6`, `[width, height]` in points, or with units: `["102mm", "74mm"]`, `"4in x 6in"`), margins (one value or CSS-style 2/4 values, in points or as `"3mm"`) and `:landscape`. Checked when the class is defined. See [Pages](https://stationery.zoolutions.llc/docs/pages). | | `font_family` | name, regular:, bold:, italic:, bold_italic: | — | Registers a family from `.ttf`/`.otf` files. With no paths it selects bundled Inter or an installed [font pack](https://stationery.zoolutions.llc/docs/fonts). | | `font_fallbacks` | *names | [] | Families tried, in order, for characters the text's own family has no glyph for; bundled Inter is tried last. | | `shaper` | shaper | nil | An object answering `call(text, font, **options)` that places the glyphs of every text, for complex scripts; also `to_pdf(shaper:)`. See [Fonts](https://stationery.zoolutions.llc/docs/fonts). | | `default_text` | **style | {} | Base text style for the whole document: `font`, `size`, `color`, `align`, `leading`, `kerning`, … | | `metadata` | title:, author:, subject:, keywords:, creator:, producer: | {} | The PDF Info dictionary, mirrored in an XMP packet (`/Metadata`: `dc:title`, `dc:creator`, `dc:subject`, the `xmp:` dates, `pdf:Producer`). An Array value is joined with `", "`; other keys pass through; `xmp: false` leaves the packet out. | | `strict` | value = true | false | Raise `Stationery::WarningsError` instead of writing a PDF that produced warnings. | | `incremental` | value = true | false | Write each page as soon as it is painted, so a long document with headers or footers holds far less; also `to_pdf(incremental:)`. See [Rendering](#rendering). | | `object_streams` | value = true | on when tagged | Pack every object that is not a stream into deflated object streams with a cross-reference stream (PDF 1.5); a tagged render does unless `object_streams false`; also `to_pdf(object_streams:)`. See [Accessibility](https://stationery.zoolutions.llc/docs/pages#accessibility-tagged-pdf). | | `page_template` | layer: :foreground | — | A block run on every page after pagination; `layer: :background` paints under the content. | | `header / footer` | height:, gap:, on: | gap: 8, on: :all | Regions that reserve space at the top/bottom of the pages `on:` matches. | ## Rendering to_pdf and what it leaves behind. `to_pdf(target = nil, strict: , debug: false)` builds the element tree, paginates it, runs page templates, resolves links and bookmarks and returns the PDF as a binary String. With a `target` it also writes to that path or IO. ```ruby document = InvoicePdf.new(invoice) document.to_pdf("invoice.pdf") document.warnings.each { |warning| Rails.logger.warn(warning.message) } document.page_count # => 2, the pages the last render laid out ``` With a block, `to_pdf { |chunk| … }` streams the file in pieces as it is written and answers the number of bytes: the first bytes leave sooner and no output buffer is built. Every page is laid out and painted before the first byte. A signed document cannot go to a block. `incremental: true` (or `incremental` at class level) writes each page's content as soon as the page is painted and lets go of it, to a block before the next page is painted. It is for long documents with headers, footers, page templates or a table of contents, which are painted once every page is known and become content streams of their own; the file lists its page contents first and is a little larger. A render with `conformance:` or `sign:`, and a `strict` one that goes to a block, take the usual path, since they must be checked before a byte is written. To a block, an error on a late page leaves the start of a file. The figures are on the [Performance](https://stationery.zoolutions.llc/docs/performance#large-documents) page. `strict: false` opts one render out of a class-level `strict`; `debug:` outlines layout rectangles (see [Pages](https://stationery.zoolutions.llc/docs/pages#debug-outlines)). ## Components Phlex's lifecycle, the same element DSL. A `Stationery::Component` is a reusable piece of a document with Phlex's lifecycle — `around_template`, `before_template`, `view_template`, `after_template` — and every element. Content passed as a block is yielded from `view_template`: ```ruby class Callout < Stationery::Component def initialize(color:) = (super(); @color = color) def view_template(&) = box(background: @color, padding: 12, radius: 6, &) end class Report < Stationery::Document def view_template render Callout.new(color: "#F3F4F6") { text "Amount due" } end end ``` A `Document` is itself a component, so documents can render other components, and components can render components. ## render What it accepts. | Argument | Renders | | --- | --- | | A component instance | `render Callout.new(color: "#EEE") { … }` — calls it with the block. | | A component class | `render Divider` — instantiated with no arguments. | | A String | A `text` paragraph. | | A Proc or Method | Yielded as content, like a block. | | A list (any Enumerable) | Each item rendered in turn. | Anything else raises `ArgumentError`. Blocks that take an argument receive the component, so `{ |c| c.text @title }` keeps your own `self` inside the block. --- # Examples 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. ```shell 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: ```shell 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](https://stationery.zoolutions.llc/docs/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](https://stationery.zoolutions.llc/examples/flyer.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/flyer.rb) ![The first page of the event flyer example](https://stationery.zoolutions.llc/examples/flyer.png) The source: examples/flyer.rb ```ruby # 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 = '' ICONS = { calendar: '', pin: '', users: '' \ '', clock: '', check: '', quote: '', sun: '' }.freeze LIFE = <<~HTML

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.

Most days follow a loose rhythm:

Every session adapts to who turns up. Nothing is compulsory, and nobody keeps score.

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\nuntil 31 January", "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 hello@harbourside.example") } } 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)}", 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](https://stationery.zoolutions.llc/examples/invoice.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/invoice.rb) ![The first page of the invoice example](https://stationery.zoolutions.llc/examples/invoice.png) The source: examples/invoice.rb ```ruby # 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", ["Acme Studio AB", "Storgatan 1", "111 22 Stockholm", "hello@acme.test"]) end column(width: 0.5) { party("BILL TO", ["#{@customer}", "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? " \ "hello@acme.test", 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](https://stationery.zoolutions.llc/docs/conformance). [Open the PDF](https://stationery.zoolutions.llc/examples/e_invoice.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/e_invoice.rb) ![The first page of the factur-x e-invoice example](https://stationery.zoolutions.llc/examples/e_invoice.png) The source: examples/e_invoice.rb ```ruby # 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: "hello@acme.test", 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 urn:cen.eu:en16931:2017 #{xml(@number)} 380 #{xml_date(ISSUED)} #{@items.each_with_index.map { |item, index| xml_line(index + 1, *item) }.join.chomp} #{xml(SELLER[:name])} #{xml_address(SELLER)} #{xml(SELLER[:email])} #{xml_vat_id(SELLER)} #{xml(@customer)} #{xml_address(BUYER)} #{xml_vat_id(BUYER)} PO-7781 #{xml_date(ISSUED)} EUR 58 #{SELLER[:iban]} #{xml_amount(vat)} VAT #{xml_amount(subtotal)} S #{VAT_RATE} #{xml_date(Date.parse(@due))} #{xml_amount(subtotal)} #{xml_amount(subtotal)} #{xml_amount(vat)} #{xml_amount(total)} #{xml_amount(total)} XML end def xml_line(number, name, quantity, price) <<~XML.gsub(/^/, " ") #{number} #{xml(name)} #{xml_amount(price)} #{quantity} VAT S #{VAT_RATE} #{xml_amount(quantity * price)} XML end def xml_address(party) <<~XML.gsub(/^/, " ").chomp #{xml(party[:postcode])} #{xml(party[:street])} #{xml(party[:city])} #{party[:country]} XML end def xml_vat_id(party) <<~XML.gsub(/^/, " ").chomp #{party[:vat_id]} XML end def xml_date(date) = %(#{date.strftime("%Y%m%d")}) def xml_amount(amount) = format("%.2f", amount) def xml(text) = text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">") end if $PROGRAM_NAME == __FILE__ ExampleEInvoice.preview.to_pdf(File.expand_path("e_invoice.pdf", __dir__)) puts "wrote examples/e_invoice.pdf" 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](https://stationery.zoolutions.llc/examples/report.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/report.rb) ![The first page of the annual report example](https://stationery.zoolutions.llc/examples/report.png) The source: examples/report.rb ```ruby # 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 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.", "Karin Lindqvist, Chief Executive Officer" 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 "#{title}. #{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 CO2e. 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 "Demand. Freight volumes follow industrial production. Long contracts with index clauses " \ "cover about 60 per cent of revenue.", markup: true li "Energy prices. Diesel and electricity are hedged twelve months ahead for three quarters of " \ "expected use.", markup: true li "Drivers. 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 CO2e per tonne-kilometre, well to wheel."]].each do |term, meaning| text "#{term}. #{meaning}", markup: true spacer 4 end subheading "6.2 Five-year summary" five_year_summary spacer 12 text %(Return to the introduction.), 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](https://stationery.zoolutions.llc/examples/letter.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/letter.rb) ![The first page of the letter example](https://stationery.zoolutions.llc/examples/letter.png) The source: examples/letter.rb ```ruby # 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 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 "hello@malaren.studio" 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 three phases over twelve weeks. 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 SEK 480,000 excluding VAT, invoiced at the end of each " \ "phase. The full proposal, with the schedule and the people involved, is available at " \ "malaren.studio/p/MS-2026-118" \ ".", "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](https://stationery.zoolutions.llc/examples/packing_slip.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/packing_slip.rb) ![The first page of the packing slip example](https://stationery.zoolutions.llc/examples/packing_slip.png) The source: examples/packing_slip.rb ```ruby # 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 "#{lines.first}\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](https://stationery.zoolutions.llc/docs/pages). [Open the PDF](https://stationery.zoolutions.llc/examples/shipping_label.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/shipping_label.rb) ![The first page of the shipping label example](https://stationery.zoolutions.llc/examples/shipping_label.png) The source: examples/shipping_label.rb ```ruby # 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 "#{lines.first}\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](https://stationery.zoolutions.llc/examples/receipt.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/receipt.rb) ![The first page of the receipt example](https://stationery.zoolutions.llc/examples/receipt.png) The source: examples/receipt.rb ```ruby # 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](https://stationery.zoolutions.llc/docs/forms). [Open the PDF](https://stationery.zoolutions.llc/examples/form.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/form.rb) ![The first page of the application form example](https://stationery.zoolutions.llc/examples/form.png) The source: examples/form.rb ```ruby # 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 · members@makers.example", size: 7.5, color: MUTED, align: :center end def self.preview = new(name: "Astrid Lindqvist", email: "astrid@example.se", 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](https://stationery.zoolutions.llc/docs/forms). [Open the PDF](https://stationery.zoolutions.llc/examples/price_card.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/price_card.rb) ![The first page of the price card example](https://stationery.zoolutions.llc/examples/price_card.png) The source: examples/price_card.rb ```ruby # 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](https://stationery.zoolutions.llc/examples/article.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/article.rb) ![The first page of the magazine article example](https://stationery.zoolutions.llc/examples/article.png) The source: examples/article.rb ```ruby # 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

Getting there

A sandstone wall beside the trackLeave 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.

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](https://stationery.zoolutions.llc/examples/postcard.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/postcard.rb) ![The first page of the postcard example](https://stationery.zoolutions.llc/examples/postcard.png) The source: examples/postcard.rb ```ruby # 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](https://stationery.zoolutions.llc/examples/newsletter.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/newsletter.rb) ![The first page of the newsletter example](https://stationery.zoolutions.llc/examples/newsletter.png) The source: examples/newsletter.rb ```ruby # 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](https://stationery.zoolutions.llc/examples/price_list.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/price_list.rb) ![The first page of the price list example](https://stationery.zoolutions.llc/examples/price_list.png) The source: examples/price_list.rb ```ruby # 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](https://stationery.zoolutions.llc/docs/conformance). [Open the PDF](https://stationery.zoolutions.llc/examples/contract.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/contract.rb) ![The first page of the contract example](https://stationery.zoolutions.llc/examples/contract.png) The source: examples/contract.rb ```ruby # 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 "#{lines.first}\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](https://stationery.zoolutions.llc/examples/certificate.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/certificate.rb) ![The first page of the certificate example](https://stationery.zoolutions.llc/examples/certificate.png) The source: examples/certificate.rb ```ruby # 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 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 `

Profile

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.

Experience

Lead engineer · Harbour Logistics, Gothenburg

2022 – today

Leads a team of six building the routing platform that plans 40,000 deliveries a day.

Software engineer · Northwind Freight, Malmö

2018 – 2022

Built the customer portal and the proof of delivery service, from the first commit to two million documents a month.

Junior developer · Example Studio, Lund

2016 – 2018

Web shops and booking systems for small businesses in southern Sweden.

Education

MSc Computer Science · Lund University

2011 – 2016

Thesis on incremental layout of long documents, graded with distinction.

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: [["kim@example.com", "mailto:kim@example.com"], ["+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 "#{language}, #{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 ``` ## Menu A restaurant menu on one page: an introduction wrapped around a floated photo, the dishes poured through two balanced columns, a floated box for the dish of the day, and headings set in the heavier weights of Inter. A photo floated left and the dish of the day floated right with the introduction wrapped between them, then the courses poured through two balanced `columns`, each course kept together, with headings in the bold of Inter. [Open the PDF](https://stationery.zoolutions.llc/examples/menu.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/menu.rb) ![The first page of the menu example](https://stationery.zoolutions.llc/examples/menu.png) The source: examples/menu.rb ```ruby # frozen_string_literal: true # A restaurant menu on one page: an introduction wrapped around a floated # photo, the dishes poured through two balanced columns, a floated box for # the dish of the day, and headings set in the heavier weights of Inter. Run # it to write examples/menu.pdf: # # ruby -Ilib examples/menu.rb # ruby -Ilib exe/stationery render examples/menu.rb # # The type is the bundled Inter. For a font pack, install it # (`stationery fonts install noto_serif`) and name it: # `font_family "Noto Serif"`, then `default_text font: "Noto Serif"`. require "stationery" class ExampleMenu < Stationery::Document PHOTO = File.expand_path("assets/bay.png", __dir__) INK = "#1C1917" MUTED = "#78716C" ACCENT = "#9A3412" CREAM = "#FFF7ED" LINE = "#E7E5E4" COURSES = { "Starters" => [ ["Smoked mackerel", "Rye crisp, pickled cucumber, horseradish cream", 145], ["Beetroot tartare", "Goat's cheese, walnuts, dill oil", 135], ["Fish soup", "Saffron broth, mussels, aioli, toasted sourdough", 165], ["Chanterelles on toast", "Brown butter, thyme, aged cheese", 155] ], "Mains" => [ ["Pan-fried cod", "Brown butter, capers, new potatoes, peas", 295], ["Slow-cooked lamb", "Root vegetables, rosemary jus, crispy kale", 325], ["Barley risotto", "Mushrooms, parsley, pecorino, lemon", 245], ["Char with sorrel sauce", "Fennel, dill potatoes, roe", 310], ["Harbour burger", "Aged beef, cheddar, onions, fries", 225] ], "Desserts" => [ ["Cloudberry parfait", "Almond crumble, warm cloudberries", 125], ["Chocolate cake", "Sea salt, crème fraîche", 115], ["Cheese from the island", "Three cheeses, crispbread, quince", 145] ], "To drink" => [ ["House lemonade", "Elderflower or rhubarb", 55], ["Local cider", "Dry, 33 cl", 85], ["Coffee or tea", "Refills are free", 40] ], "Wine by the glass" => [ ["Grüner Veltliner", "Crisp and peppery, with the fish", 125], ["Pinot noir", "Light and red-fruited, with the lamb", 135], ["Sparkling cider", "Méthode traditionnelle, from the island", 110] ] }.freeze page size: :a4, margin: [56, 56, 48, 56] default_text size: 9.5, color: INK, leading: 2 metadata title: "Menu · The Harbour Kitchen", author: "The Harbour Kitchen", creator: "stationery example" def self.preview = new(special: ["Grilled herring", "Mustard sauce, mashed potatoes, lingonberries", 185]) def initialize(special:) super() @special = special end def view_template masthead introduction spacer 16 # One flow of courses poured through two columns: column one top to # bottom, then column two, ending level where the dishes run out. columns(count: 2, gap: 28, rule: { color: LINE }) do COURSES.each { |course, dishes| course(course, dishes) } end spacer 14 rule color: LINE spacer 6 text "Tell us about allergies before you order. All prices in SEK, service included.", size: 8, color: MUTED, align: :center end private def masthead text "THE HARBOUR KITCHEN", size: 10, weight: :bold, color: ACCENT, letter_spacing: 3, align: :center spacer 4 text "Autumn menu", size: 34, weight: :bold, align: :center spacer 14 end # The photo floats left and the text wraps beside it; the dish of the day # floats right, between them, in a box of its own. def introduction image PHOTO, float: :left, width: 0.34, height: 104, fit: :cover, radius: 6, margin: 14 box(float: :right, width: 150, margin: { left: 14, bottom: 6 }, background: CREAM, radius: 6, padding: 10) do text "DISH OF THE DAY", size: 7, weight: :bold, color: ACCENT, letter_spacing: 1 spacer 3 name, description, price = @special text name, size: 11, weight: :bold text description, size: 8.5, color: MUTED spacer 3 text price.to_s, weight: :bold, color: ACCENT end text "Everything on this menu comes from within a day's drive of the harbour: the fish from the boats " \ "you can see from your table, the vegetables from two farms on the island and the cheese from the " \ "dairy up the hill. The menu changes with the catch and the season, so some dishes may run out " \ "before the evening does.", align: :justify, hyphenate: true, size: 10.5, leading: 3 end # A course is kept together, so a column never starts in the middle of one. def course(title, dishes) group(keep_together: true) do text title, size: 18, weight: :bold, color: ACCENT spacer 8 dishes.each do |name, description, price| row(gap: 8) do column { text name, size: 10.5, weight: :bold } column(width: :auto) { text price.to_s, size: 10.5, weight: :bold } end text description, size: 9, color: MUTED, style: :italic spacer 10 end spacer 14 end end end if $PROGRAM_NAME == __FILE__ menu = ExampleMenu.preview menu.to_pdf(File.expand_path("menu.pdf", __dir__)) warn menu.warnings.map(&:message) if menu.warnings.any? puts "wrote examples/menu.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](https://stationery.zoolutions.llc/examples/accessible_report.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/accessible_report.rb) ![The first page of the accessible report example](https://stationery.zoolutions.llc/examples/accessible_report.png) The source: examples/accessible_report.rb ```ruby # 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 #{name} BAR end <<~SVG #{bars.join} 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](https://stationery.zoolutions.llc/examples/multilingual_notice.pdf) · [Source on GitHub](https://github.com/zoolutions/stationery/blob/main/examples/multilingual_notice.rb) ![The first page of the multilingual notice example](https://stationery.zoolutions.llc/examples/multilingual_notice.png) The source: examples/multilingual_notice.rb ```ruby # 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: caretaker@harbour-view.example · +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 ``` --- # Elements Guide # Elements Every element available inside view_template, with its signature, options and a small example. ## At a glance The element table from the README. | Element | What it does | | --- | --- | | `text(string, **style)` | A paragraph. Plain strings are literal. | | `text(string, markup: true)` | Reads `
`; decodes numeric (`'`, `'`) and HTML 4 named entities once, so escape user data (`ERB::Util.html_escape`) before wrapping it in your own tags: `<b>` stays literal text. | | `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. | | `box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:, float:, margin:) { }` | A container. Moves to the next page whole when it fits there and continues across pages when it does not; `break_inside: :auto` splits it at any page break, `:avoid` never splits it. At a cut, `decoration: :slice` (default) drops the padding and border, `:clone` keeps the padding. `overflow: :truncate` or `:shrink_to_fit` for fixed heights; a fixed `height:` never splits. `min_height:` is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with `height:`). `at: [x, y]` pins it to a page position. `link:` makes the whole box clickable. `outset:` bleeds the background past the box (e.g. into the page margins). `rotate: -3` turns the painted box around its centre (layout box unchanged, never splits; a `link:` keeps its unrotated rectangle). `shadow: true` or `{ offset: [0, 4], blur: 8, color:, opacity: 0.15 }` paints a soft drop shadow under it, taking no space. `overflow: :hidden` clips the content to the rounded outline. `float: :left` or `:right` with a `width:` takes it to that side, `margin:` away from the text that wraps beside it: see [Floats](#floats). | | `row(gap:, align:, break_inside:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with `min_height:` do. | | `columns(count:, gap:, balance:, rule:) { }` | One flow poured through `count` columns, newspaper style: column 1 top to bottom, then column 2. Breaks where a page would (between lines with `orphans:`/`widows:`, never inside `break_inside: :avoid`, `keep_with_next` honoured). `balance: true` (default) ends the columns at nearly the same height where the content ends and fills them evenly, what cannot be shared going to the earlier columns (ten lines in three columns are 4, 3 and 3); `false` fills each before the next. Continues across pages; `page_break` inside ends the page; `rule: true \| { color:, width: }` draws a line between columns. | | `table(rows, widths:, width:, header:, split_rows:, cell:) { \|t\| }` | Tables. Cells are strings, layout nodes, procs built with the DSL (`-> { image logo }`) or components. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. A cell may be `{ content:, colspan:, rowspan: }` plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; `split_rows: true` cuts any row that reaches the page bottom instead of moving it whole. `rows` is an Array or any Enumerable; an Enumerator (lazy or not) given a width for every column is read as pages reach it, selected from its start only: see [Large documents](#large-documents). | | `image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:, float:, margin:)` | JPEG, PNG or lossless WebP, aspect preserved. `fit: [w, h]` scales to fit inside; `fit: :cover` fills `width:` × `height:` and crops around the centre. `radius:` rounds the corners; `rotate:` turns it (degrees, clockwise) without changing the space it takes. Drawn at more than twice `max_ppi:` (300) it is reported as oversized; `downscale: true` resamples a PNG or WebP to that resolution instead. | | `svg(source_or_path, width:, height:, color:, align:)` | Vector icons and drawings; `currentColor` takes `color:` (or the `color` an element sets). Linear and radial gradients (`fill="url(#id)"`, `href` chains, both gradient units); `text`/`tspan` in the document's fonts; `

Note. Prices include VAT.

HTML ``` ## Untrusted input What html and markdown do with content you did not write. `html` and `markdown` are meant for content you did not write (a CMS body, a comment, an ActionText field). Nothing in that content can reach outside the document, and parsing it is bounded: nesting is capped and time grows with the input's length. How much input you accept, and so how many pages it renders to, is yours to bound: - **No requests.** Remote images are never fetched; an image is read only from `images:` or from under `base_path:`, and a path that climbs out of it is skipped (`SkippedImage`). - **No surprising links.** Only `http`, `https`, `mailto` and `tel` hrefs (and `#anchor`) become links (`DroppedLink` for the rest), so a `javascript:` or `file:` href is plain text. - **No scripts, no fetched styles.** `script`, `template` and `title` content is dropped and raw HTML inside Markdown stays literal text. CSS is read only for the fixed list of properties above: `url()` and `@import` are dropped unread, and nothing in a style can position content outside the flow or hide it. - **Bounded nesting.** Five thousand nested `
`s, block quotes or lists render flattened, with a `NestingLimit` warning, instead of exhausting the stack (the `svg` element guards its own nesting the same way, at 128 levels). Emphasis nests without recursion, at most 64 brackets wait for their `]`, and a link label is at most 999 characters, so parsing takes time in proportion to the input. - **Size is yours to bound.** There is no limit on how much text is rendered: a megabyte of input is a few hundred pages. Cap the length of what you accept before you render it, and use `strict` (or read `document.warnings`) when a warning should stop the document. --- # Layout rules Guide # Layout rules How the box-layout engine measures, splits and paints nodes, and how that decides where each page breaks. ## Measure, split, paint The protocol every node implements. Every element builds a layout node. Nodes know nothing about pages; they answer four questions: | Method | Answers | | --- | --- | | `measure(width)` | How tall am I at this width? | | `split(width, height, fresh:)` | The part that fits in `height`, and the remainder (either may be `nil`). `fresh: true` means nothing is above it on a new page. | | `natural_width` / `min_width` | Preferred and narrowest widths — how `:auto` columns and table widths are resolved. | | `paint(canvas, x, y, width)` | Draw at a position already decided. | The paginator splits the document's root flow at the height of each page's content box, paints the head on that page and carries on with the remainder until nothing is left. Page templates, headers and footers run afterwards, so they know `page.count`. ## Pagination What moves, what splits. A flow (the document body, and every box's content) stacks children top to bottom. At a page break: - a **splittable** child — text, lists, tables, boxes, rows, groups, wraps — continues on the next page; - anything else (an image, a rule, a canvas, a fixed-height box) moves there whole; - a `spacer` that lands on the break is dropped; - `page_break` forces what follows onto a new page; - a [float](https://stationery.zoolutions.llc/docs/elements#floats) never splits: when it does not fit, or what wraps beside it could not start there, it moves to the next page with that content. A paragraph, a list item and a box beside it split as they do anywhere, and what they carry over is wrapped again at the full width; a box with a background keeps the full width on both pages, the float over it on the first. Floats taller than a page together are cut before the first that does not fit, and only a float taller than a page by itself is reported as an `Overflow`. Text splits between lines. Tables split between rows (never through a rowspan) and repeat their header rows. Lists keep each marker with the first line of its item. ## keep_with_next Never end a page on a heading. On `text`, `box` or `group`, `keep_with_next: true` never ends a page with that node: it moves to the next page with its successor. A number keeps at least that many points of what follows with it: ```ruby text "3. Financial review", size: 16, weight: :bold, keep_with_next: 60 table(rows, header: true) ``` A kept node also moves when its successor avoids breaking inside and would not start on the page. ## Page breaks in text orphans and widows. A paragraph splits between lines. `orphans:` is the fewest lines a break may leave at the foot of a page and `widows:` the fewest it may carry to the next; both default to 1, so any line may end or start a page. With larger values the break moves up until both are met, and a paragraph that cannot meet them (it would leave too few lines behind, or is shorter than orphans plus widows) moves to the next page whole. A paragraph that already starts a fresh page cannot move, so it splits as best it can, keeping the widows if a line can still stay. ```ruby default_text orphans: 2, widows: 2 # for the whole document text long_story, orphans: 3, widows: 3 # or per paragraph html body, styles: { p: { orphans: 2, widows: 2 } } ``` `text_style` sets them for a block like any text default. ## break_inside Soft avoid, auto, avoid. `break_inside:` is accepted by `box`, `row`, `column` and `text`: | Value | Behaviour | | --- | --- | | `nil` (default, "soft avoid") | Moves to the next page whole when it fits there; continues across pages when it does not fit on a page of its own. | | `:auto` | Splits at any page break, wherever it starts. | | `:avoid` | Never splits. Content taller than a page overflows (and warns). | `group(keep_together: true)` is `break_inside: :avoid` for a group. > **Behaviour change:** Boxes taller than a page used to overflow; they now continue on the next page. Pass break_inside: :avoid for the old behaviour. ## Decoration at a cut slice or clone. When a box splits, each fragment's cut side is open. `decoration: :slice` (default) drops the padding and border on that side, like CSS `box-decoration-break: slice`; `:clone` keeps the padding. Borders are left open and rounded corners are cut square at the break. ```ruby box(background: "#F0FDFA", padding: 16, radius: 8, break_inside: :auto, decoration: :clone) do text long_callout end ``` Rows split every column at the same break; a column that ends early continues as an empty fragment so backgrounds stay aligned. ## Fixed heights and overflow truncate or shrink_to_fit. A box with `height:` never splits, and neither does a row holding one. When its text does not fit, `overflow:` decides: - `:visible` (default) — draw it anyway; - `:truncate` — keep the whole lines that fit and drop the rest; - `:shrink_to_fit` — reduce the text size until it fits (when the box holds a single `text`; otherwise it truncates). ```ruby box(height: 40, overflow: :shrink_to_fit, valign: :middle) { text customer.name, size: 18 } ``` ## Overflow warnings Content is placed anyway. Content taller than the space a page has for it — an `:avoid` box, an image, a fixed-height box — is placed anyway and never raised on. Each case is recorded as a `Stationery::Warnings::Overflow` with the page, the height placed and the height available: ```ruby doc.to_pdf doc.warnings.map(&:message) # => ["content 912.0pt tall placed on page 3 with 770.0pt available"] ``` Use `strict` to fail instead — see [Warnings and strict mode](https://stationery.zoolutions.llc/docs/warnings) — and `to_pdf(debug: true)` to see the rectangles involved. --- # Pages, headers and footers Guide # Pages, headers and footers Page sizes and margins, header and footer regions that reserve space, page templates, print hints, monochrome mode, ZPL for label printers and debug outlines. ## Page size and margins ```ruby page size: :a4, margin: [40, 44, 56, 44], layout: :landscape ``` | Size | Points (portrait) | | --- | --- | | `:a3` | 841.89 × 1190.55 | | `:a4` | 595.28 × 841.89 | | `:a5` | 419.53 × 595.28 | | `:a6` | 297.64 × 419.53 | | `:a7` | 209.76 × 297.64 | | `:b5` | 498.9 × 708.66 | | `:letter` (default) | 612 × 792 | | `:legal` | 612 × 1008 | | `:tabloid` | 792 × 1224 | | `:dl` (envelope, 110 × 220 mm) | 311.81 × 623.62 | | `:c5` (envelope, 162 × 229 mm) | 459.21 × 649.13 | | `:c6` (envelope, 114 × 162 mm) | 323.15 × 459.21 | | `:label_4x6` (4 × 6 in) | 288 × 432 | | `:label_4x3` | 288 × 216 | | `:label_4x2` | 288 × 144 | | `:label_100x150` (100 × 150 mm) | 283.46 × 425.2 | | `:label_100x50` | 283.46 × 141.73 | | `[width, height]` | any size, in points | | `["102mm", "74mm"]`, `"4in x 6in"` | any size, with its units | `layout: :landscape` swaps width and height: envelopes are listed short edge first, so it is the side the address is on. `margin:` (default 36) takes one value, `[y, x]`, `[top, x, bottom]`, `[top, right, bottom, left]` or `{ top:, right:, bottom:, left:, x:, y: }`, each in points or as a length with its unit (`"3mm"`). A size or a margin that cannot be read raises `ArgumentError` when the class is defined, with what `page` takes. ## One page only: max_pages For a label, a receipt or a card. Content that does not fit a page continues on the next, as a letter or a report should. A label, a receipt or a card must not, and `max_pages` says so: ```ruby class ShippingLabel < Stationery::Document page size: :label_4x6, margin: mm(4) max_pages 1 end label = ShippingLabel.new label.to_pdf label.warnings.map(&:message) # => ["the document may have 1 page and needs 2: page 2 starts with a Code 128 barcode"] label.to_pdf(max_pages: nil) # one render without the limit ``` A render that needs more pages than the limit lays them all out and reports `Warnings::TooManyPages` (`limit`, `pages`, `moved`), naming what the first page past the limit starts with: text by its first line, quoted and cut at 40 characters, anything else by its kind (`a table`, `an image`, `a QR code`, `a box`). Under [`strict`](https://stationery.zoolutions.llc/docs/warnings#strict-mode) it raises. | Render | More pages than `max_pages` | | --- | --- | | `to_pdf` | every page written, the warning reported (`strict` raises) | | `to_png` | a picture of every page, the warning reported (`strict` raises) | | `to_zpl` | raises `Stationery::WarningsError`, strict or not: no label is written, since each page is one more label printed | `to_pdf(max_pages:)`, `to_png(max_pages:)` and `to_zpl(max_pages:)` replace the limit for one render, `nil` takes it away. It is inherited; `max_pages nil` in a subclass takes it away, and a subclass's `page` keeps it. The declaration writes nothing: a document that fits is byte for byte what it is without it. Anything but a positive Integer or `nil` raises `ArgumentError` where it is written. ## Millimetres and inches Units for labels, envelopes and pre-printed forms. Every size and position is in points (1/72 inch). `mm`, `cm`, `inch` and `pt` convert to them, in the class body and in `view_template` of every component and document: ```ruby class Label < Stationery::Document page size: [mm(102), mm(74)], margin: mm(3) def view_template box(at: [mm(20), mm(45)], width: mm(60)) { text "Fragile" } end end ``` They are private methods, `Numeric` is not patched, and a method of your own with one of the names wins. Any other class gets them with `include Stationery::Units`, or calls `Stationery::Units.mm(102)`. `page` also reads lengths written with their unit: ```ruby page size: ["102mm", "74mm"], margin: "3mm" page size: "4in x 6in", margin: ["0.25in", "0.5in"] page size: "102 x 74 mm" # the first length takes the unit of the second ``` | Part | Accepted | | --- | --- | | Number | digits, with a fraction after a point: `102`, `101.6`. No sign, exponent or comma | | Unit | `mm`, `cm`, `in`, `pt`, in either case | | Between the two lengths of a size | `x` or `×` | | Spaces | around numbers, units and the `x`, or none: `"4inx6in"` | `Stationery::Units.points("3mm")` reads one length. Unit strings are read by `page` (and `Stationery::Page.new`); elements take points, so write `padding: mm(3)` there. ## header and footer Regions that reserve space; the body flows between them. ```ruby header(gap: 8) do |page| row do text "ACME" text "Page #{page.number} of #{page.count}", align: :right end end footer(on: :rest) { text "Confidential", size: 7, align: :center } header(on: :first) {} # no header on page 1 ``` `header(height: nil, gap: 8, on: :all) { |page| … }` and `footer(…)` take the same options: - **`on:`** `:all` (default), `:first`, `:rest`, `:odd`, `:even`, `:last`, a page number, a range or `->(number) { … }`. Later declarations win for the pages they match, and an empty block removes the region there. - **`gap:`** extra space between the region and the body. - **`height:`** without it a region is measured once, on the first page that uses it; pass it when the content varies per page. The footer is bottom-aligned. A region taller than its space is still drawn and listed in `document.warnings`; regions that leave no room for the body raise `ArgumentError`. > **on: :last:** A taller last-page footer (say, with totals) is supported: the paginator checks whether the rest fits above it; content that fits a normal page but not the last one continues onto an extra page. ## page_template Runs on every page after pagination. ```ruby 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, align: :right end end page_template(layer: :background) do |page| canvas(height: page.height, at: [0, 0], width: page.width) do |c, rect| c.fill_rect(rect.x, rect.y, rect.width, rect.height, color: "#FFFBEB") end end ``` The block receives `page.number`, `page.count`, `page.width`, `page.height`, `page.margin` (`[top, right, bottom, left]`) and `page.content_box` (`x`, `y`, `width`, `height`). With a header or footer, `content_box` excludes their space — it is the body's area. ## Layers Foreground and background. `page_template` paints on the **foreground** by default, over the body. `layer: :background` paints under the content — full-bleed backgrounds, watermarks, letterheads. Use `box(outset:)` to bleed a single box's background into the margins instead. Anchors drawn by page templates resolve to the first page; bookmarks inside page templates are ignored. Warnings raised while templates draw are included in `document.warnings`. ## Page labels How viewers name the pages. `page_labels` maps a 1-based first page to the range that starts there. Each range takes `style:` (`:decimal`, `:roman`, `:roman_lower`, `:alpha`, `:alpha_lower`, or `nil` for a prefix only), `start:` (the number the range counts from, default 1) and `prefix:`. Pages before the first range keep their physical number in the viewer. ```ruby class Report < Stationery::Document page_labels 1 => { style: :roman_lower }, # i, ii, iii 4 => { style: :decimal }, # 1, 2, 3, … 12 => { style: :alpha, prefix: "Appendix " } # Appendix A, Appendix B end Report.new.to_pdf(page_labels: nil) # one render without labels ``` The labels are written as the catalog's `/PageLabels` number tree; `Inspector#page_labels` and `have_page_labels` read them back per page. ## Printing Scaling, copies, duplex and the print dialog: hints to the viewer. A PDF can say how it wants to be printed. For a page that is the medium itself (a label, a card, a pre-printed form) the defaults of a print dialog are the wrong ones: "fit to page" prints a 100 × 60 mm label at 94 %. ```ruby class ShelfLabel < Stationery::Document page size: :label_100x50, margin: "3mm" print scaling: :none, copies: 2, pick_tray_by_size: true, duplex: :simplex end ShelfLabel.new.to_pdf(print: { copies: 1 }) # laid over the hints of the class ShelfLabel.new.to_pdf(print: { duplex: nil }) # without one of them ShelfLabel.new.to_pdf(print: nil) # without any (false does the same) ``` | Option | Values | Written as | | --- | --- | --- | | `scaling:` | `:none`, `:default` | `/PrintScaling` `/None`, `/AppDefault` | | `copies:` | an Integer of 1 or more | `/NumCopies` | | `pick_tray_by_size:` | `true`, `false` | `/PickTrayByPDFSize` | | `duplex:` | `:simplex`, `:long_edge`, `:short_edge` | `/Duplex` `/Simplex`, `/DuplexFlipLongEdge`, `/DuplexFlipShortEdge` | | `pages:` | a Range of page numbers from 1, or a list of them: `1..3`, `[1..1, 3..4]`, `2..` | `/PrintPageRange` | | `dialog:` | `:on_open` | `/OpenAction << /S /Named /N /Print >>` | - The first five are entries of the catalog's `/ViewerPreferences` (ISO 32000-1, 12.2, table 150), beside the `/DisplayDocTitle` of a tagged document. `dialog: :on_open` asks the viewer to open its print dialog when the file is opened, as `window.print()` does for a page, without JavaScript. - `print` at class level adds to what the class inherits, and `to_pdf(print:)` to what the class declares. A `nil` takes a hint away (`print copies: nil` in a subclass). - Every option is checked where it is written, the class body or the call of `to_pdf`, and raises `ArgumentError` naming what it takes: `print scaling: is :none or :default, not :fit`. Ranges that overlap are refused. - `pages:` counts from 1, as the file does, and is written in page order. How many pages there are is known when the document is rendered: a range is cut at the last page (`2..` and `2..99` both end there) and one that starts after it is left out, because a viewer drops a range that names a page the document does not have. - `/ViewerPreferences` is allowed under PDF/A and PDF/UA. The print dialog is not an action PDF/A has: `dialog: :on_open` with `conformance :pdf_a2b` or `:pdf_a3b` raises `Stationery::ConformanceError` (ISO 19005-2/3, 6.5.1; veraPDF rule 6.5.1-2). PDF/UA-1 alone takes it. - The hints are written by every render: `incremental:`, signed and encrypted ones too. - `print` in a class body is this declaration. Inside `view_template` and every other method of a document it is still `Kernel#print`. - `Inspector#print_preferences` reads them back as `print` takes them, `pages` as a list of ranges, and `have_print_preference(scaling: :none)` and `assert_print_preference` assert them. They are hints: a viewer follows the ones it knows, and whoever prints can overrule them in the dialog. The table says what each viewer does and how that is known. Nothing in it was tried in a running viewer: "documented" is what the vendor's PDF reference says, "source" what the viewer's source code did when it was read on 2026-09-28, "reported" what a user wrote in the issue named. | Viewer | `scaling:` | `copies:` | `duplex:` | `pages:` | `pick_tray_by_size:` | `dialog:` | | --- | --- | --- | --- | --- | --- | --- | | Acrobat, Acrobat Reader | sets the dialog (documented) | sets the dialog, 2 to 5 (documented) | sets the dialog (documented) | sets the dialog (documented) | sets the dialog (documented) | opens the dialog (reported, [pdf.js 11442](https://github.com/mozilla/pdf.js/issues/11442)) | | Chrome, Edge (PDFium) | preset of the print preview (source) | preset of the print preview (source) | preset of the print preview (source) | not read (source) | not read (source) | starts printing (source) | | Firefox (pdf.js) | read, not used by the viewer (source; [bug 1243580](https://bugzilla.mozilla.org/show_bug.cgi?id=1243580)) | read, not used (source) | read, not used (source) | read, not used (source) | read, not used (source) | starts printing (source) | | Preview (macOS) | not tested | not tested | not tested | not tested | not tested | not tested | | Evince, Okular (poppler) | not tested | not tested | not tested | not tested | not tested | not tested | ## Monochrome Black or nothing: thermal label printers and other one-bit devices. A thermal label printer (203 or 300 dpi), a receipt printer or an e-paper display prints black or nothing. Whatever else the PDF holds, the driver or the printer turns into dots of its own choosing: grey text comes out speckled, a light rule or background vanishes or turns into a dot pattern, and a line thinner than one dot prints or not depending on where it lands. `monochrome` makes those choices where they can be seen, before anything is printed: ```ruby class ShelfLabel < Stationery::Document page size: [mm(100), mm(60)], margin: mm(4) monochrome dpi: 203 # report what a 203 dpi printer cannot print as it is end ShelfLabel.new.to_pdf(monochrome: { snap: true }) # change it instead, for this render ShelfLabel.new.to_pdf(monochrome: false) # as it was, byte for byte ``` | Option | Default | What it does | | --- | --- | --- | | `dpi:` | `203` | the printer's dots per inch: one dot is 72/dpi pt (0.355 pt at 203 dpi, 0.24 pt at 300) | | `snap:` | `false` | change colours and thin lines instead of reporting them | | `threshold:` | `0.5` | under `snap:`, a fill whose tone is darker is painted black, a lighter one is not painted | | `dither:` | `:floyd_steinberg` | how a bitmap becomes dots: `:floyd_steinberg`, `:ordered` (an 8 × 8 Bayer pattern) or `:threshold` (cut at half) | - **Colours.** Every colour that is not black or white, and anything painted at an `opacity:` below 1, is a `Warnings::NotMonochrome` naming the colour and what painted it: `text`, `a rule` (a `rule`, an underline, a strikethrough: a filled rectangle 3 pt thick or less), `a background` (any other fill), `a border or line` (any stroke), `a gradient` or `an image`, once per colour, kind and page: `text in #888888 on page 1 is not black or white`. `strict` raises on them. It sees whatever is painted, by an element, an SVG or a `canvas { }` block alike. - **`snap: true`.** Text, rules, borders and lines are painted black, except white ones, which stay white (white text on a black box). A fill is painted black when its tone is darker than `threshold:` and left out when it is lighter; the tone is the colour's luma (ITU-R BT.601), an opacity laid over white paper (black at 0.3 is a tone of 0.7). A gradient is black or left out by the average tone of its stops. Opacity is taken away. Nothing is reported. - **Line widths.** A stroke or a rule thinner than a dot (`width × the scale of a transform`) is a `Warnings::ThinLine` and is left as it is; with `snap: true` it is widened to a dot. - **The dot grid.** Outside any transform, a filled rectangle (a rule, a background, an underline) and a stroke made of horizontal and vertical lines (a border, a table's cell borders, a line) are put on the printer's grid, counted from the top left corner of the page: their edges on whole dots and their widths whole numbers of dots, so a hairline is as wide on every label. A curve, a rounded corner, a slanted line and anything under a transform keeps its geometry. - **Images.** A PNG, a lossless WebP or a JPEG is greyed (a transparent pixel is white paper), resampled to the dots it covers at `dpi:` and dithered, and embedded as a one-bit DeviceGray image (`/BitsPerComponent 1`): what prints is the pattern chosen here, not the driver's. A JPEG is decoded for it in Ruby, no larger than the dots need; one of a kind that is not decoded (lossless, arithmetic-coded, hierarchical, 12-bit) is embedded as it is and reported (`an image in JPEG 640x480`). - `monochrome` at class level is inherited and adds to what the class inherits; `monochrome false` takes it away. `to_pdf(monochrome:)` takes `true`, `false` or options laid over the class's. - Monochrome renders keep PDF/A and PDF/UA: the one-bit image is DeviceGray, which the sRGB output intent covers (checked with veraPDF, PDF/A-3b and PDF/UA-1). - `Inspector#colors` lists what a PDF paints with, so a spec can hold it: `expect(pdf).to have_pdf_colors("#000000")`. What it does not do: it does not dither a lossless, arithmetic-coded, hierarchical or 12-bit JPEG, does not look at form fields (their widget draws them, not the page), does not put dashes, line caps or curves on the grid, and does not make small text bolder. It changes nothing without `monochrome`. The rules live in `Stationery::Monochrome::Rules` and `Monochrome::Grid`, apart from the PDF canvas, and `to_png` and `to_zpl` apply the same ones (see below). ## Label printers: to_zpl ZPL II for Zebra and compatible thermal printers. A thermal label printer (Zebra, and the many makes that emulate ZPL II) is driven in its own language, not PDF. `to_zpl` writes the document as ZPL: each page is drawn one bit to a dot, as `to_png(monochrome: …)` draws it, and sent as one graphic field, so the label is the layout `to_pdf` produces, in any font the document uses. One document class serves the preview on screen and the printer. ```ruby class ShippingLabel < Stationery::Document page size: "4in x 6in", margin: mm(4) max_pages 1 # a second page raises instead of printing a second label monochrome dpi: 203 end label = ShippingLabel.new(parcel) label.to_zpl # => "^XA^PW812^LL1218^LH0,0^FO0,0^GFA,…^XZ\n", one label per page label.to_zpl("label.zpl", dpi: 300, copies: 2) label.to_zpl(compression: :hex, pages: 1) # plain hex, for printers that do not take Z64 ``` How the ZPL reaches the printer is the application's business: a socket to port 9100, a print server, a browser print agent. For example, over the network: ```ruby require "socket" TCPSocket.open("printer.local", 9100) { |socket| label.to_zpl(socket) } ``` - **What is written.** Per page `^XA`, `^PW` and `^LL` (the page's width and length in dots, `Raster.pixels(points, dpi)`), `^LH0,0`, `^FO0,0`, one `^GFA` graphic field, `^FS`, `^PQ` (copies) and `^XZ`, then a newline. The field's dots are 1 for black, each row padded to a byte with white. `compression: :z64` (the default) deflates the rows with zlib and writes them in Base64, followed by the CRC Zebra's software writes (CRC-16/XMODEM of the Base64 text, checked against a ZebraDesigner print file); `:hex` writes them as plain hex, about 20 times larger. - **Always monochrome.** A label printer prints one bit, so `to_zpl` renders with the class's `monochrome` settings when it declares them and with the defaults when it does not (`snap: false`, `dither: :floyd_steinberg`): a colour is reported as `Warnings::NotMonochrome` and dithered, as `to_png(monochrome: true)` shows it. `monochrome:` takes options laid over them (`{ snap: true }`); `monochrome: false` raises. - **`dpi:`** is the printer's: 152, 203, 300 or 600 (6, 8, 12 or 24 dots a millimetre); anything else raises `ArgumentError` naming them. It defaults to the class's `monochrome dpi:`, else 203, and replaces it when given, so `to_zpl(dpi: 300)` and `to_png(monochrome: { dpi: 300 })` are the same dots. - **`copies:`** is `^PQ`, by default the class's `print copies:`, else 1. **`pages:`** a page number, a Range or an Array, in the order given; `target` a path or anything answering `write` (a socket). - **One label.** Content that does not fit the label goes to a next page, and every page is a label. Declare `max_pages 1` (see Pages) and a document that needs a second page writes no ZPL: `to_zpl` raises `Stationery::WarningsError` with `Warnings::TooManyPages`, strict or not, because the harm of a second label is done when it prints, where no one reads a warning. `to_zpl(max_pages: nil)` writes them all; `to_pdf` and `to_png` of the same document keep every page and warn, so the page that spilled can be looked at. - `strict:`, `debug:`, `shaper:` and `max_pages:` are those of `to_pdf`; what only a PDF has raises, as for `to_png`. - **Speed.** A 4 × 6 in label takes about 25 ms at 203 dpi and 35 ms at 300 dpi (Ruby 3.4, Apple M-series), and is 10 to 20 KB of ZPL. - The labels were read back and compared with `to_png` dot for dot, and rendered by [Labelary](http://labelary.com/viewer.html), a ZPL viewer, identically. They were not printed on a printer by the gem's specs. **Barcodes the printer draws.** A `barcode` is part of the picture unless it asks otherwise: `barcode "SX0042771903", native: true`, or `to_zpl(native: true)` for every barcode that does not say `native: false`. Such a barcode is left out of the graphic field and written after it as the printer's own command, where the picture had it, so the printer puts the bars on its own dot grid: ``` ^FO146,874^BY4^BCN,124,N,N,N,N^FD>:SX>50042771903^FS Code 128, its code sets named ^FO65,197^BY3^BEN,113,N,N^FD400638133393^FS EAN-13, the printer adds the check digit ^FO650,625^BQN,2,4^FDMM,B0034https://track.example/SX0042771903^FS QR code, byte mode, level M ^FO42,42^BXN,8,200,14,14,6,_,1^FDSX0042771903^FS Data Matrix (ECC 200), 14 × 14 ``` - `^BY` is the module in whole dots, the height in dots, and data ZPL would read as a command is escaped with `^FH`. A QR code's `^FO` is 10 dots above it, since `^BQ` draws that far below its origin. A Data Matrix is asked for at its own size, so it takes the same square. - It stays in the picture when the printer could not draw it as it is: rotated or otherwise transformed, clipped, lighter than half grey, a module over 10 dots, a QR code of UTF-8 text (its ECI has no field in `^BQ`), or a Data Matrix holding `_` (the escape character `^BX` is given). - Rendered by Labelary, the Code 128 is the same dots as `to_png` draws; the EAN-13's guard bars come out 13 dots longer, as the printer draws them; a QR code covers the same modules' square, its modules chosen by the printer's own encoder (another mask); a Data Matrix of digits and capitals is the same dots, one of other text another encodation in the same square. All decode (ZBar, dmtxread) to the data. - Rules and boxes are not written as `^GB`: a box drawn natively would print over white text on it, which the picture keeps. `stationery render label.rb --zpl --dpi 300` writes `label.zpl` (see [CLI](#cli)). ## Embedded files Attachments, for Factur-X and friends. `attach_file name, data, mime:, description:, relationship:, modified_at:` embeds a file in every render; `to_pdf(attachments: [{ name:, data:, … }])` adds more for one render. Each file is a `/Filespec` with an `/EmbeddedFile` stream, listed in the catalog's `/EmbeddedFiles` name tree and `/AF` array. `relationship:` writes `/AFRelationship`: `:alternative` (the machine-readable twin Factur-X and ZUGFeRD require), `:source`, `:data`, `:supplement` or `:unspecified` (default). The same name twice raises `ArgumentError`; an encrypted document encrypts the embedded streams too. ```ruby class InvoicePdf < Stationery::Document attach_file "factur-x.xml", invoice_xml, mime: "text/xml", description: "Factur-X", relationship: :alternative end ``` `Inspector#attachments` reads them back as `{ name:, mime:, bytes:, description:, relationship: }`; `have_attachment` and `assert_pdf_attachment` assert one by name, type and relationship. For an e-invoice, [`factur_x`](https://stationery.zoolutions.llc/docs/conformance#factur-x-zugferd-e-invoices) does all of it in one line: it embeds the XML, claims PDF/A-3b and writes the Factur-X identification in XMP. ## Accessibility (tagged PDF) A structure tree for screen readers. ```ruby class ReportPdf < Stationery::Document tagged # or to_pdf(tagged: true) for one render metadata title: "Q3 report", lang: "en-US" def view_template text "Quarterly report", size: 20, heading: 1 text "Revenue grew in every region." image "chart.png", width: 300, alt: "Revenue by region, Q1 to Q3" box(role: :note, padding: 8) { text "Figures are unaudited." } end end ``` A tagged PDF carries a structure tree screen readers and reflowing viewers follow, in paint order and across page breaks: a paragraph continued on the next page stays one `P`. - `text` is a `P`; `heading: 1..6` makes it `H1`–`H6`. - `image`/`svg` are a `Figure` with `/Alt` from `alt:` and a bounding box; `alt: false` marks one decorative (an artifact, not announced). - `box(role:)` groups its content: `:section` (`Sect`), `:div`, `:blockquote`, `:note`, `:caption`, `:article` (`Art`), `:part`. Boxes without a role, rows, columns, groups and wraps add no element. - Lists are `L` (with `/ListNumbering` from the bullet shape or number format) > `LI` > `Lbl` (a text marker; drawn bullets are artifacts) and `LBody`. - Tables are `Table` > `TR` > `TH` (header rows, `/Scope /Column`) or `TD`, with `/ColSpan` and `/RowSpan`. A table split across pages stays one `Table`; its repeated header rows are artifacts. - Linked text is a `Link` inside its paragraph holding the link annotation (`/OBJR`, `/StructParent`); `box(link:)` groups its content in a `Link`. - `table_of_contents` is `TOC` > `TOCI` > `Link` (the title and its annotation) and `Reference` (the page number). - `html`/`markdown` tag headings `H1`–`H6` (with or without `bookmarks: true`) and block quotes `BlockQuote`, and give images their `alt`. In `html`, `` is decoration as `alt: false` is (an `` without the attribute is a description missing). Markdown has no way to say so: `![](photo.png)` is a description missing, and decoration goes through `html` or `image`. - Headers, footers and page templates are pagination artifacts, but for their links: a `link:` one paints (`text(link:)`, markup, `html`, `markdown`, `box(link:)`) is a `Link` of the `Document` holding its text and its annotation, read after the content of its page, in the order header, footer, page templates. The rest of the region stays an artifact. Backgrounds, borders and rules drawn outside any element are layout artifacts. - `metadata lang:` writes the catalog's `/Lang`; the title is shown instead of the file name. - Every document carries an XMP packet (`/Metadata`, uncompressed) mirroring the Info dictionary: `dc:title`, `dc:creator`, `dc:description`, `dc:subject`, `dc:language`, the `xmp:` dates and `pdf:Producer`. PDF/A and PDF/UA identification lives there (see [PDF/A and PDF/UA](#pdfa-and-pdfua)); `metadata xmp: false` or `to_pdf(xmp: false)` leaves it out. - An image or drawing without `alt:`, or with a blank one (`alt: ""`, whitespace alone, which veraPDF would accept), is a `Warnings::MissingAlt`; `alt: false` is how decoration is marked. - A heading level that is skipped is a `Warnings::SkippedHeading` (`level`, `allowed`, `page`): the first heading is `heading: 1` and a heading is at most one level below the heading before it (`1`, then `3` skips `2`). Going back up is free (`3`, then `1`). Headings are read in the order of the structure tree, inside sections, lists, table cells, columns and floats; those of headers, footers and page templates are artifacts and do not count. - A link annotation that belongs to no `Link` element is a `Warnings::UntaggedLink` (`target`, `place`, `page`): a link in the header row a table repeats on its next pages, which is an artifact (`place` is `:artifact`), and `canvas.link` without a `tag:` (`:canvas` in the body, `:header`, `:footer` or `:page_template` on the canvas of one). Every `link:` is tagged, in the body and in headers, footers and page templates; on a canvas of the body, pass the `Link` element that holds what the link draws: `canvas.link(x, y, w, h, url, tag: element)` after `canvas.tag(element) { … }`. - A missing `lang` is a `Warnings::MissingLanguage`. All four are warnings, so `strict` catches them, and `conformance :pdf_ua1` raises on the first three. - A tagged render packs its structure elements, and every other object that is not a stream, into deflated object streams (`/Type /ObjStm`, 200 objects each) with a cross-reference stream in place of the table and trailer (ISO 32000-1, 7.5.7 and 7.5.8). A table of 300 rows is 115 KB instead of 501 KB. Streams, the `/Encrypt` dictionary and a signature dictionary stay out; PDF/A-2, PDF/A-3 and PDF/UA-1 allow them. `object_streams false` at class level, or `to_pdf(object_streams: false)`, writes the classic table for a reader that cannot read them; `to_pdf(object_streams: true)` packs an untagged render too. - Untagged documents (the default) are written exactly as before. Check the tree in tests with `have_structure` and `have_tagged_content` (see [Testing](#testing)): ```ruby expect(ReportPdf.new).to have_tagged_content # tagged, and no text outside marked content expect(ReportPdf.new).to have_structure( [[:Document, [[:H1, "Quarterly report"], [:P, "Revenue grew in every region."], [:Figure, "Revenue by region, Q1 to Q3"], [:Note, [[:P, "Figures are unaudited."]]]]]] ) ``` `tagged` alone does not label the file; `conformance :pdf_ua1` does (see [PDF/A and PDF/UA](#pdfa-and-pdfua)). Nothing checks colour contrast or the reading order you build out of positioned boxes (`box(at:)` joins the reading order where it paints). ## Debug outlines See every layout rectangle. `to_pdf(debug: true)` outlines every layout rectangle on top of the content: boxes (red, padding dashed), row columns (blue), flow slots (grey, dotted), table cells (green, padding dashed), positioned boxes (pink), images (teal) and the page content box (cyan). Pass an Array to outline only some kinds: ```ruby Invoice.new.to_pdf("invoice.pdf", debug: %i[cell cell_padding]) ``` The kinds are `:box`, `:padding`, `:column`, `:flow`, `:cell`, `:cell_padding`, `:positioned`, `:image`, `:page` and `:region`. In a Rails [preview](https://stationery.zoolutions.llc/docs/rails#previews) add `?debug=1`; from the [CLI](https://stationery.zoolutions.llc/docs/cli) pass `--debug`. --- # Links, bookmarks and contents Guide # Links, bookmarks and contents External and internal links, named anchors, the PDF outline, and a table of contents with real page numbers. ## Links URLs, mailto and #anchors. A link target starting with `#` jumps to a named anchor in the same PDF (written as a PDF `/Dest` GoTo link); anything else is a URL. ```ruby text "Docs", link: "https://stationery.zoolutions.llc" text "Write to hi@acme.test", markup: true text { plain "See "; link "#totals", "the totals" } box(link: "#appendix", padding: 8, background: "#EEF2FF") { text "Appendix" } ``` `box(link:)` makes the whole box clickable. Link areas widen with justified text. ## Anchors Named link targets. ```ruby text "Introduction", anchor: "intro" # also box(anchor:), group(anchor:), table(rows, anchor:) box(anchor: "totals") { text "Totals" } anchor "appendix" # standalone; moves to the next page with what follows it text "Appendix", size: 16 ``` An anchor on content that splits across pages points at its first page. A link to an unknown anchor is dropped and reported as an `UnresolvedLink` warning; a name defined twice keeps the first and reports a `DuplicateAnchor`. Anchors drawn by page templates resolve to the first page. ## Bookmarks The PDF outline. `bookmark:` adds an entry to the PDF outline (the viewer's bookmarks sidebar) pointing at where the content paints. Levels nest under the nearest shallower entry above them; `open: true` shows an entry's children expanded. ```ruby text "Introduction", size: 18, bookmark: "Introduction" # level 1 box(bookmark: { title: "Scope", level: 2, open: true }) { … } # also group(bookmark:), table(rows, bookmark:) bookmark "Appendix", level: 1 # standalone text "Appendix", size: 16 ``` A document with bookmarks opens with the outline shown. A bookmark whose content never paints (cut off by a fixed-height box) is left out; bookmarks inside page templates are ignored. `html` and `markdown` add h1–h3 with `bookmarks: true`. ## Table of contents Filled in after pagination. `table_of_contents` lists the bookmarks with the page each one landed on, one clickable row per entry. It reads the outline when layout starts, so bookmarks declared after it are included. ```ruby text "Contents", size: 18 table_of_contents # every level, dotted leaders table_of_contents(levels: 1..2, leader: :line, indent: 16, size: 9, color: "#374151") ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `levels` | Range or Integer | 1.. | Which outline levels to list; an Integer is the maximum depth. | | `leader` | :dots, :line, nil | :dots | What fills the space between title and number. | | `indent` | Numeric | 12 | Points of indent per level. | | `gap` | Numeric | 4 | Space between rows and before the number. | | `number_width` | Numeric | width of "0000" | The fixed, right-aligned slot for page numbers. | | `**style` | text options | — | Any text style: `size`, `color`, `font`, … | Numbers are right-aligned in a fixed slot and filled in after pagination, so a long contents list paginates without reflowing. An entry whose target never paints keeps its title, with no number and no link. --- # Fonts Guide # Fonts Bundled Inter, your own TrueType and OpenType files, kerning, justification, a hook for a shaper, per-character fallback and installable font packs. ## Bundled Inter Zero configuration. Inter (regular, bold, italic, bold italic; SIL Open Font License) is bundled and used when a document declares no family. `font_family "Inter"` with no paths selects it explicitly, and `Stationery.bundled_fonts` lists what ships. Font files are read lazily, on first use, never when the gem is required. ## Your own families TTF and OTF. ```ruby class InvoicePdf < Stationery::Document font_family "Open Sans", regular: "fonts/OpenSans-Regular.ttf", bold: "fonts/OpenSans-Bold.ttf", italic: "fonts/OpenSans-Italic.ttf", bold_italic: "fonts/OpenSans-BoldItalic.ttf" default_text font: "Open Sans", size: 9 end ``` Fonts are TrueType (`.ttf`) or OpenType/CFF (`.otf`, name-keyed or CID-keyed) files. Only the glyphs a document uses are embedded (a CFF font keeps its glyph numbering and subroutines; unused glyphs are blanked), with a ToUnicode map so text copies and searches correctly. A style without its own file (bold, italic) is synthesised. A family name used at render time is resolved in order: registered families, bundled families, installed packs in `Stationery.font_paths`, then the first registered family, then bundled Inter. `font_family "Name"` with no paths raises `ArgumentError` when the name is neither bundled nor installed. ## Kerning and justification Text is pair-kerned from the font's GPOS `kern` feature (PairPos lookups, including class-based pairs and Extension lookups), falling back to the legacy `kern` table. `kerning: false` on an element or in `default_text` turns it off. Pairs that straddle a style or font change are not kerned. Kerning only tightens in practice, so a kerned line is never wider than the same line unkerned. Standard ligatures (fi, fl, ffi, …) come from the font's GSUB `liga` feature and are on by default; `ligatures: false` on an element or in `default_text` turns them off, and any `letter_spacing` does too. A ligature glyph maps back to all of its characters, so extracted and copied text is unchanged. Fonts without `liga` ligatures, such as bundled Inter, are unaffected. #### Hyphenation Off unless asked for. `hyphenate: "de"` on `text`, `text_style`, `default_text` or an `html`/`markdown` style (`styles: { p: { hyphenate: "de" } }`) breaks a word that does not fit at the longest point Liang's algorithm allows over the bundled TeX `hyph-utf8` patterns — `true` or `"en"` for American English, `"de"` for German (2006 orthography), `"sv"` for Swedish — and draws a hyphen at the break. Two letters stay together at the start of a word and two (three in English) at its end. A soft hyphen (U+00AD, `­` in HTML) names the break points of a word yourself and, as in TeX, exempts that word from the patterns; it is never measured or drawn and never reaches the PDF, so an unbroken word copies out whole. A hyphenated line is justifiable like any other wrapped line. #### Line breaking Lines break at spaces, after hyphens and at hyphenation points, and also at a zero-width space (U+200B, `` in HTML), which is never drawn and never reaches the PDF, and between ideographic characters — CJK ideographs, kana, Hangul, fullwidth forms — so Japanese, Chinese and Korean text wraps without spaces. A closing mark (。、」)ー and the small kana) stays on the line before it and an opening bracket (「() with what follows, the usual kinsoku rules. Justification still widens spaces only, so a line of CJK text is set flush left. ```ruby default_text hyphenate: "de" text "Die Silbentrennung der Donaudampfschifffahrt" # Silben- / trennung … at a narrow width text "Zei\u00ADtungsleser" # breaks only after "Zei" Stationery::Hyphenation.hyphenate("Silbentrennung", "de") # => ["Sil", "ben", "tren", "nung"] ``` The pattern sets ship with the gem under `lib/stationery/hyphenation/patterns/` with their authors' notices (`LICENSES.md`: the American English patterns' own permissive notice, MIT for German, LPPL for Swedish). Another language raises `ArgumentError` naming the bundled ones. `align: :justify` (on `text`, `text_style` and table cells) stretches the spaces of wrapped lines to the full width, keeping kerning. The last line, lines ending in a newline and lines without spaces stay left-aligned; tabs are never stretched. ## OpenType features Small caps, figure styles, stylistic sets. `features:` on `text`, `text_style` and `default_text` applies the font's other GSUB features on top of `liga`: `smcp` small caps, `onum`/`lnum` oldstyle or lining figures, `tnum`/`pnum` tabular or proportional figures (tabular for the number columns of a table), `zero` a slashed zero, `dlig` discretionary ligatures, `ss01`… stylistic sets, `case` case-sensitive punctuation. Tags are Symbols or Strings. ```ruby default_text font: "Open Sans", features: %i[onum] text "Total 2,026.00", features: %i[tnum lnum], align: :right text "Chapter One", features: [:smcp], letter_spacing: 0.5 ``` The reader applies single (SingleSubst formats 1 and 2) and ligature (LigatureSubst) lookups, also behind Extension lookups, in the font's own lookup order; a feature the font does not have is ignored without a warning, and `Stationery::Fonts::Font#features` lists the tags a font offers (bundled Inter: `tnum`, `zero`, `dlig`, `case` and stylistic sets; Open Sans: `onum`, `lnum`, `pnum`, `tnum`, `salt`). A substituted glyph keeps its source characters in the ToUnicode map, so extracted text is unchanged. Contextual features (`calt`, `clig`, `frac`) need lookup types the reader does not implement and do nothing, unless a shaper places the glyphs (below). ## Complex scripts The shaper hook: bring HarfBuzz, stationery draws what it places. Stationery places glyphs itself: one per character, the font's ligatures and single substitutions, pair kerning. Arabic, Hebrew, Indic and Thai text need a shaper, and the gem does not have one. An application that does (HarfBuzz) plugs it in, for a document class or for one render: ```ruby class Invoice < Stationery::Document shaper HarfBuzzShaper.new # inherited; `shaper nil` takes an inherited one away end Invoice.new.to_pdf(shaper: my_shaper) # this render only ``` A shaper is anything that answers `call`: ```ruby def call(text, font, size:, features:, language:, **) # => [Stationery::Shaper::Glyph.new(gid:, advance:, cluster:, x_offset: 0, y_offset: 0), …] or nil end ``` | Argument | What it is | | --- | --- | | `text` | One stretch of a line in one font and style | | `font` | A `Stationery::Shaper::Face`: `path` (the font file, nil when there is none), `index` (the face of a collection), `data` (the sfnt bytes; a WOFF is already unpacked), `units_per_em`, `postscript_name`, `glyph_count` | | `size:` | The size drawn at, in points | | `features:` | A frozen Hash of OpenType tags to switches: `"kern"` and `"liga"` always, following `kerning:` and `ligatures:` (letter spacing switches `liga` off), and the style's `features:` as `true` | | `language:` | The document's `metadata lang:`, or nil | The answer is the glyphs in **visual order**, left to right. `gid` is the glyph id in the font, `advance` and the offsets are in font units, and `cluster` is the index, in characters, of the first character of `text` the glyph stands for; glyphs of the same cluster stand together for the characters up to the next cluster. A Hash of the same fields does for a `Glyph`. Answering `nil` declines a text, which is then drawn as without a shaper. Anything else (a glyph id the font does not have, a cluster outside the text, an advance that is not a number) raises `Stationery::ShaperError`. Direction and script are not passed, because stationery knows neither: the shaper works them out from the text. What is shaped, and when: - Line breaking works on the text as written and measures each word and each stretch of spaces on its own. Each line is then cut where the font or the style changes, and every such stretch is shaped whole. That one answer is the stretch's width (alignment, underline, link rectangle) and what is drawn, so the two always agree. The width a break was decided on is the sum of the words', which can differ a little from the shaped line's, as kerning across a space already does. - The shaper is asked once per text, size and set of features in a render, and must answer the same for the same. - Every text a document draws goes through it: `text`, table cells, lists, `html`, `markdown`, headers and footers, the table of contents, text in `svg`. Form fields do not: a viewer that redraws a field after an edit would not shape it either. In the PDF the glyph ids are written as the shaper gave them. The font's widths stay its own, so an advance the shaper changed is a `TJ` adjustment, an x offset moves the pen before the glyph and back after it, and a y offset is a text rise (`Ts`) around it, which keeps marks in place under a synthetic oblique, a superscript or letter spacing. Glyphs the shaper placed are embedded whether or not a character maps to them. Where the ToUnicode map cannot give the text (glyphs out of logical order, several glyphs for one cluster, a glyph that already stands for another text, glyph 0) the glyphs are shown in a `Span` whose `ActualText` is the characters in logical order. Glyph 0 is reported as a `Warnings::MissingGlyph`, like any other, and under a [conformance](https://stationery.zoolutions.llc/docs/conformance) level with `missing_glyphs: :replace` it is drawn as the font's stand-in at the stand-in's advance. The limits of a hook: - The shaper reorders within the stretch it is given and nowhere else. Stretches on a line are placed left to right in the order written and lines break in that order, so there is no bidirectional reordering across a change of font or style; a right-to-left paragraph is set with `align: :right`, and the last line of a justified one is set left. - Fallback fonts are chosen per character from the fonts' cmaps before anything is shaped. Give the text the family that covers its script, or digits and punctuation the first family has are drawn from it, as stretches of their own. - Justification widens U+0020 spaces only (no kashida). Letter spacing is added after each cluster, so a mark stays on its base, but it pulls cursive letters apart. - Hyphenation and the breaking of a word wider than the line cut the text as written; the pieces are shaped as separate words. - Vertical advances are not read: text runs horizontally. - Extracted text depends on the reader: the gem's `Inspector` and PDFium get a right-to-left stretch as written, poppler and MuPDF reversed (see the table below). pdf-reader's own `Page#text` drops a `Span` whose first glyph has no advance (a mark drawn first); the `Inspector` does not. [`examples/shaping/harfbuzz_shaper.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/harfbuzz_shaper.rb) is an adapter for HarfBuzz through the [`harfbuzz-ruby`](https://github.com/ydah/harfbuzz) gem (`gem "harfbuzz-ruby"`, `require "harfbuzz"`; not the older `harfbuzz` gem, which answers to the same `require`), about a hundred lines to copy into an application; stationery does not depend on it. It was run with harfbuzz-ruby 1.1.0 and HarfBuzz 14.5.0 against Noto Sans Arabic, Amiri and Noto Sans Hebrew and draws joined, right-to-left Arabic with its marks and with left-to-right digits inside it. It cuts a stretch into runs of one direction by its letters alone, not by the Unicode bidirectional algorithm. The base direction of a stretch is its first letter's, so a line of an Arabic paragraph that starts with a number stays right to left. [`examples/shaping/rtl_letter.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/rtl_letter.rb) is a letter in Arabic set with it (`ARABIC_FONT=NotoNaskhArabic-Regular.ttf ruby -Ilib examples/shaping/rtl_letter.rb`); its booking number, in Latin letters from Inter, sits in a `row` beside its Arabic label, since stretches of two fonts on one line are placed left to right. What comes out of a shaped PDF depends on who reads it, and no way of writing right-to-left text is read as written by every extractor. Seven texts (Arabic, Hebrew, Arabic with digits in it, Arabic with its marks, an Arabic word in a Latin sentence, a justified paragraph, a line that wraps) were rendered three ways and read back: **A**, what the gem writes, a `Span` around the stretch with its text in logical order as `ActualText` (ISO 32000-1, 14.9.4); **B**, a `Span` for every cluster, in visual order; **C**, no `Span` for a glyph the ToUnicode map gives its text, in visual order, and one per cluster for the others. The cells count the texts that came back as written: | Arabic font | | `Inspector` | PDFium | PDFKit | pdf.js | poppler | MuPDF | | --- | --- | --- | --- | --- | --- | --- | --- | | Amiri (a glyph per letter) | **A** | 7 of 7 | 7 of 7 | 6 of 7 | 4 of 7 | 0 of 7 | 0 of 7 | | | B | 0 of 7 | 0 of 7 | 0 of 7 | 0 of 7 | 4 of 7 | 5 of 7 | | | C | 0 of 7 | 1 of 7 | 6 of 7 | 4 of 7 | 4 of 7 | 5 of 7 | | Noto Sans Arabic (a letter is its shape and its dots) | **A** | 7 of 7 | 6 of 7 | 1 of 7 | 1 of 7 | 0 of 7 | 0 of 7 | | | B | 0 of 7 | 0 of 7 | 0 of 7 | 0 of 7 | 1 of 7 | 1 of 7 | | | C | 0 of 7 | 0 of 7 | 1 of 7 | 1 of 7 | 1 of 7 | 1 of 7 | - PDFium (153.0.7999, what Chrome reads with, through pypdfium2 5.13) and the gem's `Inspector` return `ActualText` as it is: A reads as written. Without it (C) PDFium returned the words of a right-to-left line in reverse order. - poppler (`pdftotext` 26.09) and MuPDF (`mutool` 1.28.5) reorder what they read, `ActualText` included: A comes back reversed, B and C in Amiri as written, but for Arabic with digits or marks and, in poppler, a justified paragraph. - pdf.js (pdfjs-dist 6.3.289) and PDFKit (macOS 27, what Preview reads with) returned the same for A and C, which differ in nothing but `ActualText`: they read the glyphs through the ToUnicode map and reorder them. A cluster of several glyphs comes back with its text once per glyph, so a letter with a mark is doubled, and so is every dotted letter of the Noto Arabic families (Sans, Naskh and Kufi draw a letter as two glyphs). Only `ActualText` carries the text of such a font. - The Hebrew text (Noto Sans Hebrew) is the same in both halves of the table. - veraPDF 1.30.2 passes all 42 renders as PDF/UA-1, so the rules of the standard do not choose. - Acrobat, Preview itself and screen readers were not tested. The gem keeps A: it is what the specification describes, the one PDFium reads as written, and the only one that holds with a font whose glyphs are not letters. None of the three is read as written by poppler or MuPDF and by PDFium. [`examples/shaping/extraction_matrix.rb`](https://github.com/zoolutions/stationery/blob/main/examples/shaping/extraction_matrix.rb) renders the texts, holds B and C as experiments and writes the table again, with what to install at its head. ## Fallbacks Per-character, in the same weight and style. A character the text's family has no glyph for is drawn from the first family in `font_fallbacks` that has it, then from bundled Inter, in the same weight and style (synthesised when the family lacks the face): ```ruby class Report < Stationery::Document font_family "Brand", regular: "Brand-Regular.ttf" font_family "Noto Sans Symbols", regular: "NotoSansSymbols-Regular.ttf" font_fallbacks "Noto Sans Symbols" def view_template = text("Next → ☃") end ``` Spaces, joiners, variation selectors and combining marks stay with the character before them. Whitespace no font has (an ideographic space U+3000, a figure space, a narrow no-break space, …) is drawn as a blank of the character's conventional width, never as `.notdef`. Any other glyph no font has is drawn as the family's `.notdef` and reported as a `Warnings::MissingGlyph` counting each drawn occurrence (so `strict` raises on it, and a [conformance](https://stationery.zoolutions.llc/docs/conformance) level raises `ConformanceError` unless it is declared with `missing_glyphs: :replace`, which draws the first of U+FFFD, U+25A1 and `?` the font has instead). The characters themselves travel as the `ActualText` of a `Span` around the glyphs, so the text still extracts, copies and reads aloud as written. Fallback covers every text element, table cell, list marker, table of contents entry and page template text; direct `canvas.text` calls draw with the font they are given. ## Font packs Installed at development time, committed, never downloaded at runtime. | Pack | Family | License | | --- | --- | --- | | `inter` | Inter (copied from the gem) | OFL 1.1 | | `noto_sans`, `noto_serif`, `noto_sans_mono` | Noto Sans, Noto Serif, Noto Sans Mono | OFL 1.1 | | `liberation_sans`, `liberation_serif`, `liberation_mono` | Liberation Sans, Serif, Mono (metric-compatible with Arial, Times New Roman, Courier New) | OFL 1.1, Reserved Font Name "Liberation" | ```shell stationery fonts list # packs, licenses, what is in vendor/fonts stationery fonts install noto_sans liberation_serif # into vendor/fonts// stationery fonts install noto_sans --into app/fonts --force stationery fonts install liberation_sans --from ~/Downloads/liberation-fonts-ttf-2.1.5.tar.gz # offline bin/rails generate stationery:fonts noto_sans # the same, in Rails ``` `fonts install` downloads pinned files over HTTPS, checks every SHA-256 before writing anything, and writes the license next to the fonts. Files already present are kept unless `--force`. `--from` takes a directory or a `.tar.gz` holding the same files (still SHA-checked). From Ruby: `Stationery::Fonts.install(:noto_sans, into: "vendor/fonts")` returns `{ regular: path, bold: path, … }`; `Stationery::Fonts.catalog` lists the packs. `rake fonts:verify` (development of the gem only) downloads every pack and checks its SHA-256s. ## Stationery.font_paths Where installed packs are found. `Stationery.font_paths` lists the directories searched for installed packs; the Railtie adds `vendor/fonts` (and `config.stationery.font_paths`) when it exists. With the pack's directory there, the family name is enough: ```ruby font_family "Noto Sans" # found in Stationery.font_paths font_family "Noto Sans", **Stationery::Fonts.paths(:noto_sans, dir: "vendor/fonts") # outside Rails Stationery.font_paths << File.expand_path("vendor/fonts") # or append it yourself ``` Append before `Stationery.finalize!` freezes the list: the Railtie runs it once the app has initialized, so add paths in `config/application.rb` or an initializer. See [Performance](https://stationery.zoolutions.llc/docs/performance#threads-fibers-and-ractors). --- # Images and SVG Guide # Images and SVG JPEG, PNG and lossless WebP images with aspect-preserving sizing, and the SVG subset icon sets use — drawn as vectors. ## Formats Decoded in Ruby, cached per process. Images are **JPEG** (grey, RGB, CMYK), **PNG** (every colour type, alpha as a soft mask, palette transparency) and lossless **WebP**. The source is a file path or any IO (`StringIO`, an ActiveStorage download, …). Parsed fonts and images are cached per process, keyed by content, so the same logo on every page is embedded once. A WebP is decoded in Ruby (the VP8L bitstream, in a plain or an extended container; colour profile and metadata chunks are skipped) and embedded like a PNG: RGB, with the alpha channel as a soft mask when a pixel is not opaque. Decoding takes 0.2 to 0.4 s per megapixel, once per process for the same bytes. Lossy and animated WebP raise `UnsupportedImage` naming which it is, as does a file that is cut short or damaged; convert those to JPEG, PNG or lossless WebP (`cwebp -lossless`). A JPEG is embedded in a PDF byte for byte and decoded in Ruby only where pixels are needed: `to_png`, and a `monochrome` render, which dithers it. Baseline, extended and progressive JPEGs decode (every chroma subsampling, restart intervals, grey, RGB, YCbCr, CMYK and YCCK) to the pixels libjpeg-turbo gives, a photo drawn small at a half, a quarter or an eighth of its size. A lossless, arithmetic-coded, hierarchical or 12-bit JPEG, or one of more than 33 megapixels, is not decoded: `to_png` draws a crossed box (`SkippedImage`) and monochrome embeds it as it is (`NotMonochrome`). The EXIF orientation is not applied, in a picture as in the PDF. ## Sizing and fit `image(source, width: nil, height: nil, fit: nil, align: nil, opacity: nil, radius: 0, rotate: 0, max_ppi: 300, downscale: false, float: nil, margin: nil)` | Given | Result | | --- | --- | | nothing | one pixel is one point | | `width:` or `height:` | the other side follows the aspect ratio | | both | exactly that size | | `fit: [w, h]` | scaled to fit inside the box, aspect preserved | | `fit: :cover` with `width:` and `height:` | scaled to fill the box, aspect preserved, the excess cropped around the centre (CSS `object-fit: cover`) | | `radius:` | corners rounded by that many points (the image is clipped) | | `rotate:` | turned that many degrees clockwise around its centre; the space it takes up does not change (CSS `transform: rotate`) | | `float: :left` or `:right` | taken to that side of the flow; the text and the lists that follow wrap beside it, `margin:` away (CSS `float`) | An image is never wider than the space it is given; it scales down to the column. `align:` (`:left`, `:center`, `:right`) positions a narrower image and is not read on a floated one. `margin:` is a number (kept on the sides that face the text) or names the sides as `padding:` does; the rules for what wraps and what moves below are on [Elements](https://stationery.zoolutions.llc/docs/elements#floats). ```ruby image "logo.png", height: 34, align: :right image StringIO.new(product.photo.download), fit: [120, 80] # text wraps beside the photo and continues below it image photo, float: :left, width: 0.4, margin: { right: 14, bottom: 8 }, alt: "The bay at dawn" # a tilted, rounded snapshot cropped to 4:3 image photo, width: 160, height: 120, fit: :cover, radius: 8, rotate: -3 ``` A rotated image paints outside its rectangle at the corners, so leave room around it (a `box(padding:)`). ## Resolution Bitmaps keep their pixels. A JPEG is embedded byte for byte and a PNG or WebP re-encoded losslessly, so a 1600 px photo drawn 160 pt wide ships all 1600 px at 720 ppi. Print needs about 300 ppi (`width_in_points / 72 * 300` pixels: a 160 pt photo wants about 670 px) and screen PDFs half that. Every image is checked when it is painted: drawn at more than twice `max_ppi:` it is reported as a [`Warnings::OversizedImage`](https://stationery.zoolutions.llc/docs/warnings) naming the file, its pixel width and the resolution it lands at, so `strict` catches it. ```ruby class Flyer < Stationery::Document images max_ppi: 300, downscale: true # document defaults; nil switches the check off end image "photo.png", width: 160 # a 1600 px PNG is resampled to 667 px image "photo.jpg", width: 160, max_ppi: nil # this one is fine as it is ``` `downscale: true` resamples a **PNG** or a **WebP** to `max_ppi` at its drawn size with a box filter (alpha and palette transparency included) and embeds that; the layout, the tagged `Figure` and the text around it do not change. A **JPEG** is never re-encoded, so it only warns: size JPEGs before you embed them. With ActiveStorage, make a JPEG variant per drawn size (`resize_to_limit`, quality 75) and preprocess it, so a render only downloads. A 10-page flyer with 18 photos went from 3.3 MB to under 1 MB that way with no visible difference. ## SVG Icons and simple drawings as vectors. `svg(source_or_path, width: nil, height: nil, color: "#000000", align: nil)` takes SVG markup or a path to a `.svg` file. `currentColor` takes `color:`, so one icon file serves every theme colour. ```ruby CHECK = <<~SVG SVG row { column(width: 16) { svg CHECK, width: 14, color: "#0F766E" }; column { text "Shipped" } } ``` **Supported:** `path` (every command, including arcs), `rect` (with `rx`/`ry`), `circle`, `ellipse`, `line`, `polyline`, `polygon` and `g`, with fill, stroke, line caps and joins, `fill-rule`, opacity, inline `style` attributes, `