Elements
Every element available inside view_template, with its signature, options and a small example.
At a glance#
The element table from the README.
| Element | What it does |
|---|---|
text(string, **style) | A paragraph. Plain strings are literal. |
text(string, markup: true) | Reads <b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">; decodes numeric (', ') and HTML 4 named entities once, so escape user data (ERB::Util.html_escape) before wrapping it in your own tags: <b> stays literal text. |
text { b "Total"; plain " due" } | Styled runs in Ruby. Take a block argument ({ |t| t.b @x }) to keep your own self. |
box(padding:, background:, border:, radius:, width:, height:, min_height:, overflow:, at:, link:, outset:, break_inside:, decoration:, rotate:, shadow:, float:, margin:) { } | A container. Moves to the next page whole when it fits there and continues across pages when it does not; break_inside: :auto splits it at any page break, :avoid never splits it. At a cut, decoration: :slice (default) drops the padding and border, :clone keeps the padding. overflow: :truncate or :shrink_to_fit for fixed heights; a fixed height: never splits. min_height: is a floor that still splits: the first fragment keeps as much of it as the page holds, the next carries the rest (not combinable with height:). at: [x, y] pins it to a page position. link: makes the whole box clickable. outset: bleeds the background past the box (e.g. into the page margins). rotate: -3 turns the painted box around its centre (layout box unchanged, never splits; a link: keeps its unrotated rectangle). shadow: true or { offset: [0, 4], blur: 8, color:, opacity: 0.15 } paints a soft drop shadow under it, taking no space. overflow: :hidden clips the content to the rounded outline. float: :left or :right with a width: takes it to that side, margin: away from the text that wraps beside it: see Floats. |
row(gap:, align:, break_inside:) { column(width:) { } } | Columns side by side. width: is points, a fraction (0.5), :auto or nil (equal share). Splits across pages like a box, every column at once; a row with a fixed-height column never splits; columns with min_height: do. |
columns(count:, gap:, balance:, rule:) { } | One flow poured through count columns, newspaper style: column 1 top to bottom, then column 2. Breaks where a page would (between lines with orphans:/widows:, never inside break_inside: :avoid, keep_with_next honoured). balance: true (default) ends the columns at nearly the same height where the content ends and fills them evenly, what cannot be shared going to the earlier columns (ten lines in three columns are 4, 3 and 3); false fills each before the next. Continues across pages; page_break inside ends the page; rule: true | { color:, width: } draws a line between columns. |
table(rows, widths:, width:, header:, split_rows:, cell:) { |t| } | Tables. Cells are strings, layout nodes, procs built with the DSL (-> { image logo }) or components. Style with t.row(0), t.rows(-1), t.column(1), t.columns(1..), chained, plus t.zebra. Header rows repeat after a page break. A cell may be { content:, colspan:, rowspan: } plus any cell option; rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections. A row taller than the page continues on the next page, cut through its cells, with the header repeated; split_rows: true cuts any row that reaches the page bottom instead of moving it whole. rows is an Array or any Enumerable; an Enumerator (lazy or not) given a width for every column is read as pages reach it, selected from its start only: see Large documents. |
image(path_or_io, width:, height:, fit:, align:, radius:, rotate:, max_ppi:, downscale:, float:, margin:) | JPEG, PNG or lossless WebP, aspect preserved. fit: [w, h] scales to fit inside; fit: :cover fills width: × height: and crops around the centre. radius: rounds the corners; rotate: turns it (degrees, clockwise) without changing the space it takes. Drawn at more than twice max_ppi: (300) it is reported as oversized; downscale: true resamples a PNG or WebP to that resolution instead. |
svg(source_or_path, width:, height:, color:, align:) | Vector icons and drawings; currentColor takes color: (or the color an element sets). Linear and radial gradients (fill="url(#id)", href chains, both gradient units); text/tspan in the document's fonts; <style> stylesheets (element, class, id and * selectors); use, symbol sprites and nested svg viewports (viewBox, preserveAspectRatio); clipPath (both clipPathUnits). |
barcode(data, type:, level:, module_size:, width:, height:, color:, quiet_zone:, native:, align:, alt:) | A barcode drawn as vector bars, encoded in Ruby: type: :code128 (the default; printable ASCII, digit runs in code set C), :ean13 (12 digits and the check digit, or 13 checked), :qr (byte mode, level: :l, :m, :q, :h, versions 1 to 40, UTF-8 marked with an ECI) or :datamatrix (ECC 200 in ASCII encodation, digit pairs in one codeword, squares from 10 × 10 to 144 × 144). module_size: points a module (1, or 2 for a square one) or as many as fit width:; a linear one is height: (36) tall. The quiet zone is part of its size unless quiet_zone: false, and it shrinks to fit the space. Monochrome renders put it on the dot grid, a whole number of dots a module. native: true has to_zpl write the printer's own command for it: see Label printers. A figure in a tagged PDF, its alt: by default the kind and the data. |
wrap(gap:, row_gap:, align:) { } | Children side by side at their own widths, wrapping onto new rows (chips, tags). |
stack(gap:, align:) { } | A base with layers painted over it: ordinary children set the height, layer children float over them and take no space. Moves to the next page whole. |
layer(top:, right:, bottom:, left:, width:, height:, **box) { } | Inside stack: a box placed by insets from the stack's edges, in points or as a fraction (0.4, 1/3r) of its width/height; negative insets overhang. Takes every box option (rotate:, shadow:, radius:, …). |
ul(style:, gap:, indent:, marker_gap:, marker_color:) { li "…" } | Bulleted list. style: is :disc, :circle, :square, :dash or any String; unstyled nested lists cycle disc → circle → square. Any node added inside (not only li) becomes an item. Items split across pages; the marker stays with the first line. |
ol(format:, start:, suffix:, gap:, indent:, marker_gap:, marker_color:) { } | Numbered list. format: is :decimal, :alpha, :upper_alpha, :roman, :upper_roman or a Proc (n) -> String; suffix: defaults to ".". Markers right-align so bodies line up. |
li(string, **style), li(gap:) { } | A list item: a paragraph, or a block of any elements (including nested lists). |
rule(height:, color:), spacer(height), page_break | Dividers and spacing. |
group(keep_together: true, align:) { } | Keep a block on one page. |
keep_with_next: true | points | On text, box or group: never end a page with this node; with a number, keep at least that many points of what follows with it. |
text_style(**style) { } | Default text style for a block. |
canvas(height:) { |canvas, rect| } | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links; rotate(degrees, around:) { } and transform([a, b, c, d, e, f]) { } blocks. |
text_field(name, value:, width:, height:, multiline:, max_length:, comb:, read_only:, required:, font_size:, min_font_size:, max_font_size:, align:, color:, border:, background:, radius:, tooltip:, at:) | An interactive text input (AcroForm). width: is :full or points; font_size: is points, :auto or :fit; align: is :left, :center or :right; dotted names ("address.city") group fields. See Forms. |
checkbox(name, checked:, size:, label:, at:) | An interactive check box, with an optional label drawn to its right. |
radio(name, value, checked:, size:, label:, at:) | One choice of a radio group: radios sharing name form one field whose value is the checked value. |
select(name, options:, value:, width:, height:, editable:, font_size:, min_font_size:, max_font_size:, align:, color:, at:) | A drop-down (combo box); editable: true also accepts typed values. |
signature_field(name, width:, height:, label:, at:) | An empty signature field for the signer to fill, drawn as a rule over the label. |
html(source, styles:, gap:, images:, base_path:, bookmarks:, links:, max_depth:) | Rich text from HTML (ActionText/Trix, CMS output): paragraphs, headings, lists, quotes, code, rules, tables, images, inline marks and links, styled by a CSS subset (<style> rules and inline style). See HTML and Markdown. |
markdown(source, styles:, gap:, images:, base_path:, bookmarks:, links:, max_depth:) | The same from CommonMark (plus GFM tables and strikethrough). |
Text style options: font, size, weight (:regular, :bold), style (:italic), color, letter_spacing, underline, strikethrough, link, opacity, kerning (default true), ligatures (default true), features (OpenType feature tags, e.g. %i[smcp onum]), hyphenate (true for English, or "de", "sv"; default off), align (:left, :center, :right, :justify), leading, orphans and widows (the fewest lines a page break may leave behind and carry over; default 1, a paragraph that cannot meet them moves whole). align: :justify stretches the spaces of wrapped lines to the full width; the last line, lines ending in a newline and lines without spaces stay left-aligned (tabs are never stretched). Colours are "#RRGGBB", "RRGGBB", "#RGB", [r, g, b] (0-255) or [c, m, y, k] (0-100).
text#
A paragraph: plain, markup, or Ruby-built runs.
text(content = nil, markup: false, keep_with_next:, break_inside:, anchor:, bookmark:, **style)
Plain strings are always literal. markup: true reads <b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href=""> and decodes numeric (', ') and all 252 HTML 4 named entities, each once: escape user data (ERB::Util.html_escape) before wrapping it in your own tags, and <b> stays the literal text <b>. A block builds styled runs in Ruby with plain, br, b, i, u, strikethrough, sub, sup, color, size, font and link.
text "Invoice INV-7", size: 22, weight: :bold
text "Net <b>30</b> days — <link href='mailto:[email protected]'>[email protected]</link>", markup: true
text { b "Total"; plain " due"; sup "1" }
text { |t| t.color "#4F46E5", @invoice.total } # a block argument keeps your own self| Option | Type | Default | Description |
|---|---|---|---|
font | String | Inter / default_text | A registered family name. |
size | Numeric | default_text | Points. |
weight | :regular, :bold | :regular | A face without its own file is synthesised. |
style | :italic | — | Oblique is synthesised when the family has no italic file. |
color | Color | "#000000" | "#RRGGBB", "RRGGBB", "#RGB", [r, g, b] (0-255) or [c, m, y, k] (0-100). |
align | :left, :center, :right, :justify | :left | :justify widens spaces on wrapped lines; the last line, lines ending in a newline and lines without spaces stay left. |
leading | Numeric | 0 | Extra points between lines. |
orphans / widows | Integer | 1 | The fewest lines a page break may leave at the foot of a page and carry to the next; a paragraph that cannot meet them moves whole. See Page breaks in text. |
letter_spacing | Numeric | 0 | Extra points between characters. |
underline / strikethrough | Boolean | false | Decoration lines. |
link | String | — | A URL, or "#name" for an internal link. |
opacity | 0..1 | 1 | Text transparency. |
kerning | Boolean | true | GPOS / kern-table pair kerning. |
ligatures | Boolean | true | GSUB `liga` standard ligatures (off with letter spacing). |
hyphenate | true, "en", "de", "sv" | off | Breaks a word that does not fit at a point the language's patterns allow, drawing a hyphen; a soft hyphen (U+00AD, ­) names the points yourself. See Fonts. |
text_style#
Default text style for a block.
text_style(**style) { … } sets the text defaults (font, size, colour, weight, align, leading, …) for everything built inside the block.
text_style(size: 8, color: "#6B7280") do
text "Terms"
text "Payment within 30 days."
endbox#
Padding, background, border, radius — and page-aware splitting.
box(padding:, background:, border:, radius:, width:, height:, overflow:, valign:, opacity:, at:, float:, margin:, link:, outset:, break_inside:, decoration:, rotate:, shadow:, align:, gap:, keep_with_next:, anchor:, bookmark:) { … }
box(background: "#F3F4F6", radius: 6, padding: [8, 12], border: { color: "#E5E7EB", width: 0.5 }) do
text "AMOUNT DUE", size: 8, weight: :bold
text "€ 7 330,00", size: 19, weight: :bold
end| Option | Type | Default | Description |
|---|---|---|---|
padding | Box | 0 | One value, [y, x], [top, x, bottom], [top, right, bottom, left] or { top:, right:, bottom:, left:, x:, y: }. |
background | Color | nil | Fill colour. |
border | Hash | nil | { width: 1, color: "#000000", sides: %i[top right bottom left] }, merged over those defaults. |
radius | Numeric or [tl, tr, br, bl] | 0 | Corner radius. |
width | points, fraction, :auto | full width | 0.5 is half the available width; :auto is the content's natural width. |
height | Numeric | nil | A fixed height. Fixed-height boxes never split. |
overflow | :visible, :hidden, :truncate, :shrink_to_fit | :visible | What text does when a fixed height is too small. :hidden also clips the content to the rounded outline (radius:) and still splits across pages. |
valign | :top, :middle, :bottom | :top | Content placement inside a fixed height. |
at | [x, y] | nil | Pins the box to a page position, outside the flow. |
float | :left, :right | nil | Takes the box to that side of the flow it is in; what follows wraps beside it. Needs a width:. See Floats. |
margin | Numeric or Box | 0 | With float:, the space kept between the box and the text around it: a number on the sides that face the text, or sides named as for padding:. |
link | String | nil | Makes the whole box clickable (a URL or "#anchor"). |
outset | Box | 0 | Bleeds the background past the box, e.g. into the page margins. |
break_inside | nil, :auto, :avoid | nil | See Layout rules. |
decoration | :slice, :clone | :slice | At a page cut: drop padding and border, or keep the padding. |
rotate | degrees | 0 | Turns the painted box clockwise around its centre, like CSS rotate(). The layout rectangle is unchanged, the box never splits, and a link: keeps its unrotated rectangle (annotations live in page space). |
shadow | true or Hash | nil | A soft drop shadow under the box: { offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }, merged over those defaults. Painted as stacked, fading rounded rectangles (an artifact), it takes no layout space and is skipped on a fragment cut by a page break. |
align / gap | Symbol / Numeric | :left / 0 | Horizontal alignment of the children and space between them. |
A tilted, white-framed photo with a shadow — the pieces of a collage:
box(width: 180, padding: 4, background: "#FFFFFF", radius: 12, shadow: true, rotate: -3) do
image "beach.jpg", width: 172, height: 129
endrow and column#
Columns side by side.
row(gap: 0, align: :top, break_inside: nil) { … }column(width: nil, align: nil, gap: 0, break_inside: nil, **box_options) { … }
width: is points, a fraction (0.5), :auto or nil (an equal share of what is left). A column takes every box option. align: on the row (:top, :middle, :bottom) places columns of different heights. Rows split across pages like a box, every column at the same break; a row holding a fixed-height column never splits.
row(gap: 24, align: :middle) do
column(width: 0.5) { text "Invoice INV-7", size: 22, weight: :bold }
column(width: :auto) { image "logo.png", height: 34 }
column { text "Due 26 October", align: :right }
endcolumns#
One flow through several columns.
columns(count: 2, gap: 12, balance: true, rule: nil, align: nil) { … }
The block's content is one flow, poured through count columns newspaper style: column 1 top to bottom, then column 2. (A row is the other thing: columns side by side, each with content of its own.) Every column is (width - gap * (count - 1)) / count wide, and a column ends where a page would: between children, between the lines of a paragraph (honouring orphans: and widows:), inside boxes and tables by their own rules, never inside break_inside: :avoid. A heading with keep_with_next: true stays in the column of what follows it.
text "The sea wall is whole again", size: 30, weight: :bold
columns(count: 2, gap: 22, rule: { color: "#D1D5DB" }) do
text "What it cost", weight: :bold, keep_with_next: true
text body, align: :justify, orphans: 2, widows: 2
image "bay.png", width: 1.0
end
text "A note across the page, below the tallest column."balance: trueends the columns at nearly the same height wherever the content ends: on the last page of the block and before apage_breakinside it. The height is the shortest at which everything fits, and the columns are filled evenly in it: the first takes what fits, and what it leaves is balanced through the columns after it in the same way. Ten lines in three columns are 4, 3 and 3, what cannot be shared going to the earlier columns. The break rules hold, so a heading, a box ororphans:andwidows:can keep the columns further apart than a line, and a column that ends above a block that cannot split may stay shorter than the one after it.balance: falsefills each column to the height available before the next starts.- Across pages. A block that does not fit fills the height left on the page, every column full, and continues on the next page; the last page is balanced. Below other content it starts only when every column takes something in the height left, and otherwise moves to the next page. A
page_breakinside ends the page. What follows the block starts below its tallest column. rule:trueor{ color:, width: }draws a line in the middle of each gap, an artifact in a tagged PDF.count: 1lays out like agroup.count:must be an Integer of at least 1 andgap:a number of at least 0, orArgumentErroris raised.- Nesting.
columnsworks inside abox, acolumnand anothercolumns. - Tagged PDF. Reading order is column 1, then column 2; a paragraph split across columns is one
P, as it is across pages. Links, anchors, bookmarks andtable_of_contentspage numbers work inside. - A child too tall for a column at the top of a page is kept and reported as an overflow warning.
examples/newsletter.rb is a complete page. In html, column-count, columns: <n> and column-gap map to it.
group#
Keep a block together.
group(gap: 0, align: nil, keep_together: false, keep_with_next: nil, anchor: nil, bookmark: nil) { … }
A vertical group of children. keep_together: true moves the whole group to the next page rather than splitting it.
group(keep_together: true) do
rule height: 1, color: "#E5E7EB"
text "Thank you for your business", weight: :bold
endwrap#
Chips and tags.
wrap(gap: 0, row_gap: gap, align: :left) { … } — children side by side at their own widths, wrapping onto new rows. It splits between rows.
wrap(gap: 6, row_gap: 4) do
%w[ruby pdf layout].each { |tag| box(width: :auto, padding: [2, 8], radius: 8, background: "#EEF2FF") { text tag } }
endtable#
Selections, spans, DSL cells, repeating headers.
table(rows, widths: nil, width: :auto, header: false, split_rows: false, cell: {}, anchor:, bookmark:) { |t| … }
rowsis an Array or any Enumerable of rows. An Enumerator (lazy or not) whose table has a width for every column is read as pages reach it (below).- Cells are strings, layout nodes, procs built with the DSL (
-> { image logo }) or components. widths:per column: points, a fraction of the table (0.25), ornilfor a share of the rest.width::auto,:fullor points.header:true(one row) or a row count; header rows repeat after every page break.cell:defaults for every cell:padding(5),borders(all four),border_width(0.5),border_color, plusbackground,color,weight,style,size,font,align,valign,letter_spacing,leadingandmarkup.
Selections and zebra stripes
t.row(0), t.rows(-1), t.column(1), t.columns(1..) and t.cells select cells (chainable: t.row(-1).columns(3..)); assign one option (t.columns(1..).align = :right) or several with set. t.zebra(color:, from: 0, to: nil, every: 2) stripes rows.
table(rows, width: :full, widths: [nil, 45, 80, 85], header: true, cell: { padding: [7, 8], borders: [] }) do |t|
t.row(0).set(background: "#4F46E5", color: "#FFFFFF", weight: :bold)
t.columns(1..).align = :right
t.zebra(from: 1, color: "#F9FAFB")
t.row(-1).set(weight: :bold, borders: [:top])
endSpans
A cell may be { content:, colspan:, rowspan: } plus any cell option. Rows list only the cells they start, as in HTML, and pages never break through a rowspan. Spans are set in the rows, not through selections.
table([
[{ content: "Q1", rowspan: 2 }, "January", "12"],
["February", "15"],
[{ content: "Total", colspan: 2, align: :right, weight: :bold }, "27"]
])DSL cells
table([
["Logo", -> { image "logo.png", height: 16 }],
["Status", StatusPill.new("PAID")]
])Rows taller than the page
A row taller than the page continues on the next page, cut through its cells, with the header repeated. split_rows: true cuts any row that reaches the page bottom instead of moving it whole — useful for long tables of tall rows.
Rows read as pages reach them
Rows given as an Enumerator, lazy or not, to a table with a width for every column (points or fractions) are read as pages reach them: the table holds the rows of the page being filled and the one after them, and none before its first page. With incremental a price list of a million rows peaks at 130 MB, where the same rows as an Array peak at 2.8 GB (see Large documents).
orders = Order.preload(:customer).find_each.lazy.map { |o| [o.number, o.customer.name, o.total] }
rows = [%w[Order Customer Total]].each + orders
table(rows, header: true, widths: [80, 0.5, 90]) do |t|
t.row(0).weight = :bold
t.columns(2).align = :right
t.zebra(from: 1, color: "#F4F4F4")
endThe header rows, zebra, split_rows, cells spanning columns and selections by index from the start (t.row(0), t.rows(1..), t.columns(…)) work as for an Array, applied to each row as it is read; a proc or component cell is built then, in the text style the table was given in. What needs the end of the table raises an ArgumentError: a row counted from the end (t.row(-1), t.rows(1..-2)), a cell spanning rows, and a row with more columns than widths. An Enumerator with a column without a width is read whole when the table is built, as an Array is.
image#
JPEG, PNG and lossless WebP.
image(path_or_io, width: nil, height: nil, fit: nil, align: nil, opacity: nil, float: nil, margin: nil)
Aspect ratio is preserved when only one of width/height is given; fit: [w, h] scales the image to fit inside that box; with neither, one pixel is one point. An image is never wider than the space it is given. float: :left or :right takes it to that side, and the text that follows wraps beside it (Floats). Details on Images and SVG.
image "logo.png", height: 34, align: :right
image StringIO.new(blob.download), fit: [120, 80]
image "bay.jpg", float: :left, width: 0.4, margin: 12, alt: "The bay at dawn"Floats#
Text that wraps around an image or a box.
float: :left or :right on an image or a box takes it to that edge of the flow it is written in: the page body, a box, a column or a table cell. What follows it in that flow starts at its top and wraps beside it, and takes the full width again below it, in the middle of a paragraph if need be. examples/article.rb is a page built this way.
image "bay.jpg", float: :left, width: 0.4, margin: 12, alt: "The bay at dawn"
text story, align: :justify # beside the photo, then below it
box(float: :right, width: 170, margin: { left: 16, bottom: 6 }, padding: 10, role: :blockquote) do
text "The view is everywhere.", size: 13, style: :italic
end
text more # around the pull quote| Rule | |
|---|---|
margin: | A number is kept on the sides that face the text (the inner side and the bottom). A Hash ({ left: 16, bottom: 6 }, x:, y:) or an Array names the sides as padding: does. |
width: | A floated box needs one: points, a fraction of the flow's width or :auto. An image has its own size. |
| Text | Paragraphs, headings and the text inside a group or an html block wrap: every line takes the width left at its own top and is aligned and justified in it. A paragraph whose widest word does not fit beside the floats starts below them. |
| List items and boxes | A list item and a box that takes the width it is given wrap what they hold, as a block does in CSS: they keep the full width, and their lines are narrow beside the float and wide below it. A box with a background:, a border:, a shadow: or a link: paints it across the full width, under the float: the float is painted after the boxes beside it, so it sits on top of the background, and one with opacity: or a shadow: shows the background through it. The box's padding lies under the float, as a block's does in CSS; a list item keeps its indent from the float, with the marker beside its first line, over the background of its body if it has one. |
| Other elements | A box with a size or a place of its own (width:, height:, rotate:, overflow: other than :visible, valign: other than :top), a row, columns, table, rule, image, svg or form field is a block: beside the float in the width that is left, all the way down, when its own width (or the least its content takes) fits there; else below the float. A spacer takes its height beside the float; a page_break ends the page and the float with it. |
| Several floats | The next goes beside those already there when it fits, else below them, and never above one written before it. Left and right floats share a line with the text between them. |
| Height | The flow that holds a float is at least as tall as the float: what follows the box, column or cell starts below it. |
| Page breaks | A float never splits. When it does not fit what is left of the page, or the content after it could not start beside it there, it moves to the next page with that content. The lines a paragraph, a list item or a box carries over a break are wrapped again at the full width; the part that stays keeps the place its whole was given, beside the floats or below them. |
| Floats taller than the page | Floats written one after the other that are taller than a page together are cut before the first that does not fit: it starts the next page, with what was written after it, in a box, a list item, a column and a table cell as in the page body. Floats that fit a page together stay together: when one of them does not fit what is left of the page, they all go to the next. Only a float taller than a page by itself runs over it, and is reported as an Overflow. What does not fit below the floats at the top of a page goes to the next page and leaves them behind. |
| Tagged PDF | The float is read where it was written, painted after the boxes beside it or not: an image is a Figure, a box has its role:, and a floated box without one beside a box with a background is a Div, which keeps its content in place. |
Anything but :left and :right raises ArgumentError, as do margin: without float: and float: together with at:. Text wraps along the float's rectangle, never along a shape.
svg#
Vector icons and drawings.
svg(source_or_path, width: nil, height: nil, color: "#000000", align: nil) — markup String or a path to a .svg file. currentColor takes color:.
svg "icons/check.svg", width: 14, color: "#0F766E"barcode#
Code 128, EAN-13, QR codes and Data Matrix as vector bars.
barcode(data, type: :code128, level: nil, module_size: nil, width: nil, height: nil, color: "#000000", quiet_zone: true, native: nil, align: nil, alt: nil) encodes data in Ruby and draws it as one filled path: sharp at any zoom and on any printer.
barcode "SX0042771903", module_size: 1.5, height: 44 # Code 128
barcode "400638133393", type: :ean13, height: 40 # the check digit is added
barcode "https://track.example/SX0042771903", type: :qr, level: :q, width: 60
barcode "SX0042771903", type: :datamatrix, module_size: 2| Type | Data | Size |
|---|---|---|
:code128 | printable ASCII; runs of digits in code set C | 11 modules a character, quiet zone 10 |
:ean13 | 12 digits (the check digit is added) or 13 (checked) | 95 modules, quiet zone 11 and 7 |
:qr | any String, as bytes (UTF-8 marked with an ECI); level: :l, :m (default), :q, :h | the smallest of versions 1 to 40, quiet zone 4 |
:datamatrix | any String, as bytes, in ECC 200's ASCII encodation (two digits a codeword) | the smallest square from 10 × 10 to 144 × 144, quiet zone 1 |
module_size: is the width of the narrowest bar (a square one's module) in points, 1 by default (2 for a square one), or width: sets it from the whole width, quiet zones included. A barcode never takes more than the width it is given: its module is made smaller to fit. In a monochrome render it is put on the printer's dot grid, a whole number of dots a module. The digits under an EAN-13 are not drawn; write them with text. native: true asks to_zpl to have a label printer draw it with its own command, see Label printers.
All of them were decoded with ZBar from pictures of what they draw: Code 128 and EAN-13 samples, and QR codes filled to capacity at versions 1 to 40 and every level. Every Data Matrix size, filled to capacity, was read by dmtxread (libdmtx) and is module for module the symbol libdmtx writes for the same data in ASCII encodation.
stack and layer#
Overlapping content: a collage, a badge on a photo, a stamp on a card.
stack(gap: 0, align: nil) { … } is a container whose ordinary children form the base and set the height. layer(top:, right:, bottom:, left:, width:, height:, **box_options) { … } inside it is a box painted over the base, placed by insets from the stack's edges and taking no space. Insets, width: and height: are points, or a fraction of the stack's width (horizontal) or height (vertical) when given as a Float between -1 and 1 or a Rational (1/3r); negative insets overhang. A layer takes every box option, so rotate:, shadow:, padding:, background: and radius: make tilted, framed snapshots. A stack never splits: it moves to the next page whole.
Overhanging layers paint past the stack, so wrap it in a box(padding:) at least as big as the overhang to keep them inside the page margins (what examples/postcard.rb does):
box(padding: 18) do
stack do
box(width: :auto, radius: 16, shadow: true) do
image "sea.png", width: 228, height: 171, fit: :cover, radius: 16, alt: "The bay at dawn"
end
layer(bottom: -18, left: -18, width: 0.4, rotate: -3, padding: 4, background: "#FFFFFF", radius: 12, shadow: true) do
image "hills.png", width: 83, height: 62, fit: :cover, radius: 8, alt: "Hills above the village"
end
layer(top: -18, right: -18, width: 1/3r, rotate: 2, padding: 4, background: "#FFFFFF", radius: 12, shadow: true) do
image "stone.png", width: 68, height: 68, fit: :cover, radius: 8, alt: "A sandstone wall"
end
end
endWithout left:/top: a layer sits at the stack's top-left; without width: it takes its content's natural width (at most the stack's). In a tagged PDF the base and the layers attach in paint order.
canvas#
Draw directly.
canvas(height:, at: nil, width: nil) { |canvas, rect| … } reserves height points and hands the block the canvas — clipped to the reserved rectangle — and that rectangle (rect.x, rect.y, rect.width, rect.height, top-left page coordinates).
Canvas methods: fill_rect(x, y, w, h, color:), rounded_rect(x, y, w, h, radius:, fill:, stroke:, line_width:, dash:) (per-corner radii), circle(cx, cy, r, fill:, stroke:), line(x1, y1, x2, y2, color:, width:, dash:, cap:), path(fill:, stroke:, …) { |p| p.move_to; p.line_to; p.curve_to; p.close }, clip(x, y, w, h, radius:) { … }, image(image, x:, y:, width:, height:) and link(x, y, w, h, target).
canvas(height: 40) do |canvas, rect|
bars.each_with_index do |width, index|
x = rect.x + bars.first(index).sum
canvas.fill_rect(x, rect.y, width, rect.height, color: "#111827") if index.even?
end
endrotate(degrees, around: [x, y]) { … } paints the block turned clockwise around a page point, as CSS rotate() does; transform([a, b, c, d, e, f]) { … } applies any affine matrix given in the same top-left space. Shapes, images and text inside follow the transform. Link and form widget rectangles are annotations in untransformed page space, so a link inside a rotated block keeps its unrotated rectangle.
canvas(height: 120) do |canvas, rect|
canvas.rotate(-3, around: [rect.x + 60, rect.y + 60]) do
canvas.image(photo, x: rect.x, y: rect.y, width: 120, height: 120)
end
endrule, spacer and page_break#
Dividers and spacing.
rule(height: 1, color: "#000000", width: nil, radius: 0)— a filled horizontal bar.spacer(height)— vertical space, dropped when it lands on a page break.page_break— forces what follows onto a new page.
rule height: 3, color: "#4F46E5"
spacer 18
page_breakul, ol and li#
Bulleted and numbered lists.
ul(style: nil, gap: 4, indent: nil, marker_gap: 6, marker_color: nil) { … }—style:is:disc,:circle,:square,:dashor any String; unstyled nested lists cycle disc → circle → square.ol(format: :decimal, start: 1, suffix: ".", gap: 4, indent: nil, marker_gap: 6, marker_color: nil) { … }—format:is:decimal,:alpha,:upper_alpha,:roman,:upper_romanor a Proc(n) -> String. Markers right-align so bodies line up.li(string, **text_style)orli(gap: 0) { … }— a paragraph, or a block of any elements, including nested lists.
Any node added inside a list (not only li) becomes an item. Items split across pages; the marker stays with the first line.
ol(format: :upper_roman, marker_color: "#0F766E") do
li "Introduction"
li do
text "Highlights"
ul(style: :dash) { li "Revenue up 18%"; li "Two new markets" }
end
endanchor, bookmark and table_of_contents#
Internal links and the PDF outline.
anchor(name)— a named link target (link: "#name"); it moves to the next page with what follows it.anchor:is also an option ontext,box,groupandtable.bookmark(title, level: 1, open: false)— a standalone outline entry;bookmark:(a title or{ title:, level:, open: }) is also an option ontext,box,groupandtable.table_of_contents(levels: 1.., leader: :dots, indent: 12, number_width: nil, gap: 4, **text_style)— one linked row per bookmark with its page number.
text "Contents", size: 18
table_of_contents(levels: 1..2, leader: :line)
text "Introduction", size: 16, bookmark: "Introduction", anchor: "intro"The full story is on Links, bookmarks and contents.
text_field, checkbox, radio, select and signature_field#
Interactive form fields.
text_field, checkbox, radio, select and signature_field make a PDF fillable: each takes a name and lays out like a box, or sits at at: [x, y]. Their options, naming, fonts and accessibility have a page of their own: Forms.
html and markdown#
User content rendered with the elements above.
html and markdown render user content with the elements above; their parsers load on first use.
def view_template
html @post.body.to_s, # ActionText
images: ->(src) { blob_path_for(src) }, # path, IO or nil (skipped with a warning)
styles: { h1: { size: 22 }, a: { color: "#0F766E" }, code: { font: "JetBrains Mono" } }
markdown File.read("NOTES.md"), base_path: "docs", bookmarks: true
end- Images come from
images:(called with thesrc) or from files underbase_path:; sources that resolve nowhere, point outsidebase_pathor are remote URLs are skipped with aSkippedImagewarning. Nothing is ever fetched over the network. styles:is deep-merged into the defaults:h1–h6(scale:of the text size, orsize:in points; bold,keep_with_next),p,a(colour, underline),code(font:; register a monospace family for inline and block code, otherwise the text font is used),preandblockquote(box options),hr(rule options),table(cell:options,header:text style),ulandol(list options:gap:,indent:,marker_gap:,marker_color:, plusstyle:forulandformat:/suffix:forol),li(text style) andimg(max_width:, andfloat_margin:for a floated image without a CSS margin).- Links are written only for
http,https,mailtoandtelhrefs (and#anchor); anything else (javascript:,data:, a relative path) keeps its text without a link and is reported as aDroppedLinkwarning.links: %w[http https]changes the list,links: :allkeeps every href from a trusted source. gap:spaces the blocks (default 6);bookmarks: trueadds h1–h3 to the PDF outline.htmlreads a subset of CSS from<style>elements and inlinestyleattributes (see What CSS is read); what it does not read is reported once as anUnsupportedCsswarning.markdownreads none.max_depth:(default 64) is how deep the source may nest: HTML elements inside one another, or Markdown block quotes and lists. What lies deeper is flattened into the deepest element kept, so its text stays and its structure goes, and aNestingLimitwarning says how deep the source went. Block quotes and lists also stop indenting after twelve levels, where they would leave their text no width; that is reported the same way.
What CSS is read
html applies <style> rules and inline style attributes on top of styles:. The cascade is the usual one: the element's defaults from styles:, then stylesheet rules by specificity (id over class over element, the later rule winning a tie), then the inline style, then the element's own mark (<b>, <i>, <u>, <s>).
Selectors are an element name, .class, #id, any compound of them (p.lead, td.n.total), comma lists and *. Selectors with combinators or pseudo-classes (ul li, a > b, a:hover) are ignored and reported. At-rules are skipped, except that rules inside @media print and @media all are read.
| Property | Values | Applies to |
|---|---|---|
color | #rgb, #rrggbb, rgb(), rgba() (alpha ignored), the common colour names | text, inherited |
font-size | px (0.75 pt), pt, em, %, xx-small to xx-large, smaller, larger; relative sizes multiply the size around them | text, inherited |
font-weight | bold, normal, 100–900 (600 and up is bold) | text, inherited |
font-style | italic, oblique, normal | text, inherited |
text-decoration | underline, line-through, none | text, inherited |
text-align | left, center, right, justify | paragraphs, headings, cells, images; inherited from a container |
background-color | as color, or transparent | p, div and other containers, blockquote, pre, table, td, th |
padding, padding-top … padding-left | one to four lengths in px or pt | the same |
margin, margin-top … margin-left | one to four lengths in px or pt: top and bottom are added to gap:, left and right indent the block. A negative margin is drawn as none | blocks, and a floated img |
border | 1px solid #ccc in any order, none | table (every cell), td, th |
width | px, pt, %, auto | img, table, and td/th (column widths, when every cell of the first row has one) |
float | left, right, none | img only: the text that follows wraps beside it. Its margin, with margin-top … margin-left over it, is kept around it, else styles: { img: { float_margin: 8 } } towards the text |
page-break-before, page-break-after, break-before, break-after | always, page, auto | blocks |
page-break-inside, break-inside | avoid, auto | blocks |
column-count, columns | a number of columns, auto (a column width is not read) | div and other containers, p, headings, lists, tables, blockquote, pre |
column-gap | a length in px or pt, normal | the same |
margin and its sides are read in the order of the cascade: a side wins over a margin declared before it or by a lesser rule, and a margin takes back the sides declared before it. <font color size>, <center> and <img align="left|right"> (a float) are read the same way. Everything else (display, float on anything but an image, position, font-family, line-height, em lengths outside font-size, auto margins, a negative margin-left or margin-right, url() values, inline backgrounds) is ignored and named in the UnsupportedCss warning, so strict catches content that expects more than this. No value is ever fetched: url() and @import are dropped.
html <<~HTML
<style>
.note { background-color: #FEF3C7; padding: 8pt; margin: 6pt 0; break-inside: avoid }
td.amount { text-align: right; width: 25% }
h2 { page-break-before: always; color: #0F766E }
</style>
<div class="note"><p><b>Note.</b> Prices include VAT.</p></div>
HTMLUntrusted input#
What html and markdown do with content you did not write.
html and markdown are meant for content you did not write (a CMS body, a comment, an ActionText field). Nothing in that content can reach outside the document, and parsing it is bounded: nesting is capped and time grows with the input's length. How much input you accept, and so how many pages it renders to, is yours to bound:
- No requests. Remote images are never fetched; an image is read only from
images:or from underbase_path:, and a path that climbs out of it is skipped (SkippedImage). - No surprising links. Only
http,https,mailtoandtelhrefs (and#anchor) become links (DroppedLinkfor the rest), so ajavascript:orfile:href is plain text. - No scripts, no fetched styles.
script,templateandtitlecontent is dropped and raw HTML inside Markdown stays literal text. CSS is read only for the fixed list of properties above:url()and@importare dropped unread, and nothing in a style can position content outside the flow or hide it. - Bounded nesting. Five thousand nested
<div>s, block quotes or lists render flattened, with aNestingLimitwarning, instead of exhausting the stack (thesvgelement guards its own nesting the same way, at 128 levels). Emphasis nests without recursion, at most 64 brackets wait for their], and a link label is at most 999 characters, so parsing takes time in proportion to the input. - Size is yours to bound. There is no limit on how much text is rendered: a megabyte of input is a few hundred pages. Cap the length of what you accept before you render it, and use
strict(or readdocument.warnings) when a warning should stop the document.