Escape

Images and splits

Intent

Band items stack, one line under the next. Two item types break out of that. A split places cells across the width, which is how an invoice gets a seller on the left and a customer on the right. An image puts host-supplied image bytes in the flow, which is how a logo or a chart reaches the page. A grid is a run of splits on one set of columns, which is how a statement’s label and amount lines stay aligned.

Design decisions

An image source is an expression, never a literal. The bytes come from render data or from a registered function, and there is no path for the engine to fetch. A definition carries no I/O.

The bytes carry the format and the size. The engine recognises PNG and JPEG by their magic numbers, and an SVG by its root element. It reads the picture’s size from the same bytes. A raster header gives the pixel dimensions, and an SVG gives its root’s width and height, or else its viewBox. No format or size key to keep in sync with the file, and no target parses the header again for itself.

Nullish skips the item silently. A source resolving to null produces no output and no event, which is the missing-data twin of visible: false. A report whose input sometimes lacks a logo needs no guard.

Anything else invalid throws. A value that’s not a Uint8Array, or bytes that aren’t PNG, JPEG or SVG, is a located render error naming the item.

Past the size, the engine vouches for nothing. A target has to know how big a picture is before it can place one. So a file whose header truncates before its dimensions fails on every target alike: header[0].source [=$.input.logo]: could not read the image's size from its bytes. A file whose size reads but whose pixel data is corrupt has no single answer. Each target passes those bytes to its own machinery.

An SVG stays bytes, never markup. An SVG is markup, and author- or data-supplied markup reaching an HTML fragment would breach the escaping rule that protects every interpolated value. So no target writes it out as markup. The HTML target puts it in an <img> as a data:image/svg+xml URI, where a browser runs no script and loads nothing the SVG names. The PDF target draws it as vector paths. A chart your service already produces as SVG reaches the page as vectors, with no conversion to pixels on the way.

Not every target can draw an SVG. A worksheet drawing holds a raster picture only, and Word shows an SVG only beside a raster copy. So the XLSX and DOCX targets refuse one with a render error that names the item, rather than skipping it without a word. The engine learns the format only once source evaluates, so required can’t ask for SVG support. A service that renders one definition to a workbook as well sends PNG bytes there.

An image’s style vocabulary covers only what an image has. The rest of the vocabulary describes text, so any other name on an image is a definition error rather than a silent no-op.

width is a slot property, not an item property. It appears on an item only inside a split, because only there is there a width to take a share of.

Width-less slots divide what’s left, evenly. A table measures its width-less columns from their content, and a split doesn’t. A split is a line of furniture, so its geometry reads off the document rather than off the data.

A split names its two slot types rather than excluding splits. The schema names text and image, so a future item type states its own case instead of inheriting a promise made before it existed.

A grid states the geometry once. Twenty label-and-amount lines written as splits restate the same two widths twenty times. One typo moves one amount out of line. A grid declares its columns once and lays every row on them. Each row reaches a target as a split. The event stream doesn’t change, and every target that draws a split draws a grid.

API walkthrough

Placing an image

a logo in the report header
{
  "type": "image",
  "source": "=$.input.logo",
  "fit": "width",
  "alt": "{{ $.params.company }} logo",
  "style": { "align": "center", "spaceAfter": 12 }
}

source yields the complete file as a Uint8Array, from render data (=$.input.logo) or a registered function (=stamp(@.status)).

An SVG has to state its own size, as absolute width and height on its root or else as a viewBox. A bare number or px is pixels, and pt, in, cm and mm convert at 96 dpi. A percentage depends on a container the file doesn’t know, so it isn’t a size. The PDF target draws only the shapes the SVG declares itself. An <image>, <use> or <script> inside it draws nothing, and the render fetches nothing. SVG <text> draws in a base-14 sans face, whatever family it names.

fit is "natural" by default, rendering at intrinsic size with pixels converted to points at 96 dpi and capped at the content width. "width" scales to the content width. Both keep the aspect ratio, and there is no crop, stretch, or explicit height.

alt is one template string, and the one cell value the run form doesn’t reach. A styled run means nothing on a stand-in for a picture. Today the HTML target renders it and the others don’t read it.

style covers align, background, and the box and flow spacing, and nothing else. An image’s box is as wide as the container it sits in: the content width, or the slot’s share inside a split. A text item’s box has always taken that width too. fit sizes the picture and align places it inside. So a background or border* spans the width rather than hugging the picture, and no declaration asks for the old shape.

valign is the reverse case. It’s legal on an image inside a split slot, whose box takes the split’s height. It’s a definition error on a band image, whose height is its own picture’s.

An image may appear in any item array. That means the report header and footer, a group header and footer, the detail band’s array form, empty, and page bands. It may not appear in a table cell or a total cell.

Laying cells across the width

an invoice header
{
  "type": "split",
  "style": { "spaceAfter": 24 },
  "slots": [
    { "type": "text", "value": "{{ $.input.seller.name }}", "width": 60,
      "style": { "size": 10, "bold": true } },
    { "type": "text", "value": "{{ $.input.customer.name }}",
      "style": { "size": 10, "align": "right" } }
  ]
}

slots takes two or more, each a text or image item plus an optional width. Splits don’t nest, and neither appears in a table or total cell.

width is a percentage share of the content width, and it must be greater than 0 and at most 100. Shares total under 100 when any slot has none, and at most 100 when every slot has one. Over-commitment is a definition error. A split whose slots all declare a width and leave some of it unclaimed draws a warning naming the trailing share. A table’s columns already did the same, and the report still renders.

The split’s own style is the layer under its slots, so a bold split renders bold slots unless a slot says otherwise. It ranks above the band’s role default too. A report-header split declaring "size": 8, "bold": false renders its slots at 8 points and not bold, where the band alone would make them headlines. The box stays on the split and doesn’t layer into them, and flow spacing stays on the split too.

Every slot’s box is the split’s height rather than its own content’s. A bordered split therefore draws one straight edge across whether or not a slot wraps. A slot rendering nothing still holds that full-height box.

Laying several lines on one set of columns

a statement's closing lines
{
  "type": "grid",
  "columns": [{ "width": 70 }, { "width": 30, "style": { "align": "right", "format": "currency" } }],
  "rows": [
    { "slots": [{ "type": "text", "value": "Opening" }, { "type": "text", "value": "{{ $.input.opening }}" }] },
    {},
    { "style": { "bold": true },
      "slots": [{ "type": "text", "value": "Closing" }, { "type": "text", "value": "{{ $.input.closing }}" }] }
  ]
}

columns takes two or more, each an optional width and an optional style. The width shares follow a split’s rules. A column’s style is the layer under each slot in that position, and a slot’s own declarations win.

rows takes one or more, and a row is a split without its type. It keeps every rule a split has: full content width, no nesting, never broken across a page. Its style and visible belong to that line alone, which is where a rule above a total or a gap before the first line goes.

A row may name fewer slots than there are columns. The rest stay empty, so {} is a spacer line and a one-slot row is a label with nothing beside it. A slot in a grid takes no width, because its column already has one.

Reaching across table columns

A split is for a band. Inside a table, the equivalent is span.

a total row that spans
{
  "cells": [
    { "value": "Total due", "span": 4 },
    { "value": "{{ round($.subtotal + $.vat, 2) }}", "style": { "format": "currency" } }
  ]
}

A spanning cell draws as one box across the columns it covers, starting where the first starts. It takes no part in measuring them. A header cell may span too, and a spanning header takes the first column it covers.

Each total row must cover the columns exactly once, counting span.

What a target does with each

A grid reaches every target as its rows, each one a split. A split reaching a grid is one row, its slots the cells across it. The width shares go unread, because a worksheet’s columns are global to the sheet. In CSV it’s one record and its slots are the fields.

Image placement in a worksheet is wholly best-effort, and CSV reads images not at all. A slot holding one still writes an empty field, so the fields either side keep their positions.