soli-pdf

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 a rect) and write spans with a color on 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.

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_columns repeats its header on every page;
  • footer_columns closes 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 hr move the cursor down. rect, line, ellipse, qr, barcode and image don't: follow them with a move, or place them in an at. A negative move y goes 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 borderSides has no border. Once borderSides is 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.fillColor and header.textColor (camelCase) but padding_x and padding_y (snake_case). A shape's border width is borderWidth. A misspelled option is ignored silently.
  • Missing characters. A character that no loaded font covers is dropped with a warning. Check Soli::PDF.last_warnings when 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.