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" } }
]
}
| Key | Type | Meaning |
|---|---|---|
fonts | array of strings | Font families to use. The first one that matches a loaded font is the text face; the others are fallbacks. See Fonts. |
options | object | Document options: page size, margins, background, watermark, accessibility. See below. |
header | array of elements | Drawn in the header band at the top of every page. |
footer | array of elements | Drawn in the footer band at the bottom of every page. |
content | array of elements | The 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 withSoli::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
ygrows 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": []
}
| Key | Type | Default | Meaning |
|---|---|---|---|
page | string or object | "a4" | A preset (a4, letter, legal, a5, a3) or a custom size { "width": …, "height": … } in points. |
orientation | string | "portrait" | "landscape" swaps the width and height. |
margins | number or object | 56.693 (20 mm) | One number for all four sides, or an object overriding some sides. |
header_height | number | 0 | Height of the band reserved for the header. |
background | string | — | A fill colour painted behind every page. |
backgroundImage | object | — | A full-page image behind the content. |
watermark | object | — | A diagonal stamp such as PAID or DRAFT. |
tagged | boolean | false | Produce a tagged, accessible PDF (PDF/UA). |
lang | string | "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.
| Preset | Size (pt) |
|---|---|
a4 | 595.3 × 841.9 |
letter | 612 × 792 |
legal | 612 × 1008 |
a5 | 420.9 × 595.3 |
a3 | 841.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_heightreserves 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." } ]
}
| Field | Default | Meaning |
|---|---|---|
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]. |
opacity | 1 | 0 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" } ]
}
| Field | Default | Meaning |
|---|---|---|
text | — | The stamp text. Required. |
angle | 45 | Rotation in degrees. |
color | light grey | Hex fill colour. Pick a pale tint: the stamp is not transparent. |
fontSize | 96 | Point size. |
fontWeight | "bold" | "normal" or "bold". |
front | false | true draws the stamp over the content instead of behind it, so filled panels and images cannot hide it. |
x / y | page centre | Explicit 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:
| Content | Structure role |
|---|---|
A paragraph with a bookmark (or bookmarkLevel) | H1 to H6, from bookmarkLevel (default 1) |
| Any other paragraph | P |
A list | L › LI › LBody |
A table | Table › TR › TH / TD (header cells carry a scope) |
An image, qr or barcode | Figure, with the image's alt as alternative text |
| Rules, the watermark, background art, the header and footer bands | Artifact — 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 bands
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." } ]
}
#Header
- The header is drawn from the top margin, inside a band of
header_heightpoints. The content starts below that band. - Set
header_height. It defaults to0, 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_heightsimply spills over the content. - Page tokens (
#PAGE#,#PAGES#) work in header paragraphs.
#Footer
The footer band is more limited, and it sizes itself:
- Supported elements:
paragraph,hr,imageandmove(which advance the footer cursor), andrect,lineandellipse(drawn at the cursor without advancing it — place them withmove). Anything else is skipped with a warning. - Footer paragraphs are single lines: they do not wrap, so keep them short. They use
valueonly — aspansparagraph 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,
movedistances and image heights, plus 6 pt. Give footer images an explicitheight, 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
- Text & paragraphs — paragraphs, rich text, lists, links and fonts.
- Layout & drawing — the cursor, boxes, columns, shapes and images.
- Tables, Charts, QR codes & barcodes.
- Data binding —
${…},repeat,ifand page numbers. - Try any snippet on this page in the playground.