Layout & drawing
Content is laid out by a cursor that moves down the page as elements are placed. This page explains the cursor, the elements that position things — move, at, box, columns, page_break — and the drawing primitives: rules, rectangles, lines, ellipses and images.
#The cursor model
The renderer keeps a cursor: an x, y position on the current page. It starts at the top-left corner of the content area (inside the margins, below the header band). Each element is placed at the cursor, and most of them then move it down:
| Element | Moves the cursor? |
|---|---|
paragraph, list, table, chart, hr, box, columns | Yes — to just below the element. |
repeat, if, unless | Through their children. |
page_break | To the top of the next page. |
move | By the amount you give it. |
at | No — it restores the cursor after placing its content. |
image, rect, line, ellipse, qr, barcode | No. The next element is drawn at the same spot, on top. |
The last row is the one that surprises people: after an image or a shape, add a move of the element's height (plus a gap) before the next element.
[
{ "type": "rect", "width": 481, "height": 40, "fill": "0F766E" },
{ "type": "move", "y": 48 },
{ "type": "paragraph", "value": "Below the band" }
]
That behaviour is also a feature: drawing a rect and then moving back into it is how text is laid over a coloured panel. See Colored total panel.
#Pagination
Flowing content paginates on its own: paragraphs and lists break between lines, a table breaks between rows (repeating its header), and a chart moves to the next page as a whole when it does not fit. When a page ends, the footer is drawn, a new page starts, and the header is drawn again.
Elements that do not move the cursor — images, shapes, codes — never trigger a page break. If one is placed too close to the bottom, it is drawn past the margin. Force a break before it with page_break, or keep it next to flowing content that is taller than it.
#Move
move shifts the cursor. Positive y moves down, negative y moves up; positive x moves right.
{ "type": "move", "x": 0, "y": 24 }
A horizontal move persists: every following element starts at the new x — and a paragraph then wraps between that x and the right margin — until another move brings it back. This is how the dynamic example sets a title beside a logo:
[
{ "type": "image", "width": 36, "value": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='36' height='36'><rect width='36' height='36' rx='9' fill='%230f766e'/></svg>" },
{ "type": "move", "x": 46, "y": 2 },
{ "type": "paragraph", "value": "Helios Coffee", "options": { "fontSize": 15, "fontWeight": "bold" } },
{ "type": "paragraph", "value": "ORDER CONFIRMATION", "options": { "fontSize": 9, "color": "0f766e" } },
{ "type": "move", "x": -46, "y": 18 },
{ "type": "paragraph", "value": "Back at the left margin" }
]
#At
at places its content at an absolute position on the page, then puts the cursor back exactly where it was — so the flowing document around it is not affected. Use it for things that belong to a spot on the sheet rather than to the flow: a logo in a corner, a stamp, an address positioned for a window envelope.
{ "type": "at", "x": 380, "y": 60, "width": 160, "content": [
{ "type": "box", "fill": "1B3A6B", "padding": 12, "content": [
{ "type": "paragraph", "spans": [ { "text": "Placed here", "color": "FFFFFF" } ] }
] }
] }
| Field | Default | Meaning |
|---|---|---|
x | 0 | Distance from the page's left edge (not from the margin). |
y | 0 | Distance from the page's top edge. |
width | to the right margin | Wrap width of the content. |
content | — | The elements to place, laid out top to bottom from x, y. |
Coordinates are clamped to the page. at can appear anywhere in the content, as many times as needed.
#Box
A box is a container: it lays out its content inside optional padding, then paints its background and border at the size the content actually took, and moves the cursor below itself. It is the element for panels, callouts, totals blocks and signature areas — no height to compute by hand, and the panel keeps fitting when the text changes.
{ "type": "box", "fill": "F7F9FC", "border": "D7E0EC", "borderWidth": 0.8,
"radius": 4, "padding": 14, "gap": 16, "content": [
{ "type": "paragraph", "value": "Payment", "options": { "fontWeight": "bold" } },
{ "type": "paragraph", "value": "IBAN ${payment.iban}", "options": { "fontSize": 9 } }
] }
| Field | Default | Meaning |
|---|---|---|
content | — | The elements inside the box. |
padding | 0 | Inner padding: a number, or { "top", "right", "bottom", "left" }. |
width | remaining width | Box width in points. By default the box spans to the right margin. |
fill | — | Background colour. |
border | — | Border colour. No border when omitted. |
borderWidth | 0.5 | Border thickness. |
radius | — | Corner radius, for rounded corners. |
dash | — | Dash pattern of the border, such as [4, 3]. |
gap | 0 | Space left below the box. |
Boxes nest, and their children wrap at the box's inner edge.
Warning A box whose content crosses a page break is drawn without its background and border (the render adds a warning), rather than painting a panel on a page the content has already left. Keep decorated boxes to blocks that fit on a page.
The compliant invoice puts its payment details and QR code in a box; the sectioned quote uses a dashed one as its acceptance area.
#Columns
columns flows its content through several columns. The children fill the first column down to the bottom of the page, then the second, and so on; after the block, full-width layout resumes below the tallest column.
{ "type": "columns", "count": 2, "gap": 22, "content": [
{ "type": "paragraph", "value": "Column one fills top to bottom, then the flow moves on to column two.",
"options": { "fontSize": 9, "alignment": "justify" } },
{ "type": "list", "items": ["Lists flow too", "and so do tables"] },
{ "type": "page_break" },
{ "type": "paragraph", "value": "A page_break inside columns starts the next column." }
] }
| Field | Default | Meaning |
|---|---|---|
count | 2 | Number of columns, 1 to 6. |
gap | 12 | Space between columns, in points. |
content | — | The elements to flow. |
- The fill is sequential, not balanced: short content stays in the first column. Use a
page_breakto push the rest into the next column. - Overflowing the last column starts a new page and a new set of columns.
- Paragraphs, lists, tables and charts all flow. A table that overflows a column continues in the next one, with its header repeated.
- An
imageinside columns is a flow element: it is scaled down to the column width if needed, and it does move the cursor. - Columns do not nest: an inner
columnsis flattened into the outer flow, with a warning.
#Page break
page_break finishes the current page (footer included) and starts the next one (header included).
{ "type": "page_break" }
A page_break at the very end of the content leaves a blank last page. Inside columns, it moves to the next column instead.
#Rules and shapes
#Horizontal rule
hr draws a horizontal line from the cursor to the right margin, then moves the cursor down by its thickness plus 2 pt.
{ "type": "hr", "color": "cccccc", "thickness": 0.5 }
| Field | Default | Meaning |
|---|---|---|
color | light grey | Line colour. |
thickness | 0.5 | Line thickness. |
width | to the right margin | Length in points. |
dash | — | Dash pattern, such as [3, 2]. |
These fields sit directly on the element, not in an options object: { "type": "hr", "options": { "color": "…" } } is ignored and draws the default grey rule.
An hr adds almost no space around itself. Surround it with moves (or give the paragraph above it a spacing) for breathing room.
#Rect, line and ellipse
None of these move the cursor.
rect draws a rectangle with its top-left corner at the cursor:
{ "type": "rect", "width": 481, "height": 26, "fill": "f4f4f5", "border": "000000", "borderWidth": 0.5, "radius": 4 }
line draws a segment from the cursor to cursor + (dx, dy):
{ "type": "line", "dx": 170, "dy": 0, "color": "94a3b8", "width": 1, "dash": [5, 3] }
ellipse draws an ellipse whose bounding box starts at the cursor — a circle when rx equals ry:
{ "type": "ellipse", "rx": 6, "ry": 6, "fill": "16a34a" }
| Element | Fields |
|---|---|
rect | width, height, fill, border, borderWidth (default 0.5), radius, dash |
line | dx, dy, color (default black), width (thickness, default 0.5), dash |
ellipse | rx, ry (radii), fill, border, borderWidth (default 0.5), dash |
- A shape without
fillis transparent inside; withoutborder(orcolorfor a line) it has no outline. dashis an array of on/off lengths in points:[3, 2]is a short dash,[1, 2]a dotted line.- The border thickness is
borderWidth, in camelCase.border_widthis silently ignored.
A row of status dots, drawn with ellipses and moves:
[
{ "type": "ellipse", "rx": 6, "ry": 6, "fill": "16a34a" },
{ "type": "move", "x": 20 },
{ "type": "ellipse", "rx": 6, "ry": 6, "fill": "eab308" },
{ "type": "move", "x": 20 },
{ "type": "ellipse", "rx": 6, "ry": 6, "fill": "dc2626" },
{ "type": "move", "x": -40, "y": 20 }
]
The graphics example and the feature tour show every shape.
#Images
image draws a picture with its top-left corner at the cursor. Like the shapes, it does not move the cursor (except inside columns).
{ "type": "image", "value": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='80'><rect width='240' height='80' rx='14' fill='%230f766e'/></svg>", "width": 120, "alt": "Helios logo" }
| Field | Meaning |
|---|---|
value | The image source (see below). Required. |
width | Width in points. |
height | Height in points. |
alt | Alternative text, used in tagged PDFs. |
#Sizing
widthonly — the height follows from the aspect ratio.heightonly — the width follows from the aspect ratio.- Both — the image is scaled to fit inside the
width×heightbox, keeping its aspect ratio. It is never stretched. - Neither — the image is drawn at its natural size, one pixel per point. Always give a size.
#Formats
PNG, JPEG, WebP, GIF and SVG. An SVG is embedded as vector graphics, so a logo stays sharp at any size; <text> inside it is set with the loaded fonts.
#Sources
| Source | Example |
|---|---|
data: URI | data:image/png;base64,iVBORw0KGgo… or data:image/svg+xml,<svg …> |
http(s) URL | https://acme.example/logo.png |
file:// path | file:///srv/app/public/logo.png |
In an inline SVG data: URI, write colours either as a literal # (fill='#0f766e') or URL-encoded (fill='%230f766e'); both work, and percentages such as width='50%' are left alone.
Warning Which sources load depends on the
render_pdfversion. 2.9.0, the one the gem downloads today, loads onlydata:URIs: it refuseshttp(s)andfile://sources withno image-source policy installed, skipping the image. The next release loads all three, under a safety policy:
- a local path is read only if it resolves, symlinks included, inside the working directory of your Ruby process (the app root under Rails). The CLI accepts more directories with
--image-dir;- an
http(s)URL is fetched only if its host resolves to public addresses. Loopback, private and link-local ranges (cloud metadata) are refused, so a template carrying user data cannot reach your internal network. Redirects are followed and each hop is checked again.A refused image never fails the render: it is skipped with a warning.
data:URIs work with every version, which makes them the portable choice.
An image's value is not interpolated — "${company.logo}" is read as a file name, not looked up in the data. Put the source, a path, a URL or a data URI, into the template itself. Since the template is a plain Hash in Ruby, that is one assignment; here the template's first element is the logo:
require 'base64'
template = JSON.parse(File.read('app/pdf/invoice.template.json'))
logo = "data:image/png;base64,#{Base64.strict_encode64(File.binread('app/assets/images/logo.png'))}"
template['content'][0]['value'] = logo
Soli::PDF.render(template: template, data: data)
For an SVG, "data:image/svg+xml;base64,#{Base64.strict_encode64(svg)}" avoids any escaping question. When the image is the same for every document, do it once at boot and keep the finished template in a constant.
An image that cannot be loaded or decoded never fails the render: it is skipped, and the reason is added to Soli::PDF.last_warnings.
#Images in tables
A rich table cell can stack text and images — the usual way to put a logo in an invoice header. See Cells.