Text & paragraphs
Text is set by the renderer itself: it measures, wraps and aligns every line with the real font metrics. This page covers paragraphs, inline rich text, lists, links, bookmarks and fonts.
#Paragraphs
A paragraph is a block of wrapped text. It starts at the cursor, wraps at the right margin (or at the inner edge of the box it sits in), and moves the cursor below its last line.
{ "type": "paragraph", "value": "Invoice ${invoice.number}",
"options": { "fontSize": 24, "fontWeight": "bold", "color": "0f766e" } }
The text is either a plain value — one style for the whole block, with ${…} placeholders filled from the data (see Data binding) — or an array of styled spans (see Inline rich text). When both are present, spans wins.
#Paragraph options
All styling lives in options:
| Option | Type | Default | Meaning |
|---|---|---|---|
alignment | string | "left" | left, right, center or justify (case-insensitive). |
fontSize | number | 12 | Size in points. |
fontWeight | string | "normal" | normal or bold. |
italic | boolean | false | Use the italic face. |
mono | boolean | false | Use the monospace face (JetBrains Mono). |
color | string | "000000" | Text colour, hex without #. |
underline | boolean | false | Underline, drawn in the text colour. |
strike | boolean | false | Strike through, drawn in the text colour. |
lineHeight | number | 1.2 | Line height as a multiple of the font size. |
spacing | number | 0 | Extra space (pt) below the paragraph. |
minSpaceBelow | number | — | Keep-together: only start the paragraph on this page if this many points remain below its first line. |
link | string | — | Make the paragraph a link to an external URL. |
linkTo | string | — | Make the paragraph a link to an anchor in the same document. |
anchor | string | — | Name this paragraph as a jump target. |
bookmark | string | — | Add an entry to the PDF outline (the viewer's sidebar). |
bookmarkLevel | number | 1 | Nesting level of the bookmark: 2 nests under the previous level-1 entry, and so on. |
#Alignment and justification
justify spreads each line to both margins by widening the word gaps. The last line of the paragraph stays left-aligned, as in any book.
{ "type": "paragraph",
"value": "The renderer typesets text itself — measuring, wrapping and justifying every line, with no browser involved. Both edges of this paragraph line up, while its final line stays left-aligned.",
"options": { "fontSize": 10, "alignment": "justify", "lineHeight": 1.4 } }
#Weight, style and decoration
The bundled fonts provide regular, bold, italic and bold italic faces for the text family, and a monospace family. Underline and strike-through follow the text colour.
[
{ "type": "paragraph", "value": "Bold heading", "options": { "fontSize": 16, "fontWeight": "bold" } },
{ "type": "paragraph", "value": "An italic aside.", "options": { "italic": true, "color": "64748b" } },
{ "type": "paragraph", "value": "ORDER-4821", "options": { "mono": true } },
{ "type": "paragraph", "value": "Was 99.00 EUR", "options": { "strike": true, "color": "b91c1c" } }
]
#Spacing and keep-together
The cursor sits right under a paragraph's last line. There are two ways to add air:
spacingadds a gap below the paragraph, the declarative alternative to a following{ "type": "move", "y": … }.lineHeightchanges the distance between the lines inside it.1.0is tight,1.5is airy.
minSpaceBelow stops a heading from being stranded at the bottom of a page, away from the text it introduces: if fewer points than that remain below the heading's first line, the heading moves to the next page. About two body lines (30 pt) is a good value.
[
{ "type": "paragraph", "value": "How roles are assigned",
"options": { "fontSize": 16, "fontWeight": "bold", "spacing": 6, "minSpaceBelow": 30 } },
{ "type": "paragraph", "value": "A bookmarked paragraph becomes a heading; any other paragraph is body text.",
"options": { "lineHeight": 1.5, "spacing": 10 } }
]
#Inline rich text
To mix styles inside one paragraph — a bold amount, a coloured word, a link, a bit of code — give it spans instead of value. The spans wrap together as one flow of text, and each line is as tall as its largest span.
{ "type": "paragraph", "options": { "fontSize": 12 }, "spans": [
{ "text": "Amount due: " },
{ "text": "EUR 600.00", "fontWeight": "bold", "color": "0F766E", "fontSize": 16 },
{ "text": " — " },
{ "text": "pay online", "link": "https://pay.example/42", "color": "2563eb", "underline": true },
{ "text": " (reference " }, { "text": "F-42", "mono": true }, { "text": ")" }
] }
| Span field | Type | Meaning |
|---|---|---|
text | string | The text. Required. ${…} placeholders are filled from the data. |
fontSize | number | Size in points. |
fontWeight | string | normal or bold. |
italic | boolean | Italic face. |
mono | boolean | Monospace face. |
color | string | Hex colour. |
link | string | External URL: this span becomes a clickable link. |
underline | boolean | Underline this span. |
strike | boolean | Strike this span through, in its own colour — a red struck price reads as a redline. |
A span inherits fontSize, fontWeight, italic, mono, underline and strike from the paragraph's options when it does not set them. The paragraph-level alignment (including justify), lineHeight, spacing, minSpaceBelow, bookmark and anchor apply to the whole block.
Warning A few paragraph options do not carry over to spans:
color— spans default to black. Setcoloron every span that needs one.linkandlinkTo— only a span's ownlinkis clickable, and there is no span-level internal link. For a clickable table-of-contents line, use avalueparagraph.- Page tokens —
#PAGE#and friends are printed literally in spans. Use avalueparagraph for them.- The footer band ignores
spansparagraphs entirely.
A redlined price, the way the feature tour shows it:
{ "type": "paragraph", "options": { "fontSize": 10.5 }, "spans": [
{ "text": "Was " },
{ "text": "99.00", "strike": true, "color": "b91c1c" },
{ "text": ", now " },
{ "text": "79.00 EUR", "fontWeight": "bold", "color": "0F766E" }
] }
Tip If your content is Markdown (
**bold**,*italic*, links), convert it to spans in Ruby before rendering — a few lines with a regular expression or a Markdown parser's AST are enough, since each span is just a hash.
#Lists
A list is a bulleted or numbered list. It flows like a paragraph and moves the cursor below its last item.
{ "type": "list", "ordered": true, "spacing": 3, "options": { "fontSize": 11 }, "items": [
"Grind the beans",
"Boil water to 94 °C",
{ "text": "Brew", "list": { "marker": "–", "items": ["Bloom 30 s", "Pour to 250 g"] } },
{ "spans": [ { "text": "Enjoy " }, { "text": "responsibly", "italic": true } ] }
] }
| Field | Type | Default | Meaning |
|---|---|---|---|
items | array | — | The items (see below). |
ordered | boolean | false | Number the items 1., 2., … instead of using a bullet. |
start | number | 1 | First number of an ordered list. |
marker | string | "•" | Bullet of an unordered list. It must be a character the font covers — •, –, — and · are safe with the bundled fonts. |
indent | number | 18 | Left indent (pt) of this level. Must be greater than 0: 0 falls back to 18. |
spacing | number | 0 | Extra space (pt) after each item. |
options | object | — | Text style of the items: fontSize, fontWeight, color, lineHeight, alignment, italic, mono… options.spacing adds space below the whole list. |
An item is either a plain string, or an object with:
text— the item text, orspans— inline rich text (which wins overtext);list— a nested list, drawn below the item and indented one level deeper. A nested list inherits the parent's text style unless it sets its ownoptions, and can have its ownmarkerorordered.
Item text is interpolated, so "${invoice.terms}" works as an item. To build a list from an array in the data, use repeat with one paragraph per item instead.
Note As with paragraphs,
options.colorcolours plain-text items but notspansitems: give those spans their owncolor.
The terms block of the compliant invoice is a dash list in small grey type:
{ "type": "list", "marker": "—", "indent": 10, "spacing": 2,
"options": { "fontSize": 8, "color": "4B5A70", "lineHeight": 1.35 },
"items": [
"Payment terms: ${invoice.terms}.",
"Late payment: ECB refinancing rate plus 10 points, plus a €40 recovery indemnity.",
"Goods remain the property of ${seller.name} until paid in full."
] }
#Links
Three kinds of text can link to a web address:
- a
valueparagraph, withoptions.link— the whole paragraph is clickable; - a span, with
link— only that span is clickable; - a table text cell, with
link— see Tables.
[
{ "type": "paragraph", "value": "Pay online", "options": { "link": "https://pay.example/42", "color": "2563eb", "underline": true } },
{ "type": "paragraph", "spans": [
{ "text": "Questions? See " },
{ "text": "our FAQ", "link": "https://example.com/faq", "color": "2563eb" },
{ "text": "." }
] }
]
Links are not styled automatically: add a color and underline so readers can see them.
Note A URL is used exactly as written:
${…}is not filled in insidelink(nor insidebookmark). For a per-document URL such ashttps://pay.example/F-42, set the value on the template Hash in Ruby before rendering — see What gets interpolated.
#Internal links and anchors
anchor names a paragraph; linkTo on a value paragraph jumps to it. Anchors are also what #PAGE_OF:anchor# uses to print a page number, which together give a clickable table of contents.
[
{ "type": "paragraph", "value": "Jump to the terms", "options": { "linkTo": "terms", "color": "2563eb" } },
{ "type": "page_break" },
{ "type": "paragraph", "value": "Terms and conditions",
"options": { "fontSize": 16, "fontWeight": "bold", "anchor": "terms" } }
]
link_to is accepted as an alias of linkTo.
#Bookmarks
bookmark adds an entry to the document outline — the navigation sidebar of PDF viewers. bookmarkLevel nests entries the way heading levels do: a level-2 bookmark sits under the previous level-1 one.
[
{ "type": "paragraph", "value": "1. Revenue", "options": { "fontSize": 18, "fontWeight": "bold", "bookmark": "Revenue" } },
{ "type": "paragraph", "value": "1.1 By region", "options": { "fontSize": 13, "fontWeight": "bold", "bookmark": "By region", "bookmarkLevel": 2 } }
]
In a tagged PDF a bookmarked paragraph is also a heading (H1 to H6, from bookmarkLevel). The annual report and the feature tour both have a full outline.
#Fonts
#The bundled fonts
The renderer ships with two families, installed next to the render_pdf binary:
| Family | Faces | Used for |
|---|---|---|
| Titillium Web | Regular, Bold, Italic, Bold Italic | Text |
| JetBrains Mono | Regular, Bold, Italic | mono: true |
Name the text family in the template's fonts key. The name is matched loosely against font file names, ignoring case, spaces, dashes and underscores — "titillium", "TitilliumWeb" and "Titillium Web" all match TitilliumWeb-Regular.ttf.
{ "fonts": ["titillium"], "content": [ { "type": "paragraph", "value": "Set in Titillium Web" } ] }
Always set fonts. If no loaded font matches the name — or the key is missing — every non-monospace font found becomes a candidate for the text face, which gives unpredictable results once you add fonts of your own. There is no warning for an unknown family name.
#Adding your own fonts
Put TrueType or OpenType files (.ttf, .otf, .ttc) in a directory and pass it with the gem's fonts: option. It takes one directory or an array of them; they are searched before the bundled fonts.
Soli::PDF.render(
template: template,
data: data,
fonts: [Rails.root.join('app/pdf/fonts').to_s]
)
With Inter-Regular.ttf, Inter-Bold.ttf, Inter-Italic.ttf and Inter-BoldItalic.ttf in that directory, "fonts": ["inter"] sets the document in Inter:
{ "fonts": ["inter"], "content": [ { "type": "paragraph", "value": "Set in Inter" } ] }
How the renderer sorts the fonts it finds:
- Faces whose file name matches the requested family become the text family. Bold and italic are detected from the font's own flags, or from
Bold/Italic/Obliquein the file name. - Monospace fonts (by flag, or
Monoin the file name) supplymono: true. The first one found wins, and your directories come first — so a mono font of yours replaces JetBrains Mono. - Every other font becomes a fallback, used for characters the text family does not cover.
#Fallbacks and missing glyphs
Text is split into runs by which font covers each character: the text face first, then each fallback in turn. That is how a Latin document can carry a Japanese customer name — add a CJK font such as Noto Sans JP to a font directory and it fills in the missing characters.
# app/pdf/fonts holds NotoSansJP-Regular.ttf
pdf = Soli::PDF.render(template: template, data: { 'customer' => { 'name' => '山田商事' } },
fonts: ['app/pdf/fonts'])
A character that no loaded font covers is dropped from the output, and the render adds a warning:
Soli::PDF.render(template: template, data: data)
Soli::PDF.last_warnings
# => ["no font covers some glyphs in \"Status ✓\""]
The bundled fonts cover Latin text with the usual punctuation and symbols — €, •, –, —, …, ×, ½ — but not arrows, check marks or box-drawing symbols (→, ✓, ✔, ☐, □, ■, ●). Draw those instead: a tick box is an empty bordered cell, a dot is an ellipse.
Tip Check
Soli::PDF.last_warningsin your test suite. A missing glyph does not fail the render, so it is easy to ship a PDF with holes in it.
Fonts are subset to the characters a document uses, but a CJK fallback still weighs in: a one-line Japanese name through Noto Sans JP adds about 250 KB to the file, and some render time.