soli-pdf

Template structure

A template is a JSON document that describes the page: its size and margins, a running header and footer, and the content that flows from top to bottom. This page covers the top-level keys and the document-wide options; the elements themselves are documented in the pages that follow.

#Anatomy of a template

A template has five top-level keys. All of them are optional, and an empty content renders a blank page.

{
  "fonts":   ["titillium"],
  "options": { "header_height": 0 },
  "header":  [],
  "footer":  [],
  "content": [
    { "type": "paragraph", "value": "Hello ${customer.name}",
      "options": { "fontSize": 18, "fontWeight": "bold" } }
  ]
}
KeyTypeMeaning
fontsarray of stringsFont families to use. The first one that matches a loaded font is the text face; the others are fallbacks. See Fonts.
optionsobjectDocument options: page size, margins, background, watermark, accessibility. See below.
headerarray of elementsDrawn in the header band at the top of every page.
footerarray of elementsDrawn in the footer band at the bottom of every page.
contentarray of elementsThe page body, laid out top to bottom. It paginates on its own.

Every element is an object with a type: paragraph, list, table, chart, image, qr, barcode, move, at, box, columns, page_break, hr, rect, line, ellipse, repeat, if and unless.

From Ruby, the template can be a Hash, an Array or a JSON String. Hashes are serialised with JSON.generate, so string and symbol keys both work:

require 'soli/pdf'

template = JSON.parse(File.read('app/pdf/invoice.template.json'))
pdf = Soli::PDF.render(template: template, data: { 'customer' => { 'name' => 'Maison Lumière' } })
File.binwrite('invoice.pdf', pdf)

Warning The template is parsed strictly. An unknown element type, or a value of the wrong JSON type (a string where a number is expected, "fontSize": "12"), fails the whole render with Soli::PDF::RenderError. Unknown keys, on the other hand, are silently ignored — a misspelt option simply has no effect. See Errors & warnings.

#Units and coordinates

  • Every length is in points (1 pt = 1/72 inch; 1 mm ≈ 2.835 pt).
  • An A4 page is 595.3 × 841.9 pt. With the default 20 mm margins the content area is 481 pt wide — the number every column width in the samples adds up to.
  • The origin is the top-left corner and y grows downwards, as on a screen.
  • Colours are hex strings without #: "0F766E", or the 3-digit form "fff".

#Document options

{
  "options": {
    "page": "a4",
    "orientation": "portrait",
    "margins": { "top": 42, "right": 50, "bottom": 46, "left": 50 },
    "header_height": 34,
    "background": "FFFFFF",
    "watermark": { "text": "CONFIDENTIAL", "color": "f1f5f9", "fontSize": 92 }
  },
  "content": []
}
KeyTypeDefaultMeaning
pagestring or object"a4"A preset (a4, letter, legal, a5, a3) or a custom size { "width": …, "height": … } in points.
orientationstring"portrait""landscape" swaps the width and height.
marginsnumber or object56.693 (20 mm)One number for all four sides, or an object overriding some sides.
header_heightnumber0Height of the band reserved for the header.
backgroundstring—A fill colour painted behind every page.
backgroundImageobject—A full-page image behind the content.
watermarkobject—A diagonal stamp such as PAID or DRAFT.
taggedbooleanfalseProduce a tagged, accessible PDF (PDF/UA).
langstring"en-US"Document language, written to the PDF catalog. Used with tagged.

#Page size

Presets are case-insensitive. An unknown preset name falls back to A4 without a warning, so double-check the spelling if the page comes out the wrong size.

PresetSize (pt)
a4595.3 × 841.9
letter612 × 792
legal612 × 1008
a5420.9 × 595.3
a3841.9 × 1190.6

A custom size — here a 4 × 6 inch shipping label:

{
  "options": { "page": { "width": 288, "height": 432 }, "margins": 18 },
  "content": [ { "type": "paragraph", "value": "4 × 6 label" } ]
}

A landscape US Letter page:

{
  "options": { "page": "letter", "orientation": "landscape" },
  "content": [ { "type": "paragraph", "value": "Landscape" } ]
}

#Margins

margins takes a single number or an object; sides you leave out keep the 20 mm default.

{ "options": { "margins": 40 }, "content": [] }
{ "options": { "margins": { "top": 90, "left": 70, "right": 70, "bottom": 80 } }, "content": [] }

The margins frame everything, bands included:

  • the top margin is the gap above the header band;
  • header_height reserves the header band just below it, and the content starts under the band;
  • the footer band sizes itself from its content and sits just above the bottom margin;
  • the left and right margins set the content width.

Wider margins mean a narrower content area. invoice_minimal uses "margins": 64, which leaves 467 pt instead of 481 — and every column width in that file adds up to 467.

#Background colour and image

background paints a solid colour behind every page. backgroundImage stretches an image over the whole sheet, above the background colour and below the watermark and content.

{
  "options": {
    "background": "FFFBEB",
    "backgroundImage": {
      "src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 595 842'><circle cx='560' cy='788' r='150' fill='none' stroke='%230F766E' stroke-width='10'/></svg>",
      "pages": "first",
      "opacity": 0.15
    }
  },
  "content": [ { "type": "paragraph", "value": "A cover page with a faint mark in the corner." } ]
}
FieldDefaultMeaning
src—The image, in any form the image element accepts. Required.
pages"all""all", "first", "last", or a list of 1-based page numbers such as [1, 3].
opacity10 to 1. A low value such as 0.15 turns a photo or a logo into a faint tint.

The feature tour uses an SVG gradient as a first-page background.

Tip For a real letterhead — a PDF your designer already produced — use the gem's stationery: option instead: the letterhead PDF is drawn beneath every page. See Options.

#Watermark

A watermark is large rotated text stamped on every page — PAID, DRAFT, COPY, CONFIDENTIAL.

{
  "options": {
    "watermark": { "text": "DRAFT", "angle": 45, "color": "e8c4c4", "fontSize": 96, "fontWeight": "bold" }
  },
  "content": [ { "type": "paragraph", "value": "Quote Q-2026-014" } ]
}
FieldDefaultMeaning
text—The stamp text. Required.
angle45Rotation in degrees.
colorlight greyHex fill colour. Pick a pale tint: the stamp is not transparent.
fontSize96Point size.
fontWeight"bold""normal" or "bold".
frontfalsetrue draws the stamp over the content instead of behind it, so filled panels and images cannot hide it.
x / ypage centreExplicit centre point, in points from the top-left corner.
anchor"center"Vertical placement when y is not set: "top", "center" or "bottom".
pages"all""all", "first", "last", or a list of 1-based page numbers.

Stamp only the first page, near the top, on top of everything:

{
  "options": { "watermark": { "text": "PAID", "front": true, "anchor": "top", "pages": "first" } },
  "content": [ { "type": "paragraph", "value": "Invoice AN-2025-0042" } ]
}

A table can also carry its own watermark, stamped over that table only — see Per-table watermark. The annual report uses a document watermark.

#Accessible (tagged) PDF

Set "tagged": true to produce a tagged PDF: every piece of content is wrapped with a semantic role, and the file gains the structure tree, reading order and language that screen readers rely on. The output identifies itself as PDF/UA-1.

{
  "options": { "tagged": true, "lang": "fr-FR" },
  "content": [
    { "type": "paragraph", "value": "Déclaration d'accessibilité",
      "options": { "fontSize": 24, "fontWeight": "bold", "bookmark": "Déclaration", "bookmarkLevel": 1 } },
    { "type": "paragraph", "value": "Ce document est un PDF balisé." }
  ]
}

Roles come from the template, so there is nothing else to annotate:

ContentStructure role
A paragraph with a bookmark (or bookmarkLevel)H1 to H6, from bookmarkLevel (default 1)
Any other paragraphP
A listL › LI › LBody
A tableTable › TR › TH / TD (header cells carry a scope)
An image, qr or barcodeFigure, with the image's alt as alternative text
Rules, the watermark, background art, the header and footer bandsArtifact — skipped by assistive technology

Give every meaningful image an alt. A figure without alternative text fails PDF/UA, so a tagged render adds a warning to Soli::PDF.last_warnings for each image that has none. Headings come from bookmarks, so a document with a proper outline (see Bookmarks) is also a document with proper headings.

{ "type": "image", "value": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'><circle cx='20' cy='20' r='18' fill='%230f766e'/></svg>", "width": 40, "alt": "Company logo" }

See it in the accessible example.

header and footer are arrays of elements drawn on every page, each from its own cursor. Both are treated as page furniture: in a tagged PDF they are artifacts, not content.

{
  "fonts": ["titillium"],
  "options": { "header_height": 34 },
  "header": [
    { "type": "paragraph", "value": "${company.name} · Annual Report",
      "options": { "fontSize": 9, "alignment": "right", "color": "94a3b8" } },
    { "type": "move", "y": 6 },
    { "type": "hr", "color": "e2e8f0", "thickness": 1 }
  ],
  "footer": [
    { "type": "paragraph", "value": "Page #PAGE# of #PAGES#",
      "options": { "fontSize": 8, "alignment": "center" } }
  ],
  "content": [ { "type": "paragraph", "value": "Body text starts below the header band." } ]
}
  • The header is drawn from the top margin, inside a band of header_height points. The content starts below that band.
  • Set header_height. It defaults to 0, and a header drawn into a zero-height band overlaps the first lines of content.
  • Any element works in the header, including data-bound ones (table, repeat, chart), which see the whole data document.
  • The header never paginates: whatever does not fit in header_height simply spills over the content.
  • Page tokens (#PAGE#, #PAGES#) work in header paragraphs.

The footer band is more limited, and it sizes itself:

  • Supported elements: paragraph, hr, image and move (which advance the footer cursor), and rect, line and ellipse (drawn at the cursor without advancing it — place them with move). Anything else is skipped with a warning.
  • Footer paragraphs are single lines: they do not wrap, so keep them short. They use value only — a spans paragraph renders nothing in the footer — and they are always aligned across the full content width.
  • The band height is the sum of its paragraph line heights (font size × 1.2), rules, move distances and image heights, plus 6 pt. Give footer images an explicit height, otherwise they are not counted and spill below the band.
  • Page tokens (#PAGE#, #PAGES#, #TOTAL_PAGE#) are the usual reason for a footer. See Page numbers.
{
  "footer": [
    { "type": "hr", "color": "e2e8f0" },
    { "type": "move", "y": 4 },
    { "type": "paragraph", "value": "Helios Coffee SAS · RCS Lyon 842 917 361 · Page #PAGE# of #TOTAL_PAGE#",
      "options": { "fontSize": 8, "alignment": "center", "color": "64748b" } }
  ],
  "content": [ { "type": "paragraph", "value": "Body" } ]
}

#Where to go next