Recipes & layout notes
Patterns for the pieces every business document needs, taken from the examples. Each snippet assumes the default A4 margins, which give a 481 pt content width. Paste them into the playground to see them.
#Identity band with the document title
A two-column table: the company on the left, the document type on the right, both on a light fill. There are no borders because the cells have no borderSides.
{ "type": "table", "header_columns": [],
"rows": [ [
{ "content": [
{ "type": "text", "value": "${company.name}", "fontSize": 19, "fontWeight": "bold" },
{ "type": "text", "value": "${company.tagline}", "fontSize": 9 } ],
"width": 300, "fill": "D8ECE8", "valign": "middle" },
{ "text": "INVOICE", "width": 181, "fontSize": 22, "fontWeight": "bold",
"alignment": "right", "fill": "D8ECE8", "valign": "middle" }
] ],
"options": { "padding_x": 14, "padding_y": 14 } }
Note Body text in table cells is always black. For white text on a colour, draw a
box(or arect) and writespanswith acoloron top, as in the next recipe.
#Coloured total panel
A box measures its content, then paints its fill at that size:
{ "type": "at", "x": 327, "y": 600, "width": 210, "content": [
{ "type": "box", "fill": "9F1239", "padding": 14, "radius": 3, "content": [
{ "type": "paragraph", "spans": [ { "text": "TOTAL DUE", "color": "F6D3DC", "fontWeight": "bold" } ],
"options": { "fontSize": 8 } },
{ "type": "paragraph", "spans": [ { "text": "€4,920.00", "color": "FFFFFF", "fontWeight": "bold" } ],
"options": { "fontSize": 20 } }
] }
] }
In the normal flow, drop the at wrapper and give the box a width of 210. To push it to the right, put it in the right-hand column of a columns block, or use at as shown. The credit note builds the same panel from a rect with text drawn over it. That is the older technique, which needs a hand-computed height.
#Totals stack
Totals are a borderless table whose first column is an empty spacer:
{ "type": "table", "header_columns": [],
"rows": [
[ { "text": "", "width": 281 }, { "text": "Subtotal", "width": 100, "alignment": "right" },
{ "text": "${totals.subtotal}", "width": 100, "alignment": "right" } ],
[ { "text": "", "width": 281 }, { "text": "VAT 20%", "width": 100, "alignment": "right" },
{ "text": "${totals.vat}", "width": 100, "alignment": "right" } ],
[ { "text": "", "width": 281 },
{ "text": "Amount due", "width": 100, "alignment": "right", "fontWeight": "bold", "fontSize": 13,
"borderSides": { "top": "true", "bottom": "false", "left": "false", "right": "false" } },
{ "text": "${totals.total}", "width": 100, "alignment": "right", "fontWeight": "bold", "fontSize": 13,
"borderSides": { "top": "true", "bottom": "false", "left": "false", "right": "false" } } ]
] }
#Grouped lines with subtotals
A repeat over the groups, with a data-bound table inside it that binds to each group's own array. The recipe is on Data binding, and the full document is the quote by trade section.
#Acceptance box with signature lines
A dashed box holding a borderless table whose cells draw only a bottom rule:
{ "type": "box", "border": "0E4A5C", "borderWidth": 1, "dash": [4, 3], "padding": 16, "content": [
{ "type": "paragraph", "value": "APPROVED FOR WORK", "options": { "fontSize": 9, "fontWeight": "bold", "color": "0E4A5C" } },
{ "type": "paragraph", "value": "Return one signed copy to accept this quote.", "options": { "fontSize": 8.5, "spacing": 26 } },
{ "type": "table", "header_columns": [], "rows": [ [
{ "text": "Date", "width": 150, "fontSize": 8,
"borderSides": { "top": "false", "bottom": "true", "left": "false", "right": "false" }, "borderColor": "0E4A5C" },
{ "text": "", "width": 24 },
{ "text": "Signature", "width": 275, "fontSize": 8,
"borderSides": { "top": "false", "bottom": "true", "left": "false", "right": "false" }, "borderColor": "0E4A5C" }
] ] }
] }
#Tick boxes
A tick box is an empty cell with its four borders on. No glyph is involved: the bundled fonts have no ☐ character, so a text box would print nothing and add a warning.
{ "text": "", "width": 22, "valign": "middle",
"borderSides": { "top": "true", "left": "true", "right": "true", "bottom": "true" },
"borderColor": "4C3A8A" }
See the quote with options.
#PAID, DRAFT and VOID stamps
Stamp the whole document with a watermark:
{ "options": { "watermark": { "text": "PAID", "color": "e8c4c4", "fontSize": 110, "angle": 35 } },
"content": [ { "type": "paragraph", "value": "Receipt RC-2025-0488" } ] }
Or stamp a single table, in which case the stamp is centred on that table:
{ "type": "table", "data": "items", "watermark": { "text": "VOID", "fontSize": 80, "color": "fca5a5" },
"rows": [ [ { "text": "${name}", "width": 381 }, { "text": "${amount}", "width": 100, "alignment": "right" } ] ] }
To stamp only some documents, choose the template options in Ruby:
template = PdfTemplates::RECEIPT.deep_dup
template['options'] = (template['options'] || {}).merge('watermark' => { 'text' => 'PAID' }) if receipt.paid?
#Long statements
For a statement that runs over several pages:
- a data-bound table with
header_columnsrepeats its header on every page; footer_columnscloses the table and also repeats just above each page break, which gives you the "carried forward" band;#PAGE# of #PAGES#in the footer.
See Tables and the account statement.
#Letterhead from a designer
If a designer delivers the letterhead as a PDF, don't redraw it. Render your content on top of it with the stationery: option and leave room with the template's margins:
Soli::PDF.render(template: template, data: data, stationery: Rails.root.join('app/pdf/letterhead.pdf'))
{ "options": { "margins": { "top": 150, "bottom": 90, "left": 70, "right": 70 } } }
#Layout notes
The rules that trip people up most often:
- What moves the cursor. Paragraphs, tables, lists, charts, boxes, columns and
hrmove the cursor down.rect,line,ellipse,qr,barcodeandimagedon't: follow them with amove, or place them in anat. A negativemoveygoes up. - Widths add up to the content width. That is 481 pt for A4 with default margins, and 595 − left margin − right margin in general. Rows wider than that are scaled down to fit.
- Borders are opt-in. A cell without
borderSideshas no border. OnceborderSidesis present, the sides you leave out default to on. - Colours are hex without
#:"0F766E", or the three-digit"fff". - Naming is mixed. Table options use
header.fillColorandheader.textColor(camelCase) butpadding_xandpadding_y(snake_case). A shape's border width isborderWidth. A misspelled option is ignored silently. - Missing characters. A character that no loaded font covers is dropped with a warning. Check
Soli::PDF.last_warningswhen you print user-supplied names, and add a fallback font if you serve other scripts. - Decorated boxes don't split. A box with a fill or border that runs past the page bottom loses its decoration, with a warning. Keep panels to content that fits a page, or use an undecorated box.
- Images.
data:URIs work everywhere. Files and URLs need the renderer release after 2.9.0 and follow a safety policy. See Layout & drawing.