soli-pdf

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:

OptionTypeDefaultMeaning
alignmentstring"left"left, right, center or justify (case-insensitive).
fontSizenumber12Size in points.
fontWeightstring"normal"normal or bold.
italicbooleanfalseUse the italic face.
monobooleanfalseUse the monospace face (JetBrains Mono).
colorstring"000000"Text colour, hex without #.
underlinebooleanfalseUnderline, drawn in the text colour.
strikebooleanfalseStrike through, drawn in the text colour.
lineHeightnumber1.2Line height as a multiple of the font size.
spacingnumber0Extra space (pt) below the paragraph.
minSpaceBelownumber—Keep-together: only start the paragraph on this page if this many points remain below its first line.
linkstring—Make the paragraph a link to an external URL.
linkTostring—Make the paragraph a link to an anchor in the same document.
anchorstring—Name this paragraph as a jump target.
bookmarkstring—Add an entry to the PDF outline (the viewer's sidebar).
bookmarkLevelnumber1Nesting 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:

  • spacing adds a gap below the paragraph, the declarative alternative to a following { "type": "move", "y": … }.
  • lineHeight changes the distance between the lines inside it. 1.0 is tight, 1.5 is 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 fieldTypeMeaning
textstringThe text. Required. ${…} placeholders are filled from the data.
fontSizenumberSize in points.
fontWeightstringnormal or bold.
italicbooleanItalic face.
monobooleanMonospace face.
colorstringHex colour.
linkstringExternal URL: this span becomes a clickable link.
underlinebooleanUnderline this span.
strikebooleanStrike 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. Set color on every span that needs one.
  • link and linkTo — only a span's own link is clickable, and there is no span-level internal link. For a clickable table-of-contents line, use a value paragraph.
  • Page tokens — #PAGE# and friends are printed literally in spans. Use a value paragraph for them.
  • The footer band ignores spans paragraphs 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 } ] }
] }
FieldTypeDefaultMeaning
itemsarray—The items (see below).
orderedbooleanfalseNumber the items 1., 2., … instead of using a bullet.
startnumber1First number of an ordered list.
markerstring"•"Bullet of an unordered list. It must be a character the font covers — •, –, — and · are safe with the bundled fonts.
indentnumber18Left indent (pt) of this level. Must be greater than 0: 0 falls back to 18.
spacingnumber0Extra space (pt) after each item.
optionsobject—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, or spans — inline rich text (which wins over text);
  • 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 own options, and can have its own marker or ordered.

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.color colours plain-text items but not spans items: give those spans their own color.

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."
  ] }

Three kinds of text can link to a web address:

  • a value paragraph, with options.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 inside link (nor inside bookmark). For a per-document URL such as https://pay.example/F-42, set the value on the template Hash in Ruby before rendering — see What gets interpolated.

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:

FamilyFacesUsed for
Titillium WebRegular, Bold, Italic, Bold ItalicText
JetBrains MonoRegular, Bold, Italicmono: 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 / Oblique in the file name.
  • Monospace fonts (by flag, or Mono in the file name) supply mono: 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_warnings in 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.