Appearance
RFML — Reportforge Markup Language
Version 0.2 · Working draft · 2026-10-28
RFML 0.2 adds page overlays (§9.1), page stamps (§6.13), designer state in the document's meta (§19.1), lock paths and locks on page templates (§19.3), schemaFrom (§10.1), item orphans and widows on regions (§12.1), text-fit and logical sides (§8.4), and table edit operations (§21.1). It is a minor version: it only adds syntax, so a 0.1 document is read as it is and keeps declaring "rfml": "0.1" until it uses one of the additions §24.1 lists. Corrections to how documents are laid out and written apply to every document, whatever version it declares (§24.1). The additions are marked (0.2) where they appear, and §30 lists them.
Revisions 0.1.1 and 0.1.2 clarified 0.1 without changing what documents may contain. The clarifications are marked (0.1.1) or (0.1.2) where they appear. Revision 0.1.2 records the choices the reference engine made where 0.1.1 was silent or ambiguous.
RFML describes paginated reports as a tree of typed elements, styled with a subset of CSS and bound to data with a small expression language. It is shaped like HTML on purpose, with a few report-specific additions: pages, data regions, expressions, components and tests.
The language is product-neutral: anything specific to a host (such as library: references, environments or permissions) is marked as host-defined.
Contents
- Introduction
- Conventions
- Processing model
- Document structure
- Nodes
- Element reference
- Inline content
- Styling
- Pages
- Data and parameters
- Expressions
- Data regions
- Charts
- Components and subreports
- Assets and fonts
- Layout and pagination
- Outputs
- Accessibility and localisation
- Metadata, locks and authoring hints
- Tests
- Edit operations
- Diagnostics and the layout model
- Extensions
- Versioning and canonical form
- Security
- Conformance levels
- Markup notation
- Complete example
- Open questions
- Revision history
1. Introduction
1.1 Goals
RFML must let one engine produce a very wide range of reports from a small, general vocabulary. Invoices, statements, bills, grouped financials, crosstabs, letters, labels, forms and regulatory filings are all compositions of the same elements. None of them is built into the language.
It must be equally usable by three kinds of author:
- Business users, through a visual designer.
- Developers, through source files, an SDK, a CLI and version control.
- AI agents, through a typed tool interface.
All three edit the same document through the same edit operations (§21). No author has capabilities the others lack, and none bypasses validation or permissions.
1.2 Design principles
- HTML-shaped, not HTML. Element names and CSS property names follow web conventions wherever the meaning is the same. People and language models already know them. RFML is a closed, strictly typed vocabulary, not arbitrary HTML: there are no scripts, no selectors and no unknown elements.
- One canonical tree. A document is one JSON value. The markup notation (§27) is an exact one-to-one view of it, not a second format.
- Strict and typed. Every element type has its own schema. Unknown properties are errors. A document that validates is one every conforming engine can render.
- Deterministic. Output depends only on the document, data, parameters, assets, render time, engine version and the render settings of §3 (schema mode, error policy, view state, link base, allowed extensions and limits). Expressions are pure.
- General primitives, extensible edges. Variety is built from composition and components. Genuinely new capabilities come in as namespaced extensions (§23), never as changes to the core.
- Data-agnostic. A document declares the shape of the data it expects, and may name a host data source for it. It never contains connection details or queries.
- Format-neutral semantics. The tree means the same thing for every output. PDF, DOCX, XLSX and HTML are projections of it, and none is derived from another.
- Explainable. Every validation, expression and layout problem is a diagnostic with a stable code, a node reference and, where possible, a machine-applicable fix.
1.3 Relation to HTML and CSS
| RFML | HTML analogue | Difference |
|---|---|---|
box | div | Layout through display: block | flex | grid only |
text, heading | p, h1–h6 | Content is inline content (§7) with {{ }} interpolation |
span, link, br | span, a, br | Inline only |
list, item | ul/ol, li | |
table | table | Can be static or bound to data, with groups and totals |
image, rule | img, hr | |
frame | div with position: relative | Absolute positioning is allowed only for a frame's children, and for the children of a page background, overlay or pagestamp, which are positioned against the page |
repeat, matrix | none | Data regions |
chart, barcode | none | Built-in visual elements |
use, slot | custom elements, <slot> | Components with typed props |
subreport | iframe (loosely) | Embeds another document in the page flow |
pagebreak | CSS break-before: page | Shorthand element |
pages | CSS @page | Page templates with header, footer, background and overlay slots |
| style properties | CSS properties | A subset, same names and units, no selectors |
| expressions | JavaScript | CEL profile; pure, sandboxed, typed |
1.4 Out of scope
- Querying data sources, connection strings and credentials. The host fetches data and passes it in.
- Interactivity that runs scripts. Viewers change a view state and render again instead (§17.6). (0.1.2)
- Storage, publishing, environments and access control. Those belong to the host (Reportforge's control plane).
2. Conventions
- MUST, MUST NOT, SHOULD, MAY are used as in RFC 2119.
- JSON property names use
camelCase. Style property names use CSSkebab-case. Exprmeans a string containing an expression (§11).Contentmeans inline content (§7).Lengthmeans a CSS length (§8.3).Node[]means an ordered list of nodes.- Examples use canonical JSON unless they are labelled as markup notation. They are laid out compactly rather than one property per line, and short fragments may list style properties in a different order. The complete example in §28 is in canonical property order. (0.1.2)
- Text marked Informative explains intent and is not a requirement.
3. Processing model
A render takes a document, data (one value per declared dataset), parameter values, a render context (now, locale, timezone, output format), an optional view state (§17.6), assets and render settings. It produces output bytes, a layout model and diagnostics.
The render settings are part of the input, since each changes the output (0.2): the schema mode (§10.2), the error policy (§11.7), the link base for report() targets (§17.6), the extensions the host allows (§23), whether to fill missing datasets from samples (§10.1), and the limits (§25.2).
- Load. Parse the JSON, read
rfml, and migrate older versions to the engine's version (§24). - Resolve. Resolve imported components, themes, assets and subreports to pinned versions.
- Validate. Check the document against the schema, then check the semantic rules: ids are unique, references resolve, and expressions parse and type-check against the declared data schemas.
- Bind data. Check each dataset against its schema in the configured mode (§10.2).
- Bind parameters. Evaluate defaults and options in declaration order, and check the supplied values. Options computed from a dataset read the data bound in step 4 (0.2: the order of steps 4 and 5).
- Expand. Evaluate
vars,ifconditions and data regions, producing an instance tree in which every repeated element has an instance key (§22.3). Expressions that depend on pages (page.*) are left as placeholders. - Lay out. Compute styles, format the boxes, break them into pages using the page templates, and fill in page placeholders. This repeats until stable, up to an engine limit (§16.9).
- Emit. Write the requested format. Emitters MAY read the instance tree as well as the page fragments; DOCX and XLSX SHOULD (§17).
A host that fetches sourced datasets (§10.4) does so before step 4, with each dataset's sourceParams evaluated against the supplied parameter values (and their defaults). So sourceParams read only params, now, locale and timezone; any other name is expr/unknown-name. While it fetches, options computed from data aren't known yet; a host that lists a parameter's options before data is loaded leaves those out. A source call that fails stops the render with a host error, not a diagnostic, since the document isn't at fault (the reference host answers HTTP 502 with kind source-failed). (0.2)
Steps 1–3 run without data and back the designer's validation. Steps 1–7 produce the layout model used by the designer's canvas. An engine SHOULD be able to run steps 1–7 in a browser (for example as WebAssembly) so that previews match final output exactly.
4. Document structure
A document is a JSON object:
| Property | Type | Required | Purpose |
|---|---|---|---|
$schema | string | SHOULD | URL of the RFML JSON Schema version |
rfml | string | MUST | Language version, e.g. "0.2" |
id | Id | MUST | Stable document id |
meta | Meta | MUST | Title, description, locale and authoring metadata (§19) |
params | Param[] | MAY | Report parameters (§10.3) |
data | map of Dataset | MAY | Declared datasets (§10.1) |
vars | map of Expr | MAY | Document-level computed values (§11.4) |
styles | Styles | MAY | Tokens, defaults, classes, theme (§8) |
assets | map of Asset | MAY | Images, fonts and other files (§15) |
imports | Import[] | MAY | Component and theme libraries (§14.3) |
components | map of Component | MAY | Local component definitions (§14) |
i18n | map of Messages | MAY | Message catalogues by locale (§18.2) |
pages | PageTemplate[] | MUST | At least one page template (§9) |
footnotes | FootnoteSettings | MAY | Numbering and placement of footnotes (§7.3) |
body | Node[] | MUST | The document content |
outputs | Outputs | MAY | Per-format settings (§17) |
tests | Test[] | MAY | Embedded test cases (§20) |
x-* | any | MAY | Extension data (§23) |
No other top-level properties are allowed.
5. Nodes
5.1 Common properties
Every element node is a JSON object with these properties, plus the ones its type defines:
| Property | Type | Purpose |
|---|---|---|
type | string | Element type. MUST be a core type or a namespaced extension type |
id | Id | Unique within the document (component internals have their own scope, §14.2) |
class | string[] | Named style classes, applied in order |
style | Style | Inline style declarations (§8) |
rules | Rule[] | Conditional styles (§8.5) |
if | Expr → bool | Render the node only when this is true |
lang | string | BCP 47 language tag for this subtree |
running | map of Expr | Running values set when the node is placed on a page (§9.4) |
export | ExportHints | Per-format hints (§17.4) |
actions | Action[] | What happens when a viewer clicks or hovers (§17.6) |
meta | NodeMeta | Label, description, locks (§19) |
type and id are required on every element node except slot, which has no id (§14.1). (0.1.2)
5.2 Ids
- An
Idmatches^[a-z][a-z0-9-]{0,63}$. Names read as bare identifiers in expressions (parameter and dataset names, row aliases) are expression names instead (§11.4) (0.2). - Ids MUST be unique within their namespace. Nodes and page templates share one (0.2), since an edit operation's
targetnames either (§21.1): a node with a page template's id isschema/duplicate-id. Components and assets each have a namespace of their own, as do parameter and dataset names. Footnote ids are a namespace of their own too, in which an id may repeat (§7.3) (0.2). - Node ids are unique within their scope: the document (its body and page templates) is one scope, and each component template is another (§14.2). (0.1.2)
- Ids of items in a keyed list (a table's
columns,header,rowsandfooter, a region'sgroups, a chart'sseries, a region'sfilters, a matrix'srows,columnsandcells) only need to be unique within that list. So do the optional ids of a static row'scells, within the row (0.1.2). A duplicate isschema/duplicate-id. (0.1.2) - Ids are stable: tools MUST NOT change an existing id except through the
renameIdoperation (§21). - References between nodes always use ids, never positions or paths.
- Designers SHOULD generate readable ids from the element type and context (
table-charges,text-total-due).
5.3 Slots and containment
Container elements hold children in named slots. Most containers have one slot, children. Data regions and page templates have several (for example header, detail, footer).
Each slot declares which element types it accepts. The categories are:
- Block:
box,text,heading,list,table,image,rule,pagebreak,frame,pagestamp(0.2),repeat,matrix,chart,barcode,use,subreport,toc, and block extensions. - Inline: strings,
span,link,br,field-image,footnote(§7). Inline content appears only inContentproperties, never in slots. - Positioned: any block element, inside a
frameonly, with position properties (§6.12). The children of a page template'sbackgroundandoverlay(§9.1) and of apagestamp(§6.13) are positioned the same way, against the page.
A repeat is transparent to containment: its detail must contain what its parent slot accepts. A repeat inside a list produces item elements, for example.
6. Element reference
Each subsection lists the element's own properties in addition to the common ones.
6.1 box
The general-purpose container.
| Property | Type | Notes |
|---|---|---|
children | Node[] (block) |
Layout comes from style: display: block (default, vertical flow), display: flex or display: grid (§8.4). A box is breakable across pages unless its style says break-inside: avoid.
6.2 text
A paragraph.
| Property | Type | Notes |
|---|---|---|
content | Content | Required |
6.3 heading
A heading. It takes part in document outline, bookmarks, table of contents and tagged PDF structure.
| Property | Type | Notes |
|---|---|---|
level | 1–6 | Required |
content | Content | Required |
bookmark | bool | Default true. Adds a PDF bookmark |
6.4 list and item
| Element | Property | Type | Notes |
|---|---|---|---|
list | marker | disc | circle | square | decimal | lower-alpha | upper-alpha | lower-roman | upper-roman | none | Default disc |
list | start | integer | First number for ordered markers |
list | children | (item | repeat)[] | |
item | children | Node[] (block) | A plain string item is written as a single text child |
Each item's marker is drawn in the list's marker part (§8.6), which inherits from the item: "parts": { "marker": { "color": "var(--brand)", "font-weight": 700 } } on the list colours and bolds every number. Flowing HTML writes it as li::marker, and DOCX as the run properties of the numbering.
6.5 table
A grid of rows and cells. A table is either static (explicit rows) or bound (rows generated from data). A bound table is a data region (§12.3).
Static form:
| Property | Type | Notes |
|---|---|---|
columns | Column[] | Column widths and per-column styles |
header | Row[] | Repeats on each page by default |
rows | Row[] | |
footer | Row[] | |
repeatHeader | bool | Default true |
A Row is { "id", "cells": Cell[], "style"?, "class"?, "if"? }. A Cell is { "id"?, "content"?: Content, "children"?: Node[], "colspan"?, "rowspan"?, "style"?, "class"?, "rules"? }, with exactly one of content or children.
The cells are packed into a grid, section by section (header, rows, footer, and a bound table's headerRows and footerRows) (0.2):
- The grid has as many columns as
columnsdeclares, or, withoutcolumns, as many as the widest row'scolspans add up to. - Each cell takes the next column that no
rowspanfrom a row above covers, and coverscolspancolumns androwspanrows from there. A covered slot has no cell of its own: the row simply lists fewer cells. - A cell that would run past the last column, a
rowspanthat runs past the last row of its section, and a cell that would overlap one spanning down from above areschema/invalid-valuewhen the document is loaded. Layout lays the rest out as if that cell weren't there. - The table edit operations (§21.1) keep the grid packed this way; an edit that can't is
edit/table-span.
The two forms don't mix (0.1.2). A static table can't have the region properties of §12.1, and its columns have no cell, cellChildren, header, footer, sortable or sortBy. A bound table can't have header, rows or footer, and MUST have columns. Mixing them is schema/not-allowed-here.
6.6 image
| Property | Type | Notes |
|---|---|---|
asset | asset id | Static image from assets |
src | Expr → string | Dynamic image: an asset id, a library reference, or a data: URL from the data |
alt | Content | Required unless decorative is true (a11y/missing-alt error) |
decorative | bool | Marks the image as an artifact in tagged PDF |
fit | contain | cover | fill | none | Same as CSS object-fit; default contain |
Exactly one of asset or src.
6.7 rule
A horizontal line. Its appearance comes from border-top (default 0.5pt solid currentColor) and margins.
6.8 pagebreak
Forces a page break. Equivalent to break-before: page on the following sibling. It MAY set page to switch page template (§9.3).
| Property | Type | Notes |
|---|---|---|
page | page template id | Optional |
6.9 barcode
| Property | Type | Notes |
|---|---|---|
symbology | qr | datamatrix | pdf417 | code128 | code39 | ean13 | ean8 | upca | itf | aztec | Required |
value | Expr → string | Required |
showText | bool | Human-readable text under linear codes |
errorCorrection | L | M | Q | H | QR only |
alt | Content | Alternate text. Without it, the encoded value is the alt text |
decorative | bool | Marks the barcode as an artifact in tagged PDF and hides it from screen readers in HTML |
- Without a
widthorheightin its style, a linear code is drawn with 1pt modules, quiet zones included, and 36pt bars, plus a line of text withshowText. A 2D code is drawn with 2pt modules. Either shrinks to fit the available width. (0.1.2) - A value the symbology can't encode raises an
output/barcode-invalidwarning. (0.1.2) - A barcode always has alt text: its
alt, or else its encoded value, so a barcode withoutaltraises no diagnostic. A payment QR code reads better with"alt": "Scan to pay invoice {{ invoice.number }}"than with its URL. Adecorativebarcode has none: it is an artifact in tagged PDF,aria-hiddenin HTML and marked decorative in DOCX (§18.1).
6.10 toc
A generated table of contents built from heading elements, with page numbers.
| Property | Type | Notes |
|---|---|---|
levels | 1–6 | Deepest heading level included; default 3 |
leader | dots | none | Default dots (0.1.2) |
(0.1.2)
- The table lists the body's headings, including headings inside components and regions, but not those in page templates or in a region's
continuationHeaderandcontinuationFooter(0.1.2). A heading'sbookmarkdoesn't affect it. Headings whose text is empty are left out. - An entry's text is the heading's content as plain text: spans and links lose their styles, and footnote markers and field images are left out.
- An entry's page number is that of the heading's first fragment. Because it is a page value, it is filled in by the passes of §16.9. Each entry links to its heading.
- A heading that renders more than once, in a region's
detailor a component used several times, gives one entry per rendered instance, each linking to that instance (0.2). A heading in a region'sdetailtherefore lists every item; put the heading in a group'sheaderto list each group once. - An entry is one monolithic box, styled by the
entryandentry-<level>parts. The leader fills the space between the title's last line and the page number, which sits at the end edge on that line's baseline.
6.11 Data regions, charts, components
6.12 frame
A fixed-size container whose children are positioned absolutely, for forms, payment slips, labels and pre-printed stationery.
| Property | Type | Notes |
|---|---|---|
children | Node[] (positioned) |
- A frame MUST have a definite
width(or fill its parent's width) and a definiteheight. - A frame never breaks across pages:
break-inside: avoidis implied and cannot be overridden. - Each child MUST set
toporbottom,left,right,inset-inline-startorinset-inline-end(0.2), and SHOULD setwidth. Positions are relative to the frame's padding box. - Children that overflow the frame are clipped, with a
layout/frame-overflowdiagnostic. z-indexorders overlapping children; default is document order.
(0.1.2)
- A frame without a
height, or a child without a vertical inset (toporbottom) or a horizontal one (leftorright), is aschema/missing-propertyerror at/style. The values may come from the node's own style, its classes orstyles.defaults. The same holds for the children of a page template'sbackground, and of itsoverlay(0.2). - Children are positioned as in CSS absolute positioning, with the frame's padding box as their containing block. A child with both
leftandrightis stretched between them; a child with one of them and nowidthshrinks to fit its content. Percentages refer to the padding box. z-indexchanges only the painting order. Reading order and the layout model stay in document order (§18.1).rotateturns a child about the centre of its border box. Overflow is checked before rotation.layout/frame-overflowis a warning, raised once for each child that overflows.- Insets stay physical in right-to-left text (§16.1):
leftmeasures from the frame's left edge whatever itsdirection.inset-inline-startandinset-inline-end(0.2) set the side the child's direction gives (§8.4), so a layout that should mirror in right-to-left text uses them. - A positioning property in the style or rules of a node that is neither a frame's child nor in a page
backgroundoroverlay(0.2), nor a page stamp's child, raises aschema/not-allowed-herewarning. A page stamp's ownz-indexis allowed (§6.13). Classes andstyles.defaultsaren't checked for them, since they may also be used inside frames.
Informative: frames keep fixed positioning contained. Everything outside frames flows, so variable-length data cannot push fixed elements on top of each other.
6.13 pagestamp
(0.2) A point in the flow whose children are positioned against the page that point lands on: a "Paid" stamp on the page where a paid invoice's row starts, a "Fragile" mark on a parcel's label. Unlike the page overlay (§9.1), it sits in the body, so its if and its content can read the row it is placed in.
| Property | Type | Notes |
|---|---|---|
children | Node[] (positioned) | Positioned against the page, as a page overlay's children are |
decorative | bool | Default false. Marks the stamp as an artifact: tagged output leaves it out of the structure, and HTML hides it from screen readers |
once | page | item | Default page: the first instance of the stamp on a page draws. item: every instance whose point is on the page draws |
- A page stamp may be in any body slot that holds block nodes: the body, a box, a list item, a region's
detailand group bands, a bound table'sheaderRows,footerRows, group bands and a column'scellChildren, a static table's cells, continuation slots, and component templates. In a bound table it lives in a column's cell, so hiding that column hides it. - It is a
schema/not-allowed-hereerror in a page template'sheader,footer,backgroundoroverlay(its own or a variant's), anywhere inside aframe, and inside anotherpagestamp. A footnote inside a page stamp is the same error, since the page's footnote area is laid out before the stamp; one that still reaches a stamp is left out. - Its
ifis an ordinary condition (§5.1), evaluated where the stamp sits, so it sees the row alias,item,groupand the region's variables. Like anyif, it can't use page values (§11.3). Page values in its children's content are deferred as anywhere in the body (§16.9). - Its children follow the rules of a frame's positioned children (§6.12), with the page as their frame: each sets
toporbottomandleftorright, insets and percentages are of the page,z-indexorders them among themselves, and what overflows the page is clipped. - Of the stamp's own style, only
z-index,opacityandvisibilityapply.z-indexorders stamps among each other on a page. A stamp draws no box, border or background of its own, and takes no space in the flow. - Stamps paint above the page's footer, footnote area, content and header, and below its overlay (§16.13). A stamp placed over the page number hides it; place stamps clear of the bands.
- On a page, only the first instance of each stamp node draws, in placement order (§16.13). Different stamp nodes each draw. With
once: item(0.2) every instance on the page draws. The instances share the page's coordinates, so they land on each other unless their children'srulesread the row and place them apart. - A page stamp is content, read where it sits in the document: tagged output reads the children of the instance that drew, in its place, so a stamp in a table cell is read in that cell, with its row. Instances that didn't draw are not read. An instance drawn from a repeated header or a continuation slot is a Pagination artifact, as the rest of that band is, and a
decorativestamp is a Layout artifact (§18.1) (0.2). Headings in a page stamp are not listed in a table of contents. - Formats without pages can't place stamps. Flowing HTML shows each instance's children where the instance sits, unpositioned, so a stamp in a table's rows shows on every row whose
ifholds; DOCX writes the instances the layout drew; XLSX leaves stamps out with anoutput/unsupportedwarning (§17).
json
{ "type": "pagestamp", "id": "paid-stamp", "if": "row.status == 'paid'",
"children": [
{ "type": "text", "id": "paid-mark", "class": ["stamp"],
"style": { "top": "30mm", "right": "20mm", "rotate": "-12deg" },
"content": "Paid {{ Format(row.paidOn, 'd MMM') }}" } ] }7. Inline content
Content is either a single string or an array of inline items:
Content = string | Inline[]
Inline = string | Span | Link | Break | FieldImage | Footnote
Span = { "type": "span", "content": Content, "style"?, "class"?, "rules"?, "if"?, "lang"? }
Link = { "type": "link", "href": Expr → string, "content": Content, "style"?, "class"? }
Break = { "type": "br" }
FieldImage = { "type": "field-image", "src": Expr → string, "alt": Content, "style"? }
Footnote = { "type": "footnote", "id"?: Id, "content": Content, "marker"?: Content, "if"? }- Inline items have no
id, except footnotes, which MAY have one so that text can refer to them (§7.3). Other inline items are addressed by their parent node plus an index. - Line breaks in strings are treated as spaces, as in CSS
white-space: normal. Usebrfor a forced line break, or setwhite-space: pre-lineon the node. hrefmay be a URL or#<node-id>for an internal link. An internal link goes to the first rendered instance of that node, in page order. If the node rendered nothing (anifthat failed, a region with no items,display: none), the link is left out and its text kept, with anoutput/link-unresolvedwarning (0.2). The form#<node-id>?instance=<key>, naming one instance (§22.3), is reserved for a later version.- A span or link is an inline box, as in CSS. Its style accepts every property, and these apply: the text properties (§8.4) and its decoration,
background-color,padding(-*),border(-*)andborder-radius, laid out and painted as §8.4.1 says. Other box properties (margins, sizes, layout, position) have no effect on it. A field image takeswidthandheight.
7.1 Interpolation
Any string in Content is a template. {{ expr }} inserts the result of an expression, converted to text with §11.6:
json
"content": "Amount due: {{ Format(vars.totalDue, 'currency') }} by {{ Format(data.bill.dueDate, 'date:long') }}"- An interpolation starts at
{{and ends at the first}}that is outside a string literal and not closing a map or brace in the expression, so{{ Lookup(rates, r, r.code, 'GST')['rate'] }}and{{ {'a': 1}['a'] }}both work. Whitespace just inside the braces is ignored. - To write
{{literally, use{{ '{{' }}. An unmatched{{is an error (expr/unclosed-interpolation). A lone}}outside an interpolation is literal text. - An empty interpolation
{{ }}is an error,expr/empty-interpolation(0.2: the code). Its braces are kept as literal text. - An interpolation that produces
nullinserts nothing. - An interpolation error raises a diagnostic. Under the
failerror policy the render fails; undercontinuethe interpolation inserts nothing (§11.7). - Interpolation is the only way to put dynamic values into text. There is no separate value node.
Designers SHOULD show each interpolation as a chip that opens the formula editor:
- The chip's label is the field name when the expression is a plain path (using the schema's
titleif there is one), and otherwise a short form of the expression or the node'smeta.label. - A
Formatpattern is shown on the chip, e.g. "Total due · currency". - Editing a chip rewrites only the text between its braces, so the rest of the string, and its diff, are unchanged.
- The language service reports the character range of every interpolation, so diagnostics and fixes (§22.1) point at the exact chip.
7.2 Canonical form
A Content array that holds exactly one string MUST be written as that string. Adjacent strings in an array MUST be merged.
7.3 Footnotes
A footnote places a marker in the text and its content at the bottom of the page where the marker lands (§16.11).
json
"content": [
"Revenue grew 12%",
{ "type": "footnote", "id": "fn-revenue", "content": "Excludes one-off grant income of {{ Format(data.grants, 'currency') }}." },
" on the prior year."
]Markers are numbered automatically, following the document's
footnotessettings:json"footnotes": { "numbering": "decimal", "restart": "document", "maxHeight": "50%" }Setting Values Default numberingdecimal | lower-alpha | lower-roman | symbols(*,†,‡,§, …)decimalrestartdocument | section | pagedocumentmaxHeightLength or %of the content area50%(0.2: stated)
symbolsruns*†‡§‖¶, then the same six doubled (**…¶¶), then tripled, and so on without end. Asectionforrestartis a run of pages on one page template (§9.3): numbering restarts where the template changes, not at a plain page break or a switch to the template already in use.markerreplaces the automatic marker with fixed text, for example"a". A footnote with its own marker doesn't use up a number.Text can refer to a footnote's number with
{{ footnote('fn-revenue') }}, which returns its marker text.Footnotes in repeated content create one footnote per instance. Footnotes with the same
idand the same rendered content share one marker within the restart scope, so a note on a table column appears once per page or document, not once per row.Footnote ids are a namespace of their own, apart from node ids, and may repeat: sharing is what repeating them is for (0.2). Footnotes with the same
idwhose rendered content differs get a marker each, andfootnote(id)returns the first one's. Two footnotes written with the sameidand differentcontentraise aschema/footnote-idwarning, since that is usually a copy that wasn't updated; content that only differs once rendered isn't seen.The parts
footnote-marker,footnote-area,footnote-separatorandfootnote-bodystyle the marker, the area at the bottom of the page, the rule above it, and each note. Default separator: a 0.5pt rule, one third of the content width.A footnote has no
styleorclassof its own. Its parts are styled instyles.defaults.footnote.parts, in the document or its theme. By default the marker isvertical-align: super; font-size: 70%. A footnote's body inherits from thefootnote-areapart, not from the paragraph that calls it. (0.1.2)Footnotes are not allowed in page template bands (§16.11). (0.1.2)
8. Styling
8.1 Model
RFML uses CSS properties and values, without selectors. To style the pieces an element generates, such as a table's header cells or a group's footer, elements expose named parts (§8.6), like CSS ::part().
Styles reach a node, or a part of it, from these sources, applied in this order (later wins):
- Engine defaults.
- The theme named by
styles.extends, if any. styles.defaults[<element type>], andstyles.defaults.documentfor the root.- Each class in the node's
class, in order. - The node's
style. - Each matching entry in the node's
rules, in order.
For a part, each of these sources contributes its parts.<part-name> block, if it has one.
The order is source-major (0.1.1): each source applies all of its blocks (the element's own block, then its part blocks from general to specific, §8.6) before the next source applies any. So a class's cell part overrides a theme's header-cell part, because the class is a later source, even though header-cell is the more specific part.
Inherited properties (color, font-*, line-height, text-align, letter-spacing, word-spacing, text-transform, white-space, direction, hyphens) pass from parent to child as in CSS. (0.1.2: white-space)
Cascade details (0.1.2):
- Engine defaults are the CSS initial values, except
font-family: sans-serif,font-size: 10ptandcolor: #000. Headings addfont-weight: 700andbreak-after: avoid(§16.3), the footnote marker has its own default (§7.3), and so do the chart partsaxisandgridline(§13). Being engine defaults, they give way to every later source:break-after: autoon a heading turns its keep off (0.1.2). Layout defaults are given where they apply: the line of arulein §6.7, and list markers in §16.1. - The body's content inherits from the document root (
styles.defaults.document), not from the page template'scontentpart, which styles only the page's content box. - A rule's
classentries apply before the rule's ownstyle. - A document class with the same name as a theme class is applied right after it, as
styles.defaultsare after the theme's defaults (0.2): the document's declarations win and the theme's others stay, source-major across parts. (Before 0.2 the document class replaced the theme's whole.) - Within one style block, a longhand overrides its shorthand whatever their order in the JSON. (Canonical form writes each shorthand before its longhands, §24.2.)
- A declaration whose token doesn't resolve is dropped, so earlier sources stand. A value that is valid only before its tokens are substituted is dropped the same way, with a
schema/invalid-valuewarning. A token that refers to itself through other tokens is aschema/token-cycleerror, reported on each token in the cycle. - An unknown class or token is a warning (
ref/unknown-class,ref/unknown-token). When the document extends a theme, one is reported only once the theme is resolved and it defines neither (0.2); before then, as at design time without the theme's library, neither is reported, since the theme may define it.
Informative: no selectors means the style of any node is computable from the node and its ancestors alone, which is what a property panel and an agent need to explain "why is this red?".
8.2 The styles object
json
"styles": {
"extends": "theme:corporate@^2",
"tokens": { "brand": "#0085CB", "ink": "#1F2933", "space-2": "6pt" },
"defaults": {
"document": { "font-family": "Inter", "font-size": "9pt", "color": "var(--ink)" },
"heading": { "font-weight": 700, "color": "var(--brand)" },
"table": {
"border-collapse": "collapse",
"parts": {
"header-cell": { "font-weight": 600, "background-color": "var(--panel)" },
"group-footer": { "border-top": "0.5pt solid var(--ink)" },
"row-even": { "background-color": "#F7F9FA" }
}
}
},
"classes": {
"money": { "text-align": "right", "font-variant-numeric": "tabular-nums" },
"card": { "padding": "var(--space-2)", "border": "0.5pt solid var(--brand)", "border-radius": "4pt" },
"compact": { "parts": { "cell": { "padding": "2pt 4pt" } } }
}
}- A style block is a map of CSS properties, optionally with a
partskey mapping part names to further style blocks (without their ownparts). The same shape is used indefaults,classes, a node'sstyle, and inrules. - Tokens are named values, referenced as
var(--<token>). Themes provide tokens; documents MAY override them. - Class and token names match
^[a-z][a-z0-9-]*$. defaultskeys are element types, including inline types (span,link) and extension types.
8.3 Values and units
- Lengths:
pt(default when a bare number is given),mm,cm,in,px(1/96 in),em,rem,%. - Colours: hex,
rgb(),hsl(), named CSS colours,currentColor,var(--token). Engines SHOULD supportcmyk()for print outputs. - Style values are literal. A dynamic style is written as a rule (§8.5), never as an expression inside
style.
8.4 Supported properties
| Group | Properties |
|---|---|
| Text | color, font-family, font-size, font-weight, font-style, font-variant-numeric, line-height, letter-spacing, word-spacing, text-align, text-decoration, text-transform, text-indent, vertical-align, white-space, hyphens, direction, overflow-wrap |
| Spacing | margin(-*), padding(-*), margin-inline-start, margin-inline-end, padding-inline-start, padding-inline-end (0.2) |
| Size | width, height, min-width, max-width, min-height, max-height, box-sizing |
| Border | border(-*), border-radius, outline |
| Background | background-color, background-image (asset reference), background-size, background-position, background-repeat |
| Layout | display (block | flex | grid | none), flex-direction, flex-wrap, flex / flex-grow / flex-shrink / flex-basis, justify-content, align-items, align-self, gap, row-gap, column-gap, grid-template-columns, grid-template-rows, grid-column, grid-row |
| Multi-column | columns, column-count, column-width, column-gap, column-rule(-*), column-fill (balance | auto), column-span (none | all) (§16.12) |
| Pagination | break-before, break-after (auto | page | column | avoid | left | right), break-inside (auto | avoid), orphans, widows, page, box-decoration-break (slice | clone) |
Position (a frame's children, and the children of a page background, overlay or pagestamp) | top, right, bottom, left, inset-inline-start, inset-inline-end (0.2), z-index, rotate |
| Visibility and overflow | visibility (visible | hidden), opacity, overflow (visible | clip), text-overflow (clip | ellipsis), text-fit (none | shrink) (0.2) |
| Table | border-collapse, table-layout |
This table is the index of the properties. Each property's value grammar is the JSON Schema's (css.props in schema/rfml-<version>.json), which is normative for it: where this document lists values, it lists them for reading, and the schema wins if they differ (0.2).
The groups are for reading; they don't change what a property does. A style block's canonical order is the JSON Schema's (css.props), not this table's (§24.2). The JSON Schema gives each property its group (x-rfml-group, in the order of x-rfml-groups), a label where its name reads badly (title) and words people search for it by (x-rfml-keywords). These are hints for tools, not part of the language: a document never contains them, and an engine ignores them.
An unsupported property is a validation error, not silently ignored. A property added by a later version than the document declares is schema/unsupported-version (§24.1) (0.2).
Logical sides (0.2). margin-inline-start and -end, padding-inline-start and -end, and inset-inline-start and -end set the physical side the box's direction gives them: start is the left in ltr and the right in rtl. They are not inherited and are never seen by layout or the outputs, which read only the physical sides:
- The box's direction is its own
directiondeclaration, else its parent's. - Between a logical and a physical declaration of the same side, the later source wins (§8.1). Within one style block the logical one wins, whatever the JSON order, as a longhand wins over its shorthand: it comes later in the schema's order.
inset-inline-*place a frame's child (or a page-positioned one) asleftandrightdo (§6.12).
Text fit (0.2). text-fit: shrink, not inherited, sets a text or heading smaller until it fits its box. When its widest line is wider than the box, or its lines are taller than the box's definite height (else its max-height), less padding and border for box-sizing: border-box, it is laid out again at 95%, 90% and so on, down to 25% of its size, until it fits; at 25% it is drawn as it is. The font sizes, line heights given as lengths, letter and word spacing, raised baselines and the first line's indent all scale. Formats that paint from the layout (PDF, SVG, PNG and paged HTML) show the smaller size; flowing HTML and DOCX can't measure, so they set the text at its full size and raise output/unsupported (info) (§17). It is meant for forms and slips: a payee's name in a fixed field, white-space: nowrap with a width.
Hiding (0.2: stated). Three ways to hide a node differ in what they keep:
if false, or display: none | visibility: hidden | opacity: 0 | |
|---|---|---|---|
| Box and space | None | Laid out; takes its space | Laid out; takes its space |
| Drawn | No | No, nor anything inside it: visibility isn't inherited, so a descendant can't show itself | Yes, transparent |
| Read in tagged output, links and actions | No; a link to it is unresolved | No; viewers attach none of its actions | Yes |
| Its footnotes, table of contents entries, bookmarks | None | None | As for any content |
| Its running values and page stamps | None | Set and registered, since it is placed | As for any content |
| Its boxes in the layout model | None | Present, so a designer can select it | Present |
Expressions are evaluated whatever a node's style says, so variables and region items it would read are there for other nodes, and running functions count the items of a hidden row as they count those an if hides (§11.5). A table cell ignores display; hide a column with the column's if and a row with the row's if.
max-height applies to blocks, not only to flex and grid items (0.1.2). A block whose content is taller than its max-height is max-height tall and laid out whole, as a box with a definite height is: it doesn't break, and its content overflows it, drawn past its edge or clipped by overflow: clip. If it is taller than the page, it is oversized (§16.7). min-height wins over max-height. Content under the limit flows and breaks as usual. Table rows and cells ignore max-height, as they ignore height.
8.4.1 Inline boxes
A span or link with a background, a border or horizontal padding is a decorated inline box (§7), laid out and painted as CSS paints an inline box:
- Width. Its left and right padding and border take room at its start and end, as if they were content of no height. Line breaking doesn't see them: a line can't end between an edge and the text it belongs to, and the break opportunities around the box are those of its text. Percentages in an inline box's padding are 0.
- Height. Its vertical padding and border don't change the line's height. They are painted around its content area (its first font's ascent and descent, around its baseline), so they can overlap the lines above and below.
- Painting. On each line, the background, then the border, with the box's
border-radius, are painted behind the line's text. Outer boxes are painted before the boxes nested in them. - Breaking. A box broken over lines, columns or pages is painted on each line it reaches. Its left and right border, and the corner radii on that side, are drawn only where the box starts or ends, as CSS's
box-decoration-break: slicedoes; the middle pieces are open at both sides.box-decoration-breakapplies to blocks only. - Bidirectional text. The box's start and end edges belong to its own direction: in a left-to-right span the start edge is the left padding and border, in a right-to-left span the right ones, and they stay beside the box's text however the text around it reorders. Each visually contiguous run of the box's content on a line is one piece; a piece's side has a border and rounded corners when the box's edge is there.
- Tagging and links. Decoration is an artifact in tagged PDF; the text is read as it would be without it. A decorated link's area takes in its padding and border.
8.5 Conditional styles
json
"rules": [
{ "if": "row.amount < 0", "style": { "color": "var(--negative)" } },
{ "if": "row.index % 2 == 1", "class": ["zebra"] }
]Each rule has an if expression and a style, a class list, or both. Rules are evaluated in the node's scope (§11.3).
A rule's style MAY include parts. Part styles from a rule on a region are evaluated in the scope of each generated part: on a table, { "if": "row.overdue", "style": { "parts": { "row": { "color": "var(--negative)" } } } } colours the rows of overdue items.
On a region, the rules are read together (0.1.2). When any of them has part styles, all of them are checked in an item's scope (for a matrix, a cell's scope, with cell), and each is evaluated for every generated row, item or cell. When the region's own box is styled, rules that read the row alias, item, group or cell are skipped, and so are rules that read one of the region's per-row variables (§11.4) that the box's scope doesn't have. A column's rules are always evaluated per cell, in the item's scope.
Rows without an item (column headers and footers, headerRows, footerRows, group bands and the continuation slots) take the table's part rules and their columns' rules the same way: a rule that reads a name those rows don't have, the row alias, item, group outside a group's bands, or a per-row variable, is skipped there. A per-row variable named like a document or group variable reads that variable on those rows, as footers do (§11.3), so a checker checks such a rule there too, and reports expr/type when it isn't a condition there.
8.6 Parts
Elements that generate boxes of their own expose them as named parts. Parts can be styled but not selected, moved or removed: they belong to their element.
| Element | Parts, from general to specific |
|---|---|
table | row, row-odd, row-even; cell; header-row, header-cell; footer-row, footer-cell; group-header, group-footer; continued-header; empty |
repeat | header; item, item-odd, item-even; separator; footer; group-header, group-footer; empty |
matrix | corner; row-header, column-header; cell; total-row, total-column, total-cell |
list | item, marker |
toc | entry, entry-1 … entry-6, leader, page-number |
chart | title, axis, axis-label, gridline, legend, legend-label, data-label |
barcode | text |
| page templates | header, footer, content (set in pages[].style.parts) |
any element with a toggle action | toggle-indicator |
| sortable table columns | sort-indicator (on the header cell) |
regions with filters | filter-bar, filter-control (viewer only) |
Rules for parts:
- A generated box matches every part that describes it, applied from general to specific in the order above. A header cell matches
celland thenheader-cell; an even row matchesrowand thenrow-even. - Group parts nest: in nested groups,
group-headerapplies at every level. The scope variablegroup.levellets rules tell levels apart. - A table column's own
style,classandrulesapply to that column's cells after the table'scellpart styles. They apply to its header and footer cells too.partsin a class used on a column are ignored. A static table's cells belong to the column they are placed in, counting the column and row spans before them. (0.1.2) A column'srulesare evaluated for those cells too, and for the cells of band rows (headerRows,footerRows, group band rows and continuation slots); a rule that reads a row scope the row doesn't have (the row alias,itemorgroup) doesn't apply there, as on column header and footer cells. (0.1.2) - Table rows match their own kind of part only (0.1.2). Header rows match
header-row(notrow), with cellscellthenheader-cell; footer rows matchfooter-row, with cellscellthenfooter-cell. Group band rows matchgroup-headerorgroup-footer, with cellscell. AcontinuationHeader's rows matchcontinued-header, and acontinuationFooter's rows matchfooter-row. row-oddandrow-even(anditem-odd,item-even) count from 1 within the innermost group, so the first row is odd, asitem.oddis (§11.3). Rows hidden byifor a toggle aren't counted. (0.1.2)- Chart parts accept only
color,font-*,opacity,stroke(line colour),stroke-widthandstroke-dasharray. Other properties raiseschema/unsupported-part-property. - A list's
markerpart accepts only the text properties (§8.4). Other properties raiseschema/unsupported-part-property. - The chart parts
gridlineandaxiscarry engine defaults (§8.1):gridlinestroke: #dde1e6; stroke-width: 0.5pt,axisstroke: #8c939d; stroke-width: 0.75pt. An explicitstroke: noneremoves the line (§13). (0.1.2) - Inheritance works as usual from the part's box to its contents.
Component parts. A component MAY expose parts by mapping names to nodes in its template:
json
"remittance-slip": {
"parts": { "amount": "slip-amount", "barcode": "slip-barcode" },
"props": { … },
"template": [ … ]
}A theme or class can then style defaults.use or a class on the instance with "parts": { "amount": { "color": "var(--brand)" } }. Unexposed internals cannot be styled from outside.
9. Pages
9.1 Page templates
pages is a list of page templates, the equivalent of CSS @page plus master pages. The first is the default.
| Property | Type | Notes |
|---|---|---|
id | Id | Required |
size | A3 | A4 | A5 | B5 | letter | legal | tabloid or { "width": Length, "height": Length } | Required |
orientation | portrait | landscape | Default portrait |
margin | CSS margin shorthand | |
footerDistance | Length | From the page's bottom edge to the footer's bottom edge (0.1.2) |
header | Node[] (block) | Placed in the top margin area |
footer | Node[] (block) | Placed in the bottom margin area |
background | Node[] (positioned) | Drawn behind content, as a full-page frame (watermarks, letterheads) |
overlay | Node[] (positioned) | Drawn above everything else on the page, as a full-page frame (stamps such as "Draft" or "Copy") (0.2) |
variants | { first?, left?, right?, last?, blank? } | Each variant MAY override margin, footerDistance, header, footer, background and overlay (0.2) |
bleed | Length | For print outputs |
meta | NodeMeta | Label, description, note and locks (§19.3) (0.2) |
- Header and footer heights are measured from their content. If they don't fit inside the margin, the engine grows the margin and raises
layout/margin-grown. - The
lastvariant applies to the final page of the document, or of the section using that template (§9.3). - The header's bottom edge sits on the top of the content area.
layout/margin-grownis a warning, raised once per page. (0.1.2) - The footer is placed from the bottom of the page: its bottom edge is
footerDistanceabove the page's bottom edge, and a taller footer grows upwards. A percentage is of the page width, as for margins. WhenfooterDistanceisn't set, it is half the bottom margin, lowered towards the page edge as far as it takes for the footer to stay inside the margin. The content area ends at the bottom margin, or at the footer's top edge when that is higher, which grows the margin (layout/margin-grown). (0.1.2) firstapplies to the first page of each section.leftandrightgo by the page number in the document: even pages are left pages, odd pages right pages. (0.1.2)- A page takes one variant, the first that matches in the order of §16.6; variants don't layer (0.2: stated). A first page that is also a right page takes
firstalone, so its margins and bands arefirst's, falling back to the template's, never toright's. To give it both, repeat the shared settings and bands in each variant. - A blank page (§16.2) without a
blankvariant uses the variant it would otherwise have, with that variant's header, footer and background. (0.1.2) - The
backgroundis laid out again on each page, so page values work in it. It is painted before anything else on the page. A variant'sbackgroundreplaces the template's; an empty list removes it. (0.1.2) - Headers, footers and backgrounds draw every element they hold, charts, barcodes and extension elements included. A band's diagnostics are reported once per render, not once per page. (0.1.2)
The overlay (0.2):
- The
overlayis laid out as thebackgroundis: as a frame the size of the page, again on each page, so page values work in it. Its children are positioned as a frame's are (§6.12), andz-indexorders them among themselves. - It is painted last, above the content, the header, the footer and the footnote area.
- A variant's
overlayreplaces the template's, and an empty list removes it. A blank page without ablankvariant has the overlay of the variant it would otherwise have, as it has its background. - Each child's
ifdecides whether it shows, so a stamp can depend on data, parameters or the output format ("if": "params.status != 'final'"). As anywhere,ifcan't use page values (§11.3); a child can still show them in its content. The overlay is expanded once, before pagination, so it can't read the rows on its page: a stamp that depends on them is apagestampin the body (§6.13). - The overlay is not content. In tagged output it is a Pagination artifact of subtype Watermark (§18.1), so screen readers skip it, and it gets no bookmarks or table of contents entries.
- Like the other bands, it can't hold footnotes (§16.11), and it draws every element it holds.
9.2 Page values
Inside page templates, and anywhere in the body, expressions can use page:
| Value | Meaning |
|---|---|
page.number | 1-based page number within the document |
page.count | Total pages in the document |
page.sectionNumber, page.sectionCount | Same, within the current template section |
page.template | Id of the current page template |
page.running(name) | The current value of a running string (§9.4) |
Expressions that use page are deferred to layout (§16.9).
Deferral covers content and value properties (0.1.2): inline content, a barcode's value, an image's or field image's src, a link's href, running values and footnote(id). Properties that decide what is laid out can't use page (§11.3).
9.3 Switching templates
A block with style page: <template-id> starts on a page using that template, and its section continues until another block switches template. This follows the CSS page property. A pagebreak can switch template too.
The body has no test for the last page (0.2: stated). An if can't read page (§11.3), and the last variant changes a page's bands and margins, not the body. Something that must appear only on the final page goes in the last variant's footer or overlay; content that must end the document is simply placed last in the body.
9.4 Running values
Any node MAY declare "running": { "<name>": Expr }. When the node is placed on a page, its values become the page's running values for those names. page.running(name) returns the value of the last such node that started on or before the current page. This is how a header shows "Customer: Jane Smith" on each customer's pages; it is the equivalent of CSS string-set.
A repeated header (§16.8) sets its running values again on each page it repeats on. (0.1.2)
Informative: running values are not the running functions such as RunningSum (§11.5), which accumulate over a region's items.
10. Data and parameters
10.1 Datasets
data maps dataset names to declarations. At render time the host supplies one JSON value per dataset.
| Property | Type | Notes |
|---|---|---|
source | SourceRef | Optional. A host data source that supplies this dataset (§10.4) |
sourceParams | map of Expr | Values passed to the source. Evaluated with params, now, locale and timezone only |
schema | JSON Schema (2020-12), or { "$ref": "<bundle path>" } | Optional. MAY be inferred and MAY be partial |
schemaFrom | source | (0.2) The schema is the one the dataset's source publishes (§10.4). Needs source, and rules out schema |
sample | inline value or asset/library reference | Sample data for the designer, tests, previews and AI agents |
description | string | Purpose of the dataset, for people and agents |
required | bool | Default true. A missing required dataset is an error |
Datasets are read in expressions as data.<name>. A dataset name is an expression name (§11.4): ^[A-Za-z_][A-Za-z0-9_]*$ (0.2).
schemaFrom: "source" keeps schema a plain JSON Schema that generic tools can read. Before 0.2 the same was written "schema": { "$ref": "source" }, which such tools take for a relative URI. A 0.1 document keeps that form; in a 0.2 document it still works, with a schema/deprecated warning whose fix moves it to schemaFrom. schemaFrom without source is schema/missing-property, and beside schema it is schema/not-allowed-here.
A render uses a dataset's sample only when the host asks for samples, as previews, the designer and tests do (0.1.2). Otherwise a required dataset the host doesn't supply is bind/missing-dataset, so a production render never shows sample data. A test uses the sample of each dataset its data leaves out (§20).
A sample is bound exactly as supplied data is, in the render's schema mode (§10.2): a sample that breaks its dataset's schema raises the same bind/* diagnostics data would (0.2: stated).
Reading a dataset the document doesn't declare is a warning (ref/unknown-dataset), because hosts may supply datasets the document doesn't declare. Reading an undeclared parameter (ref/unknown-param) or variable (ref/unknown-var) is an error. (0.1.2)
Informative: nobody is expected to write schemas by hand. Hosts SHOULD offer schema inference from sample JSON, SQL result metadata, OpenAPI and GraphQL. A document with no schemas still renders; it just gets weaker autocomplete and validation.
10.2 Schema modes
The host chooses how strictly supplied data is checked:
strict: data that violates the schema fails the render.lenient(default): violations are warnings; the render continues.off: no checking.
At design time, a binding to a path the schema doesn't allow is a warning (bind/unknown-field), or an error in strict mode. If the schema allows additional properties, unknown paths are not reported.
10.3 Parameters
| Property | Type | Notes |
|---|---|---|
name | expression name (§11.4) | Read as params.<name>. ^[A-Za-z_][A-Za-z0-9_]*$ (0.2: was Id) |
type | string | number | integer | boolean | date | datetime | Required (0.1.2) |
label | Content | Prompt text |
description | string | |
default | Expr | May refer to now and earlier params |
required | bool | Default false |
multiple | bool | The value is a list |
options | { "value": any, "label": Content }[] or { "source": Expr, "value": Expr, "label": Expr } | Fixed list, or a list computed from a dataset or other params |
hidden | bool | Not shown in prompts; set by the host or by a parent subreport |
- Parameters are ordered. A parameter's
defaultandoptionsMAY refer to parameters earlier in the list, which gives cascading parameters. - Scopes (0.1.2): a
defaultsees earlier parameters,now,localeandtimezone. Computedoptionsadddata:sourceis evaluated in that scope, andvalueandlabelare evaluated once per item of the source, with the item asrow. A reference to a later parameter is anexpr/var-ordererror. None of them can usepage. - The
defaultof amultipleparameter MAY be one value or a list. (0.1.2) - Parameter values reach data sources through each dataset's
sourceParams(§10.4).
10.4 Data sources
A data source is a named, host-managed definition of where a dataset comes from: a connector, a query, credentials and per-environment settings. Documents refer to it by name and never see what's inside.
json
"data": {
"bill": {
"source": "billing.bill@2",
"sourceParams": { "account": "params.account", "period": "params.period" },
"schemaFrom": "source",
"sample": "samples/bill.json"
}
}- A
SourceRefis a dotted name with an optional version range:^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*(@[^\s]+)?$. Publishing a document pins the resolved version, as for libraries (§14.3). - The host publishes a source catalogue. Each entry has a name, description, version, typed parameters, an output JSON Schema and sample data. Designers and AI agents read the catalogue to offer sources, fill in
sourceParamsand get the schema without seeing queries. - At render time the host resolves the source for the current environment, calls it with the evaluated
sourceParams, and supplies the result as the dataset. - If the render request already includes data for a dataset, that data is used and its source is not called, unless host policy forbids supplying data for sourced datasets.
- A
sourceParamskey the source doesn't declare, a missing required source parameter, or a type mismatch is an error (bind/source-param). An unknown source isref/unknown-source. - Parameter
options(§10.3) MAY use a sourced dataset for their values, which gives database-driven picklists.
Informative: keeping queries in the host means the same report can run in dev, test and production against different databases, and business users and agents can change a report without being able to change what data it reads. How sources are defined (SQL, REST, GraphQL, files) may become a companion specification later.
11. Expressions
11.1 Language
Expressions use a profile of the Common Expression Language (CEL), plus the type and function library below. CEL is chosen because it is non-Turing-complete, side-effect-free, typed, and has maintained implementations in Rust, Go and JavaScript.
Profile rules:
- No user-defined functions and no unbounded loops. Comprehension macros (
all,exists,exists_one,map,filter) are allowed. - CEL's regular-expression function
matches()is not part of the profile. UseContains,StartsWithandEndsWith. (0.1.1) - The engine MUST enforce limits on evaluation cost and result size, and fail with
expr/limitwhen they are exceeded. §25.2 defines both. - The same expression with the same inputs MUST give the same result in every conforming engine. A shared conformance corpus (§26) checks this.
11.2 Types
CEL's types (null, bool, int, uint, double, string, bytes, list, map, timestamp, duration), plus:
decimal: an exact decimal number of up to 34 significant digits (IEEE 754 decimal128 precision). This avoids floating-point errors in money columns. (0.1.1)- Addition, subtraction and multiplication are exact whenever the exact result fits in 34 significant digits. Otherwise the result is rounded to 34 significant digits, half to even.
- Division gives the exact quotient when it fits in 34 significant digits, otherwise it is rounded to 34 significant digits, half to even.
- The number of decimal places kept (the quantum) is: for addition and subtraction, the larger of the two operands'; for multiplication, the sum of both; for division, the fewest that represent the result (
10 / 4is2.5,1 / 3is0.3333…to 34 digits). Rounding to 34 digits can reduce the places kept. - Values compare by numeric value, so
1.0 == 1.00.
date: a calendar date without time zone. A JSON string is read as adateif the schema says"format": "date", and as atimestampif it says"format": "date-time".
How JSON numbers in data are typed:
| Schema says | Value 12 | Value 12.50 |
|---|---|---|
"type": "integer" | int | error (bind/type-mismatch) |
"type": "number" | decimal | decimal |
"type": "number", "format": "double" | double | double |
| no schema | int | decimal |
The decimal keeps the value exactly as written in the JSON text, so 12.50 stays 12.50, not 12.5000000000000001. A "type": "number" field is always decimal, even when a row happens to hold a whole number, so a column's type doesn't change from row to row.
Numeric literals in expressions: 12 is int, 12.5 is decimal, 12.5d and 1e3 are double.
Dividing two ints gives a decimal, so 10 / 4 is 2.5. (Standard CEL truncates int division; business users expect spreadsheet behaviour.) % on ints still gives an int remainder with the sign of the dividend; Mod() takes the sign of the divisor, as spreadsheets do. (0.1.1) % on decimals is the same truncating remainder, x - Trunc(x / y) * y, so -7.5 % 2 is -1.5 (0.2: stated). A zero divisor is expr/division-by-zero.
Mixing types:
intwithdecimalpromotes todecimal. This is lossless. (Standard CEL rejects mixed arithmetic; this profile allows this one promotion.) The promoted int has no decimal places, so the quantum rules above apply as written:1 + 1.50is2.50, and2 * 1.50is3.00(0.2: stated).decimalwithdoubleis a type error. Convert explicitly withDecimal(x)orNumber(x). The type checker reports it at design time asexpr/mixed-precision.- Comparisons follow the same rules, so
Sum(rows.map(r, r.amount)) == 0.3compares decimals exactly.
Rounding: Round(x, digits) rounds half away from zero, as spreadsheets do (Round(2.675, 2) is 2.68). Round(x, digits, 'half-even') gives banker's rounding. Format rounds the same way as Round. A double is first converted to the shortest decimal that converts back to the same double, so Round(2.675d, 2) is also 2.68. (0.1.1)
Informative: both CEL engines need a decimal type added: a custom type in the Rust engine, and a library such as decimal.js behind the JavaScript one. The shared expression suite (§26) checks that they give identical results.
11.3 Scopes
| Name | Available | Meaning |
|---|---|---|
data | everywhere | The datasets |
params | everywhere | Parameter values |
vars | everywhere | Document and region variables (§11.4) |
now | everywhere | Render time, fixed for the whole render |
locale, timezone | everywhere | The effective locale and time zone: a node's lang, then meta.locale, then the render context (0.1.1) |
doc | everywhere | doc.id, doc.title, doc.version |
row alias (row by default) | inside a region's detail | The current item. Set with as |
item | inside a region's detail and repeated headers | { index, number, first, last, even, odd, continued } for the current item. continued is true only inside a header repeated on a continuation page (§16.8) (0.1.1) |
group | inside a group's header, footer or detail | { key, rows, index, count, level, parent }: index is the group's 0-based position among its siblings after the groups are sorted, count is the number of items in it, level is 1 for the outermost groups, and parent is the enclosing group or null (0.1.2) |
region | inside a region | { rows, count } after the region's filter, the viewer's filters, sort and limit, before grouping (0.1.2) |
props | inside a component | The component's props |
page | deferred contexts | See §9.2 |
fragment | a region's continuation slots | Items on this page and before it (§12.1) |
output | everywhere | { format }, e.g. 'pdf', so content can differ by format |
Region scopes nest. Inside a nested region, the outer region's row alias stays visible, and group.parent gives the enclosing group.
meta.locale, when present, takes precedence over the render context's locale, so a document with meta.locale formats the same for every reader. A document meant to follow the reader's locale leaves meta.locale out. (0.1.2)
Where the region names are visible (0.1.2):
- The row alias is visible where one item is being processed: the region's
filter,sortand per-itemvars, a group'sbyandsort, a viewer filter'sby, a repeat'sdetail, and a column'scell,cellChildren,rulesandsortBy. It is not visible in headers, footers,headerRows,footerRows,separator,emptyor the continuation slots. Matrix cells seecellinstead (§12.5). itemis visible in the detail and in the headers that repeat: a repeat'sheader, a table's column headers andheaderRows, group headers, and the continuation slots. Itsindex(from 0),number(from 1),first,last,oddandevenare positions within the innermost group, or within the region if it has no groups.groupis visible in group headers and footers, the detail, a group'ssortandvars, and the per-rowvarsof a repeat or table. A group'svarsjoinvarsin its headers and footers, in the groups nested in it and in the detail.- Per-row
vars, a region's own (§11.4), are visible where they are evaluated, for each item: the detail, and a column'scell,cellChildrenandrules. They are not visible in headers, footers,headerRows,footerRows, group bands,separator,empty, the continuation slots or the region's own box. There,vars.<name>is the group or document variable of that name, if there is one; a rule that reads a per-row variable those rows don't have is skipped (§8.5), and anything else reading it is aref/unknown-varerror. fragmentis visible only in the continuation slots.
page, fragment and item.continued are known only during layout, so they MUST NOT be used in properties that decide what is laid out: if, rules, a region's source, filter, sort, limit and group by, vars, and actions conditions. Doing so is an error (expr/page-not-allowed). (0.1.1)
The same holds for (0.1.2): the if of rows, columns, spans, footnotes and series; a chart's source, category and series value; a group's sort; a column's sortBy; a viewer filter's by; an action's initial, navigate and setParams; a component instance's props; a subreport's params and data; and parameter defaults and options and dataset sourceParams.
A chart is drawn once, whatever page it lands on, so its title, its axes' title and its series' name can't show page values either: page, fragment and item.continued there are expr/page-not-allowed too (0.1.2). Checkers report every case above, including a component instance's props (0.1.2).
11.4 Variables
json
"vars": {
"charges": "data.bill.lines.filter(l, l.kind == 'charge')",
"totalDue": "Sum(vars.charges.map(l, l.amount))"
}- Document
varsare evaluated once, in declaration order. A variable may use earlier ones; cycles are errors. - Data regions MAY declare their own
vars, evaluated per item or per group in that scope. - Inside a region, a name resolves to the per-row variable, then the innermost group's, then the enclosing groups', then the document's: a per-row variable hides a group or document variable of the same name, and its own type is the one checked. Each group's
varsare evaluated after the groups around it, so they may read those. (0.1.2) - A repeat's or table's per-row
varsare evaluated for each item after its groups', so they may read the row alias,item,groupand the groups' variables. A matrix's are evaluated for each source item before it is grouped, with the row alias only. (0.1.2) - A variable that reads a later one is an
expr/var-ordererror; a fix reorders the variables when there is no cycle. A cycle is anexpr/cycleerror, reported once. (0.1.2) - An error in a document variable is reported once, at
/vars/<name>, not again wherever the variable is read. (0.1.2)
Expression names (0.2). A name read as a bare identifier in an expression is an expression name, ^[A-Za-z_][A-Za-z0-9_]*$, not an Id: in CEL line-item.amount is a subtraction. That covers variable names, dataset names (data.<name>), parameter names (params.<name>) and row aliases (as). Kebab case stays for node ids, classes, tokens and parts.
- A row alias may not be one of the scope names of §11.3 (
data,params,vars,page,item,group,region,props,fragment,output,now,locale,timezone,doc,cell,series), which it would hide, nor a CEL literal, operator or reserved word (true,false,null,in,as,break,const,continue,else,for,function,if,import,let,loop,package,namespace,return,var,void,while). Either isschema/invalid-value. - A component prop whose name isn't an expression name can only be read as
props['due-date']. The props schema is JSON Schema, which allows any name, so it is aschema/invalid-valuewarning, not an error.
11.5 Function library
Function names are PascalCase, following spreadsheet conventions. Each function also works as a receiver call (x.Sum() for Sum(x)).
| Group | Functions |
|---|---|
| Aggregates | Sum, Count, Avg, Min, Max, First, Last, Distinct, CountDistinct, Median, Percentile(list, p) — each takes a list. Use CEL map to project fields: Sum(group.rows.map(r, r.amount)). Nulls are skipped, except by Count, which counts every item. Sum of an empty list is 0; Avg, Min, Max and Median of an empty list are null (0.1.1) |
| Running | RunningSum(expr), RunningCount(), RunningAvg(expr), Previous(expr) — over the current region in display order, reset at the start of each innermost group (0.1.2). Details below |
| Logic | If(cond, a, b), Switch(value, k1, v1, …, default), Coalesce(a, b, …), IsBlank(x) |
| Text | Upper, Lower, Proper, Trim, Left, Right, Mid, Len, Replace, Contains, StartsWith, EndsWith, Split, Join, PadLeft, PadRight |
| Number | Round(x, digits, mode?), RoundUp, RoundDown, Floor, Ceiling, Abs, Mod, Power, Sqrt |
| Date | Today(), Date(y, m, d), DateAdd(d, n, unit), DateDiff(a, b, unit), Year, Month, Day, Weekday (ISO 8601: 1 is Monday, 7 is Sunday (0.1.1)), EOMonth(d, n), StartOf(d, unit), ParseDate(s, pattern) |
| Lookup | Lookup(list, r, keyExpr, key) returns the first item r whose keyExpr equals key, or null; LookupAll(…) returns every match. Lookup(list, 'field', key) is shorthand for matching one field (0.1.1) |
| Navigation | report(id, params?) returns a target for another report with parameter values, for links and drill-through (§17.6.4); footnote(id) returns a footnote's marker |
| Format | Format(value, pattern, locale?) (§11.6) |
| Localisation | T(key, args?) reads i18n messages (§18.2) |
| Conversion | Number(x), Decimal(x), Text(x), ToDate(x) |
Extensions MAY register more functions under a namespace, e.g. acme.TaxCode(x).
Function details (0.2: stated):
Ifevaluates its condition, then only the branch it picks, soIf(true, 1, 1/0)is1. A condition that is an error makesIfthat error; one that isn't a bool isexpr/type.SwitchandCoalescestop at the first match the same way.- Aggregates.
CountDistinctskips nulls, as every aggregate butCountdoes (before 0.2 the reference engine counted null as a value).Median(list)isPercentile(list, 0.5).Percentile(list, p)takespfrom 0 to 1 and interpolates as spreadsheets'PERCENTILE.INCdoes: sorted valuesv, rankp × (n − 1), and the value betweenv[floor(rank)]and the next, proportionally. So the median of an even count is the mean of the two middle values. Apoutside 0 to 1 is an error value. - Dates.
DateAdd(d, n, unit)addsnunits:day,week,monthoryear(plural forms too) to a date, and alsohour,minuteandsecondto a timestamp; a month or year added to the 31st lands on the month's last day.DateDiff(a, b, unit)is the whole number of units fromatob, positive whenbis later and truncated towards zero:day,week,month,quarter,year, and for two timestamps alsohour,minuteandsecond.Today()is the calendar date ofnowin the effectivetimezone.ParseDateon text that doesn't match its pattern is an error value, not null.
Running functions (0.1.2):
- They are evaluated where a region processes its items: the detail, row cells, per-item
varsand the region's rules. In the footer of the innermost group, or of a region without groups, they give their value at the last item. - In
continuationHeaderandcontinuationFooterthey are deferred values: they run over every item placed up to the end of the current page, in display order, without group resets.Previous(x)there isxat the last of those items. - Anywhere else, including inside components, a call is an
expr/evaluationerror. - Each distinct call (function and argument text) keeps its own running state. Every item counts, even items on which the call wasn't evaluated, for example because an
ifhid it. - A call has one value per item: the state advances once for each item, whatever the number of places that write the same call text in it. Two cells of a row that both show
RunningSum(row.amount)show the same total (0.2: stated). RunningSumandRunningAvgskip nulls.Previousisnullon the first item of a group, or of a region without groups.
11.6 Conversion to text
Format(value, pattern, locale?) uses the locale from the argument, then the node's lang, then meta.locale, then the render context.
| Pattern | Example result (en-AU) |
|---|---|
'number', 'number:0', 'number:2' | 1,234.50 |
'currency', 'currency:EUR' | $1,234.50, €1,234.50 |
'percent', 'percent:1' | 12.5% |
'date:short' | 'date:medium' | 'date:long' | 'date:full' | 17/10/26, 17 Oct 2026, … |
'time:short', 'datetime:medium' | |
A spreadsheet-style pattern such as '#,##0.00;(#,##0.00)' or 'dd MMM yyyy' | Number patterns follow spreadsheet sections; date patterns follow Unicode CLDR |
The default currency for 'currency' is meta.currency. Without one, 'currency' with no code is an error value at render time; a checker reports it at design time as an expr/invalid-pattern warning, with the fix of naming a code ('currency:EUR') (0.2). 'percent' multiplies by 100, so Format(0.125, 'percent:1') is 12.5%. When a value is interpolated without Format, numbers use 'number' with as many decimals as the value has, dates use 'date:medium', and null gives an empty string.
11.7 Errors
- Reading a field that doesn't exist gives
null, not an error.IsBlankand CELhas()test for it. - Type errors, division by zero and limit errors produce an error value. The surrounding text or style is left out, and an
expr/*diagnostic is raised with the node id and expression. - The host's error policy decides whether any error-severity diagnostic fails the render (
fail) or lets it continue (continue). An engine's default isfail(0.2). Hosts SHOULD usefailfor published and production renders, so a bill can't go out with a blank amount, and MAY usecontinuefor previews and the designer, which show the diagnostics beside the output. - Under
continue, a failed interpolation inserts nothing and a failed style is left out, each with its diagnostic. - An
ifthat evaluates to an error counts asfalse, and raises its diagnostic like any other error, so underfailit fails the render.
12. Data regions
A data region repeats content for each item of a list. RFML has three: repeat (any content), table in bound form, and matrix (crosstab).
12.1 Shared region properties
| Property | Type | Notes |
|---|---|---|
source | Expr → list | Required. Evaluated in the enclosing scope |
as | expression name (§11.4) | Row alias, default row. Not a reserved name (0.2: was Id) |
filter | Expr → bool | Applied first |
sort | { "by": Expr, "direction"?: "asc" | "desc", "nulls"?: "first" | "last" }[] | Applied after filtering. Stable |
limit | integer | Applied after sorting |
groups | Group[] | Nested groups, outermost first (§12.4) |
vars | map of Expr | Per-item variables |
empty | Node[] | Rendered instead when there are no items |
filters | ViewerFilter[] | Filters the viewer offers to readers (§17.6.5) |
continuationFooter | Node[] | Row[] | Placed at the bottom of each page the region continues past, e.g. "Carried forward" |
continuationHeader | Node[] | Row[] | Placed at the top of each continuation page, after repeated headers, e.g. "Brought forward" |
orphans, widows | integer ≥ 1 | (0.2) repeat and bound table only. Items a page break must leave before it and after it; see below |
orphans and widows (0.2) do for a region's items what the style properties of the same name do for a paragraph's lines (§16.3). They count the items of the innermost group open across a break, or of the whole region when no group is. A break that leaves fewer than orphans of those items before it, or fewer than widows after it (with the group's or region's footer), breaks R1 and is taken only when R1 is relaxed. So "widows": 2 keeps a group's last two rows with its total. An item made of several boxes counts once. Unset, they don't apply; they are properties of the region, not style properties, so the lines inside each item keep their own orphans and widows.
Inside the continuation slots, fragment gives:
fragment.rows: the items placed on the current page.fragment.rowsBefore: all items placed on earlier pages.
So a page subtotal is Sum(fragment.rows.map(r, r.amount)) and a carried-forward total is Sum(fragment.rowsBefore.map(r, r.amount)) + Sum(fragment.rows.map(r, r.amount)). These are deferred values (§16.9).
In a continuationHeader, fragment.rows is empty, because the header is placed before the page's items are known; fragment.rowsBefore holds the items of earlier pages. In a continuationFooter, both are filled. (0.1.2)
An item whose row is split across a page end is placed on both pages, so it is in fragment.rows on each, and in fragment.rowsBefore only from the page after the one it ends on (0.2: stated). A carried-forward total therefore counts it once; per-page subtotals count it on both pages.
Processing details (0.1.2):
- Order. The region's
filterapplies first, then the viewer's filters (§17.6.5), thensort, thenlimit, then grouping. - Sorting is stable and never fails. Nulls sort last whatever the direction, unless
nullsisfirst. Values of different kinds sort by kind: null, booleans, numbers, strings, dates, timestamps, durations, bytes, then anything else. Numbers compare by value acrossint,decimalanddouble. emptyreplaces only the items. The region's header and footer, and a table's column header and footer rows, are still rendered.
12.2 repeat
| Property | Type | Notes |
|---|---|---|
header | Node[] | Before the items. Repeats on each page if repeatHeader is true |
detail | Node[] | Rendered for each item. Required |
footer | Node[] | After the items |
repeatHeader | bool | Default false |
separator | Node[] | Between items |
repeat has no box of its own. Its output joins the parent's flow, so a repeat inside a display: grid box produces grid cells (label sheets, card layouts).
12.3 table
A table with a source is a bound table:
| Property | Type | Notes |
|---|---|---|
columns | Column[] | Required |
repeatHeader | bool | Default true |
headerRows, footerRows | Row[] | Extra rows above the column headers or below the column footers |
Column = {
"id": Id,
"width"?: Length | "auto" | "<n>fr",
"header"?: Content,
"cell": Content | "cellChildren": Node[],
"footer"?: Content,
"sortable"?: bool, // readers can sort by this column in the viewer
"sortBy"?: Expr, // sort key; defaults to the cell's expression if it is a plain path
"if"?: Expr, // hide the whole column
"style"?, "class"?, "rules"? // apply to every cell in the column
}- Column footers form the table's footer row, evaluated in region scope (
region.rows). - Groups (§12.4) add group header and footer rows. In a table, a group's
headerandfooterare eitherRow[](cells aligned to the columns) orNode[](drawn in one cell spanning all columns). - Rows are kept whole when they fit on a page; how oversized rows split is defined in §16.4.
12.4 Groups
Group = {
"id": Id,
"by": Expr, // group key, in row scope
"sort"?: "asc" | "desc" | { "by": Expr, "direction"? }, // default: key ascending
"header"?: Node[] | Row[],
"footer"?: Node[] | Row[],
"breakBefore"?: "auto" | "page", // start each group on a new page
"keepTogether"?: bool, // avoid breaking a group when it fits on one page
"repeatHeader"?: bool, // repeat the group header after a page break
"vars"?: map of Expr // per-group variables
}Inside a group, group.rows holds that group's items, so subtotals are Sum(group.rows.map(r, r.amount)).
(0.1.2)
- Items with equal keys form one group. Numeric keys compare by value, so
1and1.00are the same group. Groups are first taken in the order their keys first appear, then sorted stably bysort, which defaults to the key ascending. Anullkey sorts last. breakBefore: pagestarts each of the region's groups at that level on a new page, except the first.- A group header is kept with the box after it, as with
break-after: avoid(§16.3). So is a region's header.
12.5 matrix
A crosstab: row groups down the side, column groups across the top, and aggregate cells.
| Property | Type | Notes |
|---|---|---|
rows | Group[] | Required. Outermost first |
columns | Group[] | Required. Outermost first |
cells | { "id", "value": Expr, "format"?: string, "style"?, "rules"? }[] | Evaluated with cell.rows, the items in that row/column intersection |
totals | { "rows"?: bool, "columns"?: bool, "label"?: Content } | Adds total rows/columns using the same cell expressions over the wider set |
corner | Content | Top-left header |
repeatRowHeaders, repeatColumnHeaders | bool | Repeat headers on each page when the matrix breaks horizontally or vertically. Default false (0.1.2) |
A matrix wider than the content area is split into column chunks. Pages run down, then across: every row of the first column chunk, then every row of the next chunk, and so on. (0.1.1)
- Each axis groups the region's items as §12.4 does, level by level, and has a line per innermost group. A group header sees
group(itsparentis the enclosing group on the same axis) and the group'svars; with noheader, it shows the group key. - Each cell is evaluated once per row line and column line, with
cell.rowsholding the items in both (possibly none).formatformats the value asFormat(value, format)does; typed outputs keep the value. Aformatthat isn't a valid pattern is anexpr/invalid-patternerror. (0.1.2) - Viewer filters apply to a matrix. Viewer sorting doesn't: a sort on a matrix is ignored, with an
interaction/unknown-targetwarning. (0.1.2) totals.rowsadds a subtotal line after the lines of each group that has inner groups, and a grand total line at the end;totals.columnsdoes the same for columns. A total's label (totals.label, defaultTotal) sits at the level below the group it totals and covers the inner levels; the grand total's covers every level. A total line'scell.rowsholds every item of the group it totals.- Each column chunk has every row and the column headers cut to its columns. With
repeatColumnHeaders, the column headers also repeat at the top of each continuation page. WithrepeatRowHeaders, the row headers repeat on each column chunk, and an outer row header shows again on the first row of a continuation page. - Columns take their max-content width. A matrix that fits isn't stretched to its container.
- In continuation slots,
fragment.rowsholds the items of the row lines placed on the page: every item of those lines, whichever column chunk the page shows, since a page of one chunk still carries whole row lines (0.2: stated). Total lines add no items. So a carried-forward total on a chunk's pages counts the same items as on the first chunk's.
13. Charts
Charts use a small, format-neutral model, so that the engine can draw them as vector graphics in PDF and HTML and as native, editable charts in XLSX and DOCX where possible.
| Property | Type | Notes |
|---|---|---|
source | Expr → list | Required |
as | expression name (§11.4) | Row alias (0.2: was Id) |
kind | column | bar | line | area | pie | donut | scatter | combo | Required |
category | Expr | Category (x) value per row |
series | Series[] | Required |
stacked | none | normal | percent | |
axes | { "x"?: Axis, "y"?: Axis, "y2"?: Axis } | |
legend | { "position": "top" | "bottom" | "left" | "right" | "none" } | |
labels | { "show": bool, "format"?: string } | Data labels |
title | Content | |
palette | var(--token)[] or colours | Default comes from the theme |
alt | Content | Text alternative; required for tagged PDF |
Series = { "id": Id, "name": Content, "value": Expr, "kind"?: chart kind (combo only),
"axis"?: "y" | "y2", "color"?: colour, "if"?: Expr }
Axis = { "title"?: Content, "format"?: string, "min"?: number, "max"?: number,
"gridlines"?: bool, "reverse"?: bool }width and height come from style; charts never break across pages.
What each kind reads (0.2: stated):
kind | category | series | stacked | axes |
|---|---|---|---|---|
column, bar, line, area | The x axis's values | Any number; axis: y2 puts one on the second value axis | Series of the same kind on the same axis stack | x, y, y2 |
combo | The x axis's values | Each series' kind (column, bar, line, area, scatter; default column) | As above, per kind | x, y, y2 |
scatter | The x value: a numeric axis when every category is a number, else a category axis | Any number, drawn as points | Ignored: points don't stack | x, y, y2 |
pie, donut | The slice labels, also listed in the legend | The first series only; its zero, negative and null values draw no slice | Ignored | Ignored |
(0.1.2)
- Without a
height, a chart is 0.6 times its width tall. A chart with no width from its style or its container (in flex and grid, for example) is 300pt wide. axes.xis always the category axis, andyandy2the value axes, also whenkind: bardraws the bars horizontally.- Without a
palette, series take the tokens--chart-1to--chart-12from the document or its theme, in order, up to the first one missing. With none of them, the engine's default palette is used. - To hide gridlines, use the axis's
gridlines: false. - A legend at the left or right takes at most 40% of the chart's width. A pie or donut's side legend may also take all the width its round plot doesn't need. Beside horizontal bars (
kind: bar), category labels take at most 40% of the width. - A legend label or a horizontal bar's category label longer than its room is shortened to end in "…". Labels stay on one line. The full names stay in the chart's data: in its
chart-pointboxes (§17.6.6) and in the native charts of DOCX and XLSX. - The
axisandgridlineparts have engine defaults (§8.1): axis linesstroke: #8c939d; stroke-width: 0.75pt, gridlinesstroke: #dde1e6; stroke-width: 0.5pt.stroke: noneon a chart part draws no line. A part that sets onlystrokekeeps the default width. (0.1.2) - A chart without
alt, or with an emptyalt, raises ana11y/missing-altwarning, which is an error under the PDF/UA standards at design time and in PDF renders (§17.1).
Escape hatch: a chart MAY set "vegaLite": { ... } with a Vega-Lite specification instead of kind/series. Vega-Lite is an optional feature of the Advanced level (§26) (0.2): it needs a JavaScript runtime that an engine need not carry. An engine that has it draws the chart as vector graphics in PDF and HTML and as an image in office outputs, with an output/chart-not-native info diagnostic. An engine without it, the reference engine among them, puts a placeholder box of the chart's size in its place and raises output/not-implemented (info). Charts written with kind and series draw in every engine.
14. Components and subreports
14.1 Definitions
A component is a reusable fragment with typed props and named slots.
json
"components": {
"remittance-slip": {
"description": "Tear-off payment slip with barcode and amount due.",
"props": {
"type": "object",
"required": ["reference", "amount"],
"properties": {
"reference": { "type": "string", "description": "Payment reference" },
"amount": { "type": "number" },
"dueDate": { "type": "string", "format": "date" }
}
},
"slots": ["notes"],
"template": [ /* Node[] using props.* and { "type": "slot", "name": "notes" } */ ]
}
}propsis a JSON Schema. The designer builds its property panel from it, and agents read it as the component's contract.- In a component template, expressions see
propsplus the document-wide scopes. A component cannot see the row aliases where it is used unless they are passed in as props. { "type": "slot", "name": ..., "children"?: Node[] }marks where caller content goes;childrenis the default content.partsMAY map part names to node ids in the template, so themes and classes can style those nodes from outside (§8.6).
14.2 Instances
json
{ "type": "use", "id": "slip", "component": "remittance-slip",
"props": { "reference": "data.bill.paymentRef", "amount": "vars.totalDue" },
"slots": { "notes": [ { "type": "text", "id": "slip-note", "content": "Pay by direct debit to avoid fees." } ] } }- Each prop value is an
Expr, evaluated where the component is used, and checked against the prop's schema. - Ids inside a component template are scoped to it. In the layout model and diagnostics, an inner node is addressed as
<instance-id>/<inner-id>. - An instance can have
class,styleandrules, which apply to the component's root nodes.
(0.1.2)
- Props are evaluated in the caller's scope. A prop the instance leaves out takes the schema's
default, if it has one. Values are bound through the props schema as datasets are inlenientmode (§10.2): a mismatch is abind/type-mismatchwarning at/props/<name>, and a string with"format": "date"becomes adate. - The template sees the document-wide scopes and
props. Slot content is evaluated in the caller's scope, as if it were written where the instance is. - A component that uses itself, directly or through other components, is a
ref/cycleerror. - An inner node's instance key is the instance's own key (§22.3): the node
slip/slip-amounthas the keyslip, ortable-x[2]/slipwhen the instance repeats in a region.
(0.2)
- An instance adds no box of its own. Its component's root nodes are laid out where the instance is: in the parent's flow, or as the parent's flex or grid items or a frame's children, so a root stretches to its grid row as any item does. Slot content at a template's root is a root too.
- The instance's
class,styleandrulesapply to each root after the root's own, so they win where both set a property. An instance at the root of another component's template passes on what it gets: the outermost instance's apply last. What the instance itself would inherit, its roots inherit. - The layout model still has a box for the instance, for hit-testing (§22.2).
- So on a component with several roots, a margin, padding, border, outline, background or break set on the instance is drawn or applied once per root:
break-before: pagebreaks before each root, and a border draws round each. A checker warns withschema/per-root-stylewhen an instance of a multi-root component sets one of those in its ownstyleorrules(0.2). A component that should be one card, take one break or carry one stamp has a single root box, with everything else inside it; the payment slip of §28 has one frame root for that reason.
14.3 Libraries
json
"imports": [
{ "from": "library:billing", "version": "^2.1", "components": ["remittance-slip", "usage-chart"] },
{ "from": "library:corporate-theme", "version": "2.0.3", "theme": true }
]- Library references are resolved and pinned by the host. A published document MUST record the exact resolved versions.
- How libraries are packaged and stored (their manifest and file layout) is host-defined. (0.1.1)
- Local components override imported ones with the same name, with a
schema/component-shadowedwarning.
14.4 subreport
Embeds another RFML document in the current page flow.
| Property | Type | Notes |
|---|---|---|
report | string | Document reference, e.g. "library:statements/line-items" |
version | semver range | Pinned at publish |
params | map of Expr | Values for the child's parameters |
data | map of Expr | Values for the child's datasets, usually slices of the parent's data |
The child uses the parent's pages; its own pages, outputs and tests are ignored when it is embedded. Its styles apply only inside it. A subreport inside a region renders once per item.
(0.1.2)
- The child's
bodyis laid out in place of thesubreportnode. Its root style is its own theme andstyles.defaults.documentover the subreport node's style. paramsanddataare evaluated in the caller's scope. The values indataare the child's datasets as they are; they are not bound again.paramsare bound against the child's parameter declarations. The child evaluates its ownvars, and seesdocas itself.now,timezoneandoutputare the parent's; the child formats with its ownmeta.locale, else the parent's locale.Tin the child looks for each key in the child'si18nfirst, then the parent's (§18.2) (0.1.2).- A
paramskey the child doesn't declare isref/unknown-paramat/params/<name>, and adatakey it doesn't declare isref/unknown-datasetat/data/<name>. A required child parameter without a default thatparamsleaves out isschema/missing-propertyat/params. The child's own errors are reported on the subreport node at/report, with the inner node and path in the message. - Inner nodes are addressed
<subreport-id>/<inner-id>, as component internals are. - A document that embeds itself, directly or through other subreports, is a
ref/cycleerror. Nesting deeper than the limit (§25.2) stops the render withsecurity/limit.
15. Assets and fonts
json
"assets": {
"logo": { "kind": "image", "src": "library:brand/logo.svg" },
"inter": { "kind": "font", "family": "Inter", "src": "library:fonts/inter-variable.ttf",
"weight": "100 900", "style": "normal" },
"inter-italic": { "kind": "font", "family": "Inter", "src": "library:fonts/inter-italic.ttf", "style": "italic" }
}kindisimage,fontorfile. Afileasset is any other file, such as one attached to a PDF/A-3 output (§17.1). An asset MAY have adescription, which such an attachment carries. (0.1.2)srcis a host library reference, a relative path inside a document bundle, or adata:URL. Remotehttp(s)URLs MUST be disabled unless the host allows them.- Image formats: PNG, JPEG, SVG (static, no scripts), WebP.
- Font formats: TTF, OTF, WOFF2. Engines MUST embed fonts used in PDF output, subsetting allowed.
font-familyin styles refers to thefamilyof font assets, the theme's fonts, or the engine's bundled fallback fonts.
Bundled fonts and fallback (0.2: named):
- An engine bundles four families: Noto Sans, Arimo, Noto Sans Arabic and Noto Sans Mono. The conformance suite's layout cases (§26) are measured with these files (and the test font Ahem), so engines that bundle them lay the cases out alike.
- Text falls back, glyph by glyph, through its
font-familylist, then through those four in that order (§16.1). - Generic and common names stand in for a bundled family when no font has that name:
sans-serif,serif,system-ui,ui-sans-serif,ui-serif,cursiveandfantasyfor Noto Sans;monospaceandui-monospacefor Noto Sans Mono;Helvetica,Helvetica Neue,ArialandLiberation Sansfor Arimo, which has their metrics;CourierandCourier Newfor Noto Sans Mono. - A family in a
font-familyvalue that matches no font asset, no theme or render font, no bundled family and none of those names raisesref/unknown-font(warning) at the declaration (0.2): its text falls back and reflows, which is easy to miss. A value that reads a token isn't checked.
16. Layout and pagination
Layout has two parts. Formatting decides the size and position of boxes within an area, and follows CSS by reference. Fragmentation decides where content breaks between pages, and is defined by RFML itself, because CSS leaves fragmentation of flex and grid layouts loosely specified and browsers differ.
16.1 Formatting
Formatting follows these specifications, limited to the properties and values in §8.4:
- CSS 2.2 visual formatting model (block and inline formatting contexts)
- CSS Box Model Level 3
- CSS Flexible Box Layout Level 1
- CSS Grid Layout Level 1 (no subgrid, no masonry)
- CSS Text Level 3 and CSS Inline Layout Level 3, for line breaking, alignment and white space
RFML differs from CSS in these places, to keep layout predictable for designers:
| Topic | RFML rule |
|---|---|
| Margin collapsing | Margins never collapse. Vertical space between siblings is the sum of their margins. |
Floats, position | No floats, and none planned: use flex or grid to put text beside an image. Absolute positioning only for a frame's children (§6.12) and the children of a page background, overlay (§9.1) or pagestamp (§6.13), which are positioned against the page (0.2: stated). |
| Percent heights | Resolve against a frame's definite height, otherwise against the page content area. Inside a table cell, whose height is never definite, a percentage height is auto (0.2). |
line-height: normal | Exactly 1.2 × font-size, not taken from font metrics, so line heights don't change when a font is substituted. |
| Inline box metrics | Ascent and descent come from the font's OS/2 sTypoAscender/sTypoDescender. The font is the inline box's primary font, the first available one in its font-family list, even where glyphs come from fallback fonts, so fallback never changes line heights. (0.1.2) |
| Text shaping | OpenType shaping with kerning and standard ligatures, HarfBuzz-compatible. No estimated text widths. |
| Line breaking | Unicode UAX #14; hyphenation with hyphens: auto when the locale has patterns. |
| Bidirectional text | Unicode UAX #9. direction: rtl mirrors the inline axis as CSS does: lines start on the right, an over-constrained block keeps its right margin, list markers hang on the right, and table, flex and grid columns run from the right. Physical properties (margin-left, padding-right, borders, frame insets) stay physical; the logical sides follow the direction (§8.4) (0.2). lang doesn't set direction, as in CSS. |
| Font fallback | The listed font-family order, then the engine's bundled fallback fonts (§15). Never system fonts. |
| Heights (0.1.2) | A box with a definite height is monolithic (§16.2); content that doesn't fit follows its overflow. min-height applies only to a box that isn't broken across pages. |
| Lists (0.1.2) | Lists have no default padding. A marker hangs outside its item, 0.5em before the item's content box (after it in right-to-left text), so it sits in whatever padding the list or item has. The marker's text takes the marker part's style (§6.4); the 0.5em and the side are the item's. Its baseline is the item's first baseline. |
| Multi-column (0.1.2) | Column properties apply only to display: block boxes; on flex and grid boxes they are ignored, as in CSS. |
16.2 Fragmentation model
Pages are laid out one at a time. Each page's content area comes from its template and variant (§16.6), so its width can differ from page to page. Content is formatted into the current content area until it overflows, then broken at a break point chosen by the rules below, and the rest continues on the next page.
Break opportunities are the only places content may break:
| Class | Where |
|---|---|
| A | Between adjacent block siblings in a block or column flex flow |
| B | Between two lines of a paragraph |
| C | Between rows: table rows, grid rows, region items, wrapped flex lines |
| D | Inside a row, when the row is breakable (§16.4) |
Monolithic boxes have no break opportunities inside them: image, chart, barcode, frame, rule, each single line of text, and any element with break-inside: avoid while it fits on an empty page. So are a box with a definite height (other than a table row or cell), a table of contents entry, and a placeholder. (0.1.2)
Forced breaks come from break-before: page, break-after: page, pagebreak, a group's breakBefore: page, and a change of page template (§9.3). left and right force a break and then, if needed, a blank page so the content starts on a left or right page; the blank page uses the template's blank variant.
A blank page is a page like any other (0.2: stated): it takes the next page.number, counts in page.count and in its section's page.sectionCount, and has its variant's header, footer, background and overlay. It holds no content, footnote area or page stamps; footnotes and running values carry on to the page after it.
16.3 Choosing the break point
When content overflows the content area:
Find the last break opportunity before the overflow point.
Check it against these rules:
- R1 Orphans and widows. A paragraph split leaves at least
orphanslines before andwidowslines after (default 2 each). - R2 Keep with neighbour. No break between boxes where the earlier one has
break-after: avoidor the later one hasbreak-before: avoid. Headings havebreak-after: avoidby default, as an engine default (§8.1), so an explicitbreak-after: autoon a heading turns the keep off (0.1.2). A region's header and a group's header are kept with the box that follows them in the same way. (0.1.2) A run of such headers, such as a region header followed by a group header, stays whole with the box after the last of them: no break falls inside the run or right after it (0.1.2). - R3 Keep together. No break inside an element with
break-inside: avoid, a group withkeepTogether, or a region item.
- R1 Orphans and widows. A paragraph split leaves at least
If the break point breaks a rule, try earlier opportunities in turn. Take the latest one that breaks no rule, provided at least one line or monolithic box stays on the page.
If none qualifies, relax rules in this order and repeat step 3: first R1, then R2, then R3 starting from the innermost element. Each relaxation raises a
layout/forced-breakwarning naming the rule and node.R3 is relaxed one keep-together element at a time (0.1.1). A break opportunity inside n nested keep-together elements breaks R3 n times. The k-th R3 relaxation allows breaking inside the k innermost of them, but not the ones outside those.
If there is still no break point, break at the overflow point, between lines. A monolithic box is never sliced across pages: if it is taller than an empty page it is handled as in §16.7. (0.1.1)
The result is greedy and deterministic: each page is filled as far as the rules allow, with no look-ahead or global optimisation. Authors can predict where breaks will fall, and a change late in the document never moves earlier breaks.
Progress rule. Every page holds at least one line or one monolithic box, even when it overflows, so pagination always ends.
A page stamp (§6.13) is a point, not a box in the flow: it adds no break opportunity, takes no space and is not a unit of progress (§16.13).
16.4 Rows and items
- A table row, grid row or wrapped flex line is placed whole when it fits on the remaining page.
- A row that doesn't fit moves to the next page whole, unless it is taller than an empty page or has
break-inside: autoset on it explicitly. - When a row breaks, each cell or item breaks independently at its own best break point, and the row's height on each page is that of its tallest fragment.
- Table rows joined by a
rowspanare one unit (0.2: stated): the unit is placed whole, with break opportunities only before and after it, and a spanning cell is as tall as the rows it covers. A unit that doesn't fit moves to the next page whole. Only a single row breaks inside; a unit of several rows taller than an empty page is placed whole at the top of a page and handled as oversized (§16.7), withlayout/overflow. - A region item (§12) behaves like a row: kept whole when it fits on an empty page.
16.5 Edges of fragments
- A margin that meets a break is dropped: the content on the next page starts at the top of the content area, without the margin.
box-decoration-break: slice(default) splits a box's border, padding and background at the break, as if cut with scissors.clonedraws the full border, padding and rounded corners on each fragment, which suits panels and cards that run across pages.
16.6 Page templates
Each page takes its template from the section in progress (§9.3) and the matching variant (§9.1). Variants are matched in the order blank, last, first, left/right, so on a section of one page last wins over first (0.1.2).
The content area is the page minus margins, then minus the measured height of the header and footer. Because the last variant can only be known after pagination, pages are first laid out with last treated as not matching. If the final page uses a last variant with a different content area, that page is laid out again; if its content no longer fits, the extra content goes on a new page that then becomes the last. The page it overflowed from keeps its own variant, and its break point is not revisited. The new page is laid out with the last variant, and this repeats until the final page fits. (0.1.1)
16.7 Oversized content
Monolithic boxes (§16.2) are never sliced across pages (0.1.1). A kept-together element that doesn't fit an empty page is not monolithic: it breaks where §16.3 step 4 relaxes R3 (0.1.2). Likewise a single table row taller than an empty page breaks as in §16.4. A monolithic box taller than an empty page's content area is handled by its overflow style:
visible(default): the box is placed at the top of a page and drawn past the bottom margin, with alayout/overflowerror.clip: the box is clipped to the content area, with alayout/overflowerror.
A box wider than its container follows the same overflow rule horizontally.
16.8 Repeating headers and continuation
When a table, region or group continues on a new page:
- Its repeating headers are placed at the top of the content area, outermost first: the table header or region
header(withrepeatHeader), then each open group's header (withrepeatHeader). - Then its
continuationHeader, if it has one (§12.1). - On the page it is leaving, its
continuationFooteris placed below its last item on that page.
The space for continuation footers is reserved while filling the page, so a page doesn't end up too full to hold one. Inside repeated headers, item.continued is true. If the repeating headers alone don't fit on an empty page, they are left out on continuation pages, with a layout/header-too-tall warning.
Repeated headers and continuation slots are placed whole, with no breaks inside them, and they don't count towards the progress rule (§16.3). The space reserved for a continuation footer is measured with fragment set to null. (0.1.2)
Both continuation slots are placed as repeated content: in tagged output they are Pagination artifacts (§17.1), they get no bookmarks, and the table of contents lists no heading inside them (0.1.2).
16.9 Deferred page values
Expressions using page.* or fragment.* are filled in after pagination. If the filled-in text changes the layout (for example "Page 9 of 10" becoming "Page 10 of 10"), the engine re-lays out from the first affected page, up to 3 passes, then raises layout/unstable-page-values and keeps the last result.
(0.1.2) In the first pass, page.count and page.sectionCount are null, and table of contents page numbers are blank. A pass is final when the values it read are the ones it produced: page and section counts, running values per page, footnote markers, table of contents page numbers and header heights. layout/unstable-page-values is a warning.
(0.1.2) A deferred value is evaluated in the scope it was met in. Inside a subreport that includes the child's locale and messages: T reads the child's i18n first, then the parent's, as it does for values evaluated before layout (§14.4).
16.10 Determinism and conformance
- For a given engine version, the same inputs MUST produce the same layout model, on any platform.
- Engines MUST NOT use system fonts, the system locale or the system clock. Everything comes from the document, the data, the assets and the render context.
- Layout conformance tests (§26) compare layout models: every box MUST be on the same page as expected, and its position and size MUST be within ±0.5pt. The cases are in
conformance/layout/of the reference implementation, each a document with its data (case.json) and the expected layout model (layout.json), laid out with the bundled fonts of §15 (0.2).
16.11 Footnotes
- A footnote's body goes in the footnote area at the bottom of the page where its marker's line is placed. The footnote area grows upward from the bottom of the content area and shrinks the space left for content.
- If a line's footnotes don't fit on the page together with the line, the line moves to the next page, as long as the progress rule (§16.3) allows it.
- If a single footnote is taller than the space left, or the area would exceed
footnotes.maxHeight, the footnote body breaks and continues at the top of the next page's footnote area, with alayout/footnote-splitinfo diagnostic. - Footnotes in a multi-column box go in the page's footnote area, across the full content width.
- In a
frame, footnote markers are allowed, and their bodies go in the footnote area of the frame's page. - A footnote MUST NOT be inside a page template's
header,footer,backgroundoroverlay(0.2), its own or a variant's, including content passed to a component's slot there. Checkers report it as aschema/not-allowed-hereerror at the content that holds it. (0.1.2) A component used in a band whose template has a footnote, directly or through components it uses, is the same error, at the instance'scomponent(0.1.2). A footnote that still reaches a band (through a component the checker couldn't resolve) is left out when the band is laid out, with anoutput/unsupportedinfo diagnostic. (0.1.2) - Markers are numbered in document order, starting again at the start of each document, section or page as
footnotes.restartsays. Footnotes that share anidand have the same text share one marker within that scope. (0.1.2) - When footnote bodies are still waiting at the end of the content, they continue on pages that hold only footnotes. (0.1.2)
16.12 Multi-column flow
A box with columns (or column-count / column-width) set lays out its children in columns:
- Content fills the first column to the bottom of the available space, then the next column, then continues in the first column of the next page. Each column is a fragmentation container, and the break rules in §16.3 apply inside it.
break-before: columnandbreak-after: columnforce a column break. - With
column-fill: balance(default), the columns on the box's last page are balanced to roughly equal height. Withauto, columns fill one after another. - A child with
column-span: allinterrupts the columns, spans the full width, and the columns resume below it. - Multi-column boxes cannot be nested inside each other (
layout/nested-columnserror). The inner box is laid out as a single column (0.1.2). Tables, regions, frames and images are allowed inside columns and break normally. column-ruledraws a line between columns that both have content.
Informative: the reference engine can build formatting on an existing Rust CSS layout library (for example Taffy, for block, flex and grid) and a text shaping and line-breaking library, and implement §16.2–16.9 on top. The same engine compiled to WebAssembly gives the designer page-exact previews.
16.13 Page stamps
(0.2)
- A
pagestamp(§6.13) is a point in the flow of zero size. It is never a break opportunity: a stamp before a box goes with that box, so a stamp at the top of a row lands on the page where the row starts, and a stamp after the last box of its container stays with that box. A stamp in a container that holds nothing else lands where the container starts. Stamps change no other box's position. - A stamp's point is registered when the box it goes with is placed on a page for good, as running values are (§9.4), not while content is measured or a break is being chosen. A repeated header or continuation slot registers its stamps again on each page it is placed on.
- After a page's content is fixed, the first instance of each stamp node registered on the page, in placement order (every instance, for a stamp with
once: item(0.2)), is laid out against the page, as a page overlay is (§9.1), with the values its point had (item.continuedin a repeated header,fragmentin a continuation slot). Its children are clipped to the page, and a child that overflows it raises alayout/frame-overflowwarning, once per render. - Stamps belong to the layout of the page that registered them. A page laid out again (the
lastvariant, §16.6) keeps only the stamps of the layout it keeps. Stamps never move a break, so they add no layout passes (§16.9). - Painting order, from the top: the overlay, then stamps in
z-indexorder (placement order among equals), then the footer, the footnote area, the content, the header and the background.
17. Outputs
17.1 PDF
The reference output. Settings in outputs.pdf:
| Property | Values |
|---|---|
standard | none | PDF/A-2b | PDF/A-3b | PDF/UA-1 | PDF/A-2a+UA-1 |
tagged | bool, default true |
compress, embedFonts | bool |
metadata | { "author"?, "subject"?, "keywords"? } (title comes from meta.title) |
attachments | asset ids, of kind: "file" assets or images (§15). Embedded only with PDF/A-3b (0.1.2) |
view | A fixed view state for this output (§17.6.1) (0.1.2) |
Standards (0.1.2):
- The PDF's language is
meta.locale, or the render context's locale when the document has none. PDF/UA-1andPDF/A-2a+UA-1always produce a tagged PDF. Settingtagged: falsewith either is an error (a11y/untagged-pdf). With either standard, these are also errors: nometa.title, or a blank one (a11y/missing-title); nometa.localeand no locale in the render context (a11y/missing-lang); an image or chart without alt text that isn'tdecorative, or a field image without alt text (a11y/missing-alt). A field image has nodecorative: it always needsalt(§7) (0.2: stated). A barcode's alt text defaults to its encoded value (§6.9).- The standard is a PDF one, so
a11y/missing-title,a11y/missing-lang,a11y/untagged-pdfand thea11y/missing-alterrors it adds are errors only at design time (validation and publishing, where no format is chosen) and in PDF renders. A render in any other format reports them as warnings and isn't failed by them. (0.1.2) - Any other requirement of the standard that the output can't meet is an
output/standarderror. When the error policy iscontinue(§11.7), the PDF is written without the standard, and anoutput/standarderror at/outputs/pdf/standardsays so. attachmentswith a standard other thanPDF/A-3bare left out, with anoutput/unsupportedwarning. An attachment whose asset has no bytes is left out with aref/unknown-assetwarning.
17.2 DOCX
DOCX is written as a flowing Word document generated from the instance tree, not as a copy of the PDF pages:
- Page templates become Word sections, with headers, footers, size and margins. Word's footer distance is the template's
footerDistance(§9.1) (0.1.2). textandheadingbecome paragraphs with styles;tablebecomes a Word table with a repeating header row;listbecomes a Word list.- Charts become native Word charts where the chart model allows, otherwise images.
framebecomes an anchored drawing group or a table approximation; emitters MUST report which they used.- Multi-column boxes become Word sections with columns. Footnotes become native Word footnotes, with the same numbering settings.
- Word does its own pagination, so page breaks will not match the PDF exactly. Explicit page breaks and
break-before: pageare kept.
Mapping details (0.1.2):
page.number,page.countandpage.sectionCountbecome Word fields, so Word fills them in. Any other value that depends on the page is written as it was on the page where the node was laid out, with anoutput/unsupportedwarning. That holds for property values too (a link'shref, an image'ssrc, a barcode'svalue), not only for text (0.1.2).- Variants:
firstbecomes Word's first-page header and footer, andleftandrightits even- and odd-page ones. Word has no last-page header or footer, so the bands of alastvariant are placed as floating content where they were laid out on the last page, with anoutput/variant-unsupportedwarning. Ablankvariant, and a variant's ownmargin, are left out with the same warning. - A frame becomes an anchored drawing group (
output/frame-anchored, info). A frame inside a frame is written as flowing content, with anoutput/frame-anchoredwarning. - A page template's
backgroundis drawn behind the text from the section's header. - A page template's
overlayis drawn in front of the text from the section's header, as shapes anchored there, the way Word places watermarks (0.2). Word repeats a header's content unchanged, so the overlay is laid out once per header, as on the first page that header serves; page fields still update. The emitter reports this with anoutput/overlay-anchoredinfo diagnostic. - A
pagestamp(§6.13) becomes an anchored drawing group in a paragraph of its own where the stamp sits, in front of the text and without text wrapping, its children as text boxes and pictures laid out as on the page the layout drew it on (0.2). Word draws it on whichever page it puts that paragraph, which is the stamp's meaning even where Word breaks pages differently. Word can't draw only the first instance on a page, so only the instances the layout drew are written. In a table cell Word positions a drawing from the cell's corner even when it is anchored to the page, so there the group is anchored from the cell, with offsets from the layout, and anoutput/frame-anchoredinfo diagnostic says it moves with the cell. A stamp inside a frame is aschema/not-allowed-hereerror (§6.13); one that still reaches a text box (through a component the checker couldn't resolve) is written as flowing content, with anoutput/frame-anchoredwarning (0.2: stated as recovery). - A
setParamsaction (§17.6) has nothing to act on in a Word document, so it is left out with anoutput/unsupportedinfo diagnostic; PDF does the same (0.2). text-fit(§8.4) is left out, with anoutput/unsupportedinfo diagnostic: Word can't shrink text to fit (0.2).- A group's
repeatHeaderhas no Word equivalent. The header is written once, with anoutput/unsupportedinfo diagnostic. A table's own header rows repeat. - A box laid out as a row (a flex row, or a grid of several columns) becomes a borderless table with a cell per child. A child with a background or borders that stretches to its row (its
heightisautoand it is alignedstretch) fills its cell: its background, borders and padding go on the cell, so it is as tall as the row, as in the layout. The space between such children is then spacer columns, and the box's padding and row gaps spacer rows of their exact height. Other decorated children are a one-cell table inside their cell. A component instance's roots are the row's children (§14.2) (0.2). - A decorated span or link (§8.4.1) becomes run shading for its
background-colorand a run border for its border. Word has one border per run, so the first side that is drawn (top, left, bottom, right) stands for all four. A span inside a decorated span keeps the outer one's shading and border unless it has its own. Word has no run padding or corner radius: they are left out, with anoutput/unsupportedinfo diagnostic on the node.
17.3 XLSX
XLSX is written from data regions, not page layout:
- By default each top-level bound
table,repeatandmatrixbecomes a worksheet, named from itsmeta.labelor id. - Cells hold typed values (numbers, dates, booleans) with number formats derived from the format pattern, not formatted text.
- Group headers and footers become rows, with outline levels for grouping.
- Charts become native Excel charts.
- Content outside regions is written above the first region on the first sheet, or skipped, according to
outputs.xlsx.otherContent(top \| skip). - Footnotes on cells become cell notes. Other footnotes are listed under the sheet's last region.
outputs.xlsx.sheets can set the mapping explicitly:
json
"xlsx": { "sheets": [ { "name": "Charges", "from": "table-charges", "freeze": "A2", "autoFilter": true } ] }Mapping details (0.1.2):
- Which regions. Without
sheets, every boundtable,repeatandmatrixthat is not inside another region becomes a sheet, in document order, wherever it sits (in boxes, frames, components or subreports). A region inside another region is written as rows of the outer region's sheet. Withsheets, only the listed regions become sheets, in list order; other regions count as content outside regions. A document with no regions gives one sheet named aftermeta.title. - Sheet names. A sheet's
name, else the region'sexport.xlsx.sheethint, else itsmeta.label, else its id. Names are made valid for Excel (at most 31 characters, none of[]:*?/\) and unique, ignoring case. - Typed cells. A cell whose content is one interpolation (ignoring whitespace around it) holds the value itself: a number, a date, a timestamp in the render context's time zone, or a boolean when no
Formatpattern is applied. A decimal that a spreadsheet number can't hold exactly is written as text. Anything else is the formatted text. Number formats come from theFormatpattern, in the effective locale (§11.3); a pattern Excel can't express gives text. - Freeze. A region with a repeating header freezes the rows above its first item.
freezenames a cell counted from the region's top-left cell. A freeze is dropped when other content is written above the region. A matrix always freezes its header rows and columns. Afreezethe sheet sets that is dropped, or that isn't a cell reference, raises anoutput/unsupportedwarning at/outputs/xlsx/sheets/<i>/freeze; the implicit freeze of a repeating header is dropped silently. (0.1.2) - Unmatched sheets. A
sheetsentry whosefromis no boundtable,repeatormatrixof the render gives no sheet, with anoutput/sheet-unmatchedwarning at/outputs/xlsx/sheets/<i>/from. Afromthat names no node at all is aref/unknown-nodeerror at design time. (0.1.2) - Filters and outlines.
autoFilter: true, or avaluesviewer filter on the region (§17.6.5), puts an AutoFilter over the region's header and rows. Each group level adds one outline level around its members; its header and footer rows stay at the outer level. - Matrices. A matrix is one sheet. Column headers are merged across their columns; row headers are not merged down, so they stay readable in a collapsed outline, and a subtotal line repeats its group's header. Outer row and column groups become outline groups.
- Footnotes. Footnotes in table cells and repeat items become cell notes. Other footnotes keep their marker in the text and are listed below the sheet's last row, after one blank row.
- Inline decoration. A span's or link's background, border and padding are left out, without a diagnostic; cells keep their text properties.
- Overlays. A worksheet has no pages to stamp, so a page template's
overlayis left out. When an overlay child would show, anoutput/unsupportedwarning at the template'soverlaysays so, once per template and variant. (0.2) - Page stamps. A
pagestampis left out, with anoutput/unsupportedwarning once per stamp node that shows: a stamp is content, so losing it warns as losing an overlay does. (0.2) - Repeats. A
repeatsheet has no columns of its own (0.2: stated). Each of its rows (the header, each item, each group band, the footer) is one sheet row, and the row's leaf content (its texts, headings, barcodes and the like, in document order) fills the columns from the left. A repeat whose items hold one text each is a one-column list; for real columns use a boundtable.
17.4 Export hints
Any node MAY set export, keyed by format:
json
"export": { "xlsx": { "skip": true }, "docx": { "keepWithNext": true } }Standard hint keys are listed per format in the JSON Schema. Unknown hint keys are warnings.
17.5 HTML
HTML output is either paged (pages as fixed-size boxes, matching PDF) or flow (a responsive document without pages). Both are interactive when shown in a viewer (§17.6).
(0.1.2)
- The two are separate output formats,
html-pagedandhtml-flow. Both are settled byoutputs.html. - The output contains no scripts and allows none. Links follow §25.1, and a rejected link raises a
security/link-rejectedwarning. - In
paged, decorated inline boxes are drawn as the PDF draws them. Inflow, they are CSS inline boxes the browser lays out, with the same padding, borders and slicing (§8.4.1). flowhas no pages. A value that depends on the page is written as it was on the page where the node was laid out, with anoutput/unsupportedwarning, property values (a link'shref, an image'ssrc, a barcode'svalue) as well as text (0.1.2). Footnotes are numbered through the whole document.- In
flow, a component instance writes no element of its own: its roots are written where it is, with its style (§14.2). An instance with actions is an element ofdisplay: contentsthat carries them. (0.2) - In
paged, a page'soverlayis a positioned layer above the rest of the page, hidden from screen readers as other artifacts are.flowleaves page templates out, the overlay included; when an overlay child would show, anoutput/unsupportedwarning says so, as in XLSX (§17.3). (0.2) - In
paged, a page stamp is drawn as the PDF draws it. It is content, so screen readers read it, unless it isdecorative.flowhas no pages: a stamp's children are shown where the stamp sits, unpositioned, with anoutput/unsupportedinfo diagnostic once per stamp node; a decorative stamp isaria-hidden. (0.2) - In
paged,text-fittext is drawn at the size the layout chose.flowcan't measure, so it sets the text at its full size, with anoutput/unsupportedinfo diagnostic (0.2).
17.6 Interactivity
17.6.1 Model
Interactions never run scripts. Each action changes the view state, and the viewer renders the document again with the new state, either on the server or with the engine running in the browser.
json
"view": {
"params": { "period": "2026-Q3" },
"toggles": { "grp-details@table-charges[group:Water]": "expanded" },
"sorts": { "table-charges": [ { "column": "col-amount", "direction": "desc" } ] },
"filters": { "table-charges": { "flt-service": ["Water", "Sewerage"] } }
}- The view state is part of the render input (§3), so the same document, data, parameters and view state always give the same output.
- Toggle keys are a node id plus an instance key (§22.3), so each group or row expands on its own.
- Viewers SHOULD put the view state in the page URL, so a view can be shared or bookmarked. Hosts decide whether view state may carry parameter values.
- With no view state, every action is in its initial state. That is what PDF, DOCX and XLSX outputs show, unless
outputs.<format>.viewgives a fixed view state.
(0.1.2)
- A render's view state is its format's
outputs.<format>.view, with the view state of the request laid over it key by key (one toggle, one region's sort, one filter at a time).outputs.pdf.viewserves PDF and the layout model and page images,outputs.html.viewboth HTML formats, andoutputs.docx.viewandoutputs.xlsx.viewtheir formats. - A key the view state doesn't have takes the action's
initial. - View state comes from URLs, so it never fails a render. A malformed entry is skipped with a
schema/wrong-typewarning. An entry naming a region, column, filter or toggle target that doesn't exist is skipped with aninteraction/unknown-targetwarning. - Collapsed content and rows removed by viewer filters are left out of every format, except as §17.6.3 says for XLSX.
17.6.2 Actions
Action = { "on": "click" | "hover", "if"?: Expr, <one effect> }
Effects: "toggle" | "navigate" | "setParams" | "tooltip"A node can have several actions, at most one effect per event except tooltip, which combines with a click action.
| Effect | Fields | Meaning |
|---|---|---|
toggle | toggle: node id(s); initial: expanded | collapsed | Expr → bool; indicator: bool (default true) | Show or hide the target nodes. Drill-down |
navigate | navigate: Expr → target; target: self | new (default self) | Go to another report, a URL or an internal anchor. Drill-through |
setParams | setParams: map of Expr | Change parameter values and render again, e.g. clicking a chart bar selects that period |
tooltip | tooltip: Content | Text shown on hover. Only on hover |
17.6.3 toggle
json
{ "type": "text", "id": "svc-name", "content": "{{ group.key }}",
"actions": [ { "on": "click", "toggle": "grp-lines", "initial": "collapsed" } ] }- The target is resolved in the trigger's instance scope: a toggle in a group header affects that group's instance of the target, not every group.
- A collapsed target takes no space, as if its
ifwere false. Totals and aggregates are unchanged, because they come from data, not from what is visible. - With
indicator: true, the trigger shows ▸ when collapsed and ▾ when expanded, styled through thetoggle-indicatorpart. - In XLSX, toggles on group content become Excel outline groups, collapsed or expanded to match.
(0.1.2)
- Targets are node ids or the ids of a table's rows, a table's columns or a region's groups. A target that doesn't exist is an
interaction/unknown-targeterror. So is one that names something else with an id, which a toggle can't show or hide: a page template, a chart series, a viewer filter, or a matrix's row or column group or cell. (0.1.2) - Toggle keys. A toggle's key in the view state is
<target>@<scope>. The scope is the trigger's instance key (§22.3) cut to the repeated levels that trigger and target share. When they share none, the key is the target id alone, and one state covers every instance of the target. Viewers don't build keys: each trigger carries its keys in the layout model (§22.2) and in HTML output. - Groups and columns. A toggle in a group's header that names its own group hides that group's items; the group's header and footer stay. A toggle that names a column empties that column's cells in the detail.
- A toggle whose
ifdoesn't hold leaves its targets shown. - The indicator goes at the start of the trigger's content, on text, headings, boxes, frames and list items.
indicator: falseturns it off. Every format but XLSX draws it. - In XLSX, a collapsed group's rows are written and hidden in its collapsed outline group.
17.6.4 navigate
json
{ "type": "link", "href": "report('customer-statement', { 'account': row.accountNumber, 'period': params.period })",
"content": "{{ row.accountNumber }}" }link.hrefandnavigatetake the same targets: anhttps:ormailto:URL,'#<node-id>', orreport(id, params).- The host resolves
report()targets: it checks the reader may open that report, checks the parameter values against the target's parameters, and builds the URL. Viewers SHOULD offer a way back to the previous report. - How the target is encoded in the URL (for example, as a signed token so parameter values can't be read or altered) is host-defined. (0.1.1)
- In PDF and DOCX, report targets become hyperlinks to the host's URL for that report. If the host has none, the link is left out with an
output/link-unresolvedwarning. - This holds in every format (0.1.2). Report targets are resolved when the document is expanded, from a link base the host gives the render. Without one, a report link is left out and its text kept, with an
output/link-unresolvedwarning. - A
navigateorhrefvalue that is not#<node-id>, anhttps:ormailto:URL, or a report target is left out, with asecurity/link-rejectedwarning, whether it comes from the document or from data. (0.1.2)
17.6.5 Viewer sorting and filters
Sorting. Table columns with sortable: true can be sorted by the reader. Clicking the header cycles through ascending, descending and the document's own order. The reader's sort replaces the region's sort for the rows within each group; group order is unchanged. Sorted headers show the sort-indicator part.
The reader's sort reorders the rows the region's limit kept; it doesn't change which rows those are (0.1.2). Only bound tables sort, by the column's sortBy or else its cell's plain path. A sort on a column that isn't sortable, or on a repeat or matrix, is ignored with an interaction/unknown-target warning. The sort-indicator (▲ or ▼) ends the header cell's content, in every format but XLSX.
Filters. A region's filters describe filter controls the viewer shows above the region:
ViewerFilter = {
"id": Id,
"label": Content,
"by": Expr, // the value to filter on, in row scope
"kind": "values" | "range" | "search", // pick from values, a min-max range, or text search
"initial"?: value | value[] // initial selection
}valuesoffers the distinct values ofbyacross the region's rows.rangeworks on numbers and dates.searchmatches text, case-insensitively.- The reader's filters are applied after the region's own
filter, before sorting and grouping. Totals then reflect the filtered rows, and the region shows itsemptyslot if nothing is left. - In XLSX,
valuesfilters become an AutoFilter on the region's sheet. - The reader's filters also apply before
limit. (0.1.2) - A filter's selection in the view state is a list of values (
values),[min, max]with either endnull(range), or a string (search).nullturns the filter off, even when it has aninitial; so do an empty list, an empty string and[null, null]. (0.1.2) valuesoffers the distinct values over the rows left by the region's ownfilter. (0.1.2)
17.6.6 Actions on charts
Chart actions are evaluated with the chart's row alias bound to the clicked data point and series bound to the clicked series:
json
{ "type": "chart", "id": "chart-usage", "source": "data.bill.usageHistory", "as": "q", "kind": "column",
"actions": [
{ "on": "click", "setParams": { "quarter": "q.label" } },
{ "on": "hover", "tooltip": "{{ q.label }}: {{ Format(q.kilolitres, 'number:1') }} kL/day" }
] }(0.1.2)
- A chart's actions are evaluated once per data point: for each series shown and each row of
source, with the row alias bound to the row andseriesto{ id, name }of the series. An action whoseifdoesn't hold for a point is left out there. The chart as a whole has no actions. - In the layout model (§22.2), each drawn point that has an action is a box of kind
chart-pointinside the chart's box, with the chart's node and instance. Its rectangle is the bar; a square around a line, area or scatter point; or a square inside a pie or donut slice. Itstextis the point's label (category, series: value, without the series when the chart has one), and itsactionsare the point's. A point that isn't drawn, such as a null value or a zero slice, has no box. - HTML outputs (§17.5) carry each
chart-pointas a transparentrectover the chart, with the samedata-rfml-*hooks as other actions and the point's label as its accessible name. PDF, DOCX and XLSX draw the chart without them.
17.6.7 Parameters in the viewer
The viewer shows a parameter panel built from params (§10.3), unless outputs.html.paramPanel is false. Changing a value and applying it renders the report again. Hidden parameters are not shown.
17.6.8 Diagnostics
Problems with actions use the interaction/* codes: for example interaction/unknown-target for a toggle naming a node that doesn't exist, or interaction/sort-key-missing for a sortable column whose cell isn't a plain path and has no sortBy.
18. Accessibility and localisation
18.1 Accessibility
- Reading order is document order. Absolutely positioned children of a
frameare read in their document order, not their visual position. So are a page stamp's children, where the stamp sits in the document: a stamp in a table cell is read in that cell, with its row, though it is drawn elsewhere on the page (0.2). Only the instances that drew are read (§6.13). headinglevels produce the PDF structure tree. Engines SHOULD warn when heading levels skip (a11y/heading-skip).imageandchartMUST havealtunless markeddecorative. Afield-imagehas nodecorativeand always needsalt, which the schema requires (0.2: stated). AbarcodeMAY havealt; without it, its encoded value is its alt text (§6.9).- Severities (0.1.2): an
imagewithoutaltthat isn'tdecorativeis ana11y/missing-alterror. Achartwithoutalt, and an emptyalton an image, chart or field image, area11y/missing-altwarnings, which become errors under the PDF/UA standards at design time and in PDF renders, and stay warnings in other formats (§17.1).a11y/heading-skipis a warning: the first heading may have any level, and each later one may go at most one level deeper than the one before. - In tagged output, page headers, footers and backgrounds, repeated headers, continuation slots, rules, borders and backgrounds, table of contents leaders and decorative images and barcodes are artifacts, not content. A footnote's body is a note placed right after the paragraph that calls it. (0.1.2) Repeated headers and both continuation slots,
continuationHeaderandcontinuationFooter, are Pagination artifacts (0.1.2). A page's overlay is a Pagination artifact of subtype Watermark (0.2). A page stamp is content, unless it isdecorative, which makes it a Layout artifact (0.2); a stamp drawn from a repeated header is a Pagination artifact, as the header is. - Tables have header cells marked; bound tables mark column headers automatically.
langon any node sets its language for screen readers and hyphenation.
18.2 Localisation
json
"meta": { "locale": "en-AU", "currency": "AUD" },
"i18n": {
"en": { "amount-due": "Amount due", "page-of": "Page {page} of {count}" },
"fr": { "amount-due": "Montant dû", "page-of": "Page {page} sur {count}" }
}T('amount-due')returns the message for the effective locale (§11.3), falling back to its parent locales (such as the language), then tometa.localeand its parents, then to the key with ai18n/missingwarning. (0.1.2)- Messages use ICU MessageFormat, so plurals and selects work:
T('items', { 'n': region.count }). - In a subreport,
Tlooks for a key in the child'si18nfirst and then in the parent's, each along the same locale chain, with the child'smeta.localeas the document locale (§14.4). (0.1.2) - The locale is the effective one (§11.3): a node's
lang, thenmeta.locale, then the render context. A message found only undermeta.locale(or its language) uses that language's plural rules and formats. - A message key missing from the whole chain renders as the key and raises
i18n/missingat the node. At design time, checkers also report messages that aren't valid MessageFormat (i18n/invalid-message), locales whose chain lacks a key another locale has (i18n/missing, at/i18n/<locale>), andTcalls that leave out an argument the message reads (i18n/missing-argument) or pass one of the wrong type (expr/type). - When checkers check a subreport's document as part of its parent (§14.4), a key the child's
i18nlacks but an enclosing document's has counts as present, andTcalls with it are checked against that message. A document checked on its own can't tell whether it will be embedded, so a key missing from its owni18nstays ani18n/missingwarning there, never an error. (0.1.2) - Formatting follows the locale (§11.6). Right-to-left text is supported through
directionand the Unicode bidi algorithm (§16.1).langdoes not set the direction, as in CSS: an Arabic subtree setsdirection: rtltoo. Margins, padding and insets written with the logical sides (§8.4) (0.2) mirror with the direction, so one style set serves both directions.
19. Metadata, locks and authoring hints
19.1 Document meta
| Property | Type | Notes |
|---|---|---|
title | string | Required |
description | string | What the report is for, written for people and AI agents |
locale | BCP 47 | Default formatting locale |
currency | ISO 4217 | Default currency |
tags | string[] | |
owner, version | string | Informational; the host's version history is authoritative |
designer | object | Designer-only state, ignored by engines (0.2) |
designer (0.2):
- It holds state that only designers read, such as the alignment guides of each page template. It is saved, versioned and diffed with the document, so everyone who edits the document shares it.
- Engines MUST ignore it: it changes no output, no layout and no diagnostic. Its contents are designer-defined, and validation only checks that it is an object.
Informative: the reference designer keeps its guides in guides, keyed by page template id. Each guide is a vertical line at x or a horizontal one at y, measured from the page's top-left corner in the page's units:
json
"meta": {
"title": "Invoice",
"designer": { "guides": { "default": [ { "x": "20mm" }, { "y": "45mm" } ] } }
}19.2 Node meta
| Property | Type | Notes |
|---|---|---|
label | string | Name shown in the designer outline |
note | string | A free-form author note. Written as a comment in markup notation (§27.2) |
description | string | Intent of this node ("Shows the customer's last 8 quarters of usage") |
locked | true or a list of property names and paths (0.2: paths) | Locked properties cannot be changed, except by roles the host allows to unlock (§19.3) |
designer | object | Designer-only state (collapsed, guides). Ignored by engines |
Informative: description is written for AI agents as much as people. An agent asked to "make the usage chart show 12 quarters" finds the node by its description and changes it with one validated operation.
19.3 Locks
- A locked property cannot be changed by an edit operation, and a locked node (
locked: true) cannot be removed, moved or have its locked properties changed. Its unlocked descendants can still be edited. - The host maps roles to the right to set or clear locks. Typical use: developers lock layout and brand styles, so business users can change wording, fields and visibility.
- An operation that would change locked content is rejected with an
edit/lockederror naming the node and property. Setting or clearingmeta.lockeditself requires the right to unlock. (0.1.1) - What a lock covers (0.1.2):
locked: truerefuses every change to the node, including inserting nodes into its slots or moving them out, except changes tometa.designer.- A list of property names refuses changes to those properties. A slot is locked by naming its property, such as
children. - Removing a node that has a lock, on itself or anywhere below it, needs the right to unlock. So does unwrapping a locked container, inserting a node that carries
meta.locked, and renaming a node whose lock coversid. - A node locked on some properties only can still be moved.
- The references
renameIdrewrites inside locked nodes are not changes to them.
- Lock paths (0.2):
- An entry of the list may be a dotted path below a property:
style.color,style.parts.cell.font-family,rules.0.style. Each segment is a property name, a part name or a list index. The format is^[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$; anything else isschema/invalid-value. - A path locks the value at that path only: with
["style.color"], the colour can't change and the padding can. An operation is refused when the value at a locked path is different after it, so replacing or removing the wholestyleis refused when it changes the colour, and allowed when it keeps it. - So "lock the brand, let people edit the rest" is
"locked": ["style.color", "style.font-family", "class"]. - A dotted entry in a document that declares an earlier version is
schema/unsupported-version.
- An entry of the list may be a dotted path below a property:
- Page templates (0.2) take
metawithlockedas nodes do (§9.1). Their locks are checked forsetandunseton the template, for inserting into and removing from its bands, and forsetDocumentunder/pages, which can't change a locked template's locked properties or remove it without the right to unlock.
20. Tests
A document can carry its own tests: inputs, plus assertions over the rendered result.
json
"tests": [
{
"name": "typical bill fits on two pages",
"data": { "bill": "samples/bill-typical.json" },
"params": { "copy": "customer" },
"expect": [
"pages.size() <= 2",
"diagnostics.all(d, d.severity != 'error')",
"text('text-total-due') == 'Amount due $543.51'",
"node('slip').page == pages.size()"
],
"snapshot": true
},
{
"name": "no usage history still renders",
"data": { "bill": "samples/bill-no-history.json" },
"expect": [ "node('chart-usage') == null", "visible('text-no-history')" ]
}
]expectitems are boolean expressions over a result scope:pages(from the layout model),diagnostics, and these helper functions:node(id): the first box of that node as{ page, x, y, w, h, text }, ornullif it wasn't rendered. Component internals use<instance-id>/<inner-id>.nodes(id): every box of that node, across instances and fragments.text(id): the rendered text of the node's first instance, all fragments joined.visible(id): whether the node rendered at least one box.- The four functions also take a static table row or cell by its qualified name,
<table-id>/<row-id>or<table-id>/<row-id>/<cell-id>, as component internals are, and a page template's bands aspage:<template-id>(§22.2).
snapshot: trueasks the host to compare page images against an approved baseline.- Result details (0.1.2):
node(id)also haskind,instanceandfragment. Its lengths are decimals in points, as in the layout model (§22.2). A box whose child box has the same node, such as a cell and its content, counts as one box.- A test's
datavalue is a bundle path or an inline value. A path that isn't in the bundle fails that test withbind/missing-dataset. - A test with
snapshot: truefails while its snapshot is new or differs from the approved one, until the host approves it. - Tests may use every extension the engine has (§23).
- The text is as drawn: after white-space processing (§7) and
text-transform, with a soft wrap as a space and a forced break (a preserved line feed orbr) as a line break (\n). A word split by hyphenation oroverflow-wrapis whole. Fragments, and a box's child boxes, are joined by line breaks. - Content drawn again on each page, a page band or a header repeated on a continuation page, is an instance of its own on each page (§22.3), so
text()gives the first page's copy andnodes()lists every page's. - An id that no box can have (one that isn't a node's, a column's or a qualified row or cell name) is a
ref/unknown-nodewarning: the function finds nothing.
- Tests run in CI, in the designer ("Check report"), and inside AI agents' generate-and-check loops.
21. Edit operations
Every change to a document, whether from the designer, the source editor's structured actions, the CLI, the SDK or an AI agent, is expressed as edit operations. They are the only way the host changes a stored document, apart from replacing it whole after validation.
21.1 Operations
| Operation | Fields | Effect |
|---|---|---|
insert | parent, slot, index?, node | Insert a node (and its subtree) |
remove | target | Remove a node and its subtree |
move | target, parent, slot, index? | Move a node |
set | target, path, value | Set a property; path is a JSON Pointer within the node, e.g. /style/color |
unset | target, path | Remove an optional property |
renameId | target, newId | Rename an id and update every reference |
wrap | targets[], wrapper | Put adjacent siblings inside a new container |
unwrap | target | Replace a container with its children |
convert | target, toType | Change type while keeping compatible properties (e.g. repeat ↔ bound table) |
extractComponent | targets[], name, props | Turn nodes into a component and an instance |
inlineComponent | target | Replace an instance with its expanded nodes |
setDocument | path, value | Change top-level properties (styles, params, data, pages, …) |
insertRows | target, section, index?, count? | Insert empty rows into a table (0.2) |
removeRows | target, section, index, count? | Remove rows from a table (0.2) |
insertColumns | target, index?, count? | Insert empty columns into a table (0.2) |
removeColumns | target, index, count? | Remove columns from a table (0.2) |
mergeCells | target, section, row, column, rows, columns | Merge a rectangle of cells into one (0.2) |
splitCell | target, section, row, column | Split a merged cell into single cells (0.2) |
target and parent are node ids, and page template ids for page slots; the two share one namespace, so a target is never ambiguous (§5.2) (0.2). Parameter, dataset, component, asset and footnote ids are not targets. Using ids rather than array positions keeps operations meaningful when several people or agents edit at once.
Addressing and effects (0.1.2):
- The document's
bodyis{ "parent": "", "slot": "body" }. - A slot is named by its JSON Pointer from its owner, without the leading
/:children,detail,groups/0/header,columns/1/cellChildren,slots/notes, and for page templatesheaderorvariants/first/footer. A slot that doesn't exist yet is created. indexcounts positions in the slot after a moved node has left it, and is clamped to the end. Withoutindex, the node goes last.setandunsetchange one node's (or page template's) own properties. Their path can't go into a child node.idchanges only throughrenameId,typeonly throughconvert, and nodes in a slot only throughinsert,removeandmove. Unsetting a property that isn't there changes nothing, and unsetting a list item removes it. Setting below a missing object creates it.- A static table's rows and cells aren't nodes, and its row lists aren't slots, so
setandunseton the table reach a cell by position, as the table operations below do:/rows/1/cells/0/content,/header/0/cells/2/style/text-align(0.2: stated). A cell needs noidto be edited. setDocumentwith anullvalue removes the property. It can't change the body's nodes.renameIdrenames nodes and page templates. Besides id-valued references (toggle targets,pagebreak.page, thepagestyle property,outputs.xlsx.sheets[].from), it rewrites string literals that name the node:'#<id>'in link targets, and the first argument of the test functionsnode,nodes,textandvisible(§20). Renaming a page template also renames its key in the reference designer'smeta.designer.guides(§19.1) (0.2); the rest ofmeta.designeris opaque and untouched. Footnote ids are not node ids, sofootnote('id')is not rewritten; nor is a component'spartsmap, since a component's template nodes are not operation targets.wrapputs adjacent siblings at the end of the wrapper's main slot (children, ordetailforrepeat), where the first of them was.unwrapputs the main slot's nodes where the container was; its other slots must be empty.convertkeeps the properties both types have and moves the main slot's content. Arepeat's detail nodes become a boundtable's columns and back. Required properties are filled in (content: "",level: 1).extractComponent'spropsmaps prop names to expressions. Each occurrence of an expression in the targets becomesprops.<name>, and the new instance passes the expression.inlineComponentreplaces an instance with aboxthat keeps the instance's id and common properties.- An operation the document can't take is an
edit/invalid-operror.
Table operations (0.2):
targetis atable(§6.5, §12.3).sectionnames its row list:header,rowsorfooterfor a static table, andheaderRowsorfooterRowsfor a bound one. A bound table's body rows come from its data, so they can't be inserted or removed.- Rows, columns and cells are addressed by position, counted from 0. A section is a grid: each cell takes the next column that no row span from above covers. The grid has the table's declared
columns, or without them as many columns as its widest row (the sum of that row'scolspans). countdefaults to 1 and is at most 1000. Withoutindex, new rows and columns go last, and anindexpast the end is clamped.- New rows get the ids
row-<n>and new columnscolumn-<n>, with the smallestnnot in use. Their cells are{ "content": "" }. A static table's new column is{ "id" }, and a bound table's{ "id", "cell": "" }. A table without declared columns gets cells only. - A span across the insertion point grows over the new rows or columns. A row that stops short of the new columns stays short.
- A span across removed rows or columns shrinks. A cell that starts in them and reaches past them moves to the first row or column left. A cell wholly inside them goes, with its nodes. Removing every row of a list removes the list, and a table keeps at least one column.
mergeCellsmerges therows×columnsrectangle whose top left isrow,column. The merged cell keeps the top-left cell's other properties. Its content is the merged cells'contentin reading order, with abrbetween, or, when any of them haschildren, all their children, with eachcontentas atextnode.splitCellsplits the cell coveringrow,columninto single cells. The first keeps its content and properties, and the other positions get empty cells. Splitting a single cell changes nothing.- An
edit/table-spanerror refuses a merge whose rectangle cuts a merged cell, any table operation on a section whose spans already overlap or run past its rows or columns, and an edit that would make a table without declared columns narrower than the cells of one of its sections need. - The inverse of a table operation sets back the table properties it changed (
seton/rows,/columns, …).
21.2 Rules
- A batch of operations is applied atomically: if the result doesn't validate, nothing changes and the diagnostics are returned.
- "Doesn't validate" means the batch adds errors (0.1.2). Errors are compared by code, node (following renames in the batch) and path, counting repeats, so a document that already has errors can still be edited. Hosts MAY apply a batch without this check, for example for a preview. A missing-alt error (
a11y/missing-alt) on a node the batch inserts doesn't count: the node keeps the error until its alt text is written, so a PDF/UA render still fails, but the insert is allowed. (0.1.2) - Every operation has an inverse, computed when it is applied, which gives undo and redo.
- Locks (§19.3) and host permissions are checked for every operation.
- The host records each batch with its author (person, API key or agent), timestamp and optional message.
- Every operation can be expressed as JSON Patch (RFC 6902) over the canonical JSON, so generic tools still work.
21.3 Live co-editing
Several people and agents can edit the same draft at once. The draft's working copy is held in a CRDT (a data structure that merges concurrent changes without a central lock), and canonical JSON is produced from it whenever the draft is saved, validated, rendered or published. This section defines the shared model in CRDT-neutral terms. Yjs in the browser and Yrs in Rust are the reference binding.
Shared model.
| Part | Shape | Merge behaviour |
|---|---|---|
nodes | Map of node id → node record | Adding a node with a new id never conflicts |
| node record | type, parent, slot, order, plus one entry per property | Each property merges independently; last writer wins |
parent, slot, order | Parent node id (or a document slot such as body), slot name, and a fractional index string | Last writer wins. A node's children are the nodes naming it as parent, sorted by order, then id |
Map-valued properties (style, props, export, meta) | Map | Each key merges independently, so two people changing different style properties both win |
Arrays of objects with ids (columns, groups, series, params, filters, and a table's row lists (0.2)) | Keyed collection with order, like children | Concurrent inserts are both kept; each item's properties merge independently |
A row's cells (0.2) | Keyed collection for cells with an id; cells without one stay in the row's value | Cells with ids merge as items; the rest of the row merges as one value |
Other arrays (sort, rules, class, actions) | Whole value | Last writer wins |
Content | Rich text: characters, with spans and links as marks and br, footnote and field-image as embedded items | Character-level merge, so concurrent typing in the same paragraph is kept |
Expression properties (Expr) | Whole string | Last writer wins. Expressions are never merged character by character, because merging two people's keystrokes inside one formula produces nonsense |
Top-level properties (styles, data, pages, vars, i18n, components, …) | Maps, using the rules above |
Rules.
- Each edit operation batch (§21.1) is applied as one CRDT transaction. It is validated against the editor's local state first.
- Merged state is always accepted, even if the merge makes it invalid (for example, two people binding the same column to fields of different types). The language service reports the problem to everyone with a diagnostic, and the draft can't be published until it validates.
- Concurrent moves that make a cycle (A moved into B while B is moved into A): when the tree is produced, the node with the greatest id in the cycle is moved to the end of
body, with acollab/cycle-resolvedwarning. Every peer resolves it the same way. - Edit and delete at the same time: a deleted node is gone, and edits to it are dropped. Children moved into a deleted node move to its former parent, at its position.
- Order keys (0.2). An
orderis a fractional index: a non-empty string of the digits0–9,A–Z,a–z, valued in that order, that doesn't end in0. Keys compare character by character as strings, shorter first on a tie, so every binding sorts them alike, and between any two keys there is always another. Siblings with equal keys sort by id. - Same id inserted twice (0.2). When two peers insert nodes (or keyed items) with the same new id, the insert from the greater peer id keeps it. The other is given the id with
-2appended (or-3, and so on, the first free), with acollab/id-clashwarning naming both, so neither author's work is lost. - Locks and permissions (§19.3) are enforced by the host's sync server, which rejects updates the author isn't allowed to make. Agents connect as peers through the same server, under their own identity.
- Undo reverts only the current author's own changes, in reverse order, skipping changes that later edits by others made impossible.
- Presence (who is editing, and which node they have selected) is exchanged by node id and instance key. Its format is host-defined.
- History. The CRDT holds the live draft only. Saved drafts and published versions are canonical JSON snapshots, so git history and diffs show meaningful versions, not keystrokes.
Informative: the MCP tools for AI agents are these operations plus read tools (get document, schema, component catalogue, sample data, render preview, run tests). A typical agent loop is: read, propose a batch, get diagnostics and a preview, adjust, then commit.
22. Diagnostics and the layout model
22.1 Diagnostics
json
{
"code": "bind/unknown-field",
"severity": "warning",
"message": "data.bill.dueDat is not in the bill schema. Did you mean data.bill.dueDate?",
"node": "text-due-date",
"path": "/content",
"range": [28, 44],
"instance": null,
"fixes": [
{ "title": "Use data.bill.dueDate",
"ops": [ { "op": "set", "target": "text-due-date", "path": "/content", "value": "Due {{ Format(data.bill.dueDate, 'date:long') }}" } ] }
]
}| Code family | Raised by |
|---|---|
schema/* | Structure and property validation |
ref/* | Unresolved ids, components, assets, subreports |
expr/* | Parse, type and evaluation errors |
bind/* | Data and parameter binding |
layout/* | Overflow, forced breaks, unstable page values, frame overflow |
output/* | Format-specific degradations (e.g. chart drawn as an image), and output settings that match nothing (output/sheet-unmatched, §17.3) |
a11y/* | Accessibility checks |
i18n/* | Missing messages and arguments, invalid messages, unsupported locales |
interaction/* | Actions, toggles, viewer sorting and filters |
edit/* | Rejected edit operations, such as edit/locked (§19.3) (0.1.1) |
collab/* | Merges of concurrent edits (§21.3) |
security/* | Limits, rejected assets and links (§25) |
x-<ns>/* | Extensions |
Severities: error, warning, info. Codes are stable across versions. Messages may change and are not for matching.
A diagnostic raised for repeated content is reported once per node, path and code, with the instance key of the first instance that raised it. (0.1.2)
range is a half-open [start, end) pair of offsets into the string value at path, counted in UTF-16 code units, the unit that editors such as Monaco and the DOM use. (0.1.1)
22.2 Layout model
A render can return the layout model alongside or instead of output bytes. It is what the designer's canvas draws and hit-tests, and what tests assert against.
Layout = { "pages": Page[], "diagnostics": Diagnostic[], "timings": { ... },
"filters"?: Filter[] } // (0.1.2)
Page = { "number", "template", "variant", "width", "height", "boxes": Box[] }
Box = { "node": Id, "instance": InstanceKey | null, "fragment": int,
"kind": string, "name"?: string, // (0.2)
"x", "y", "w", "h", // points, top-left origin
"text"?: string, "children"?: Box[],
"actions"?: BoxAction[] } // (0.1.2)- Every box records the node that produced it, so the designer can select any element, including ones nested in containers, regions and components.
fragmentnumbers the pieces of a node split across pages, from 0.- Boxes are nested to match the node tree.
(0.1.2)
xandyare page coordinates, not relative to the parent box.kindis an open set of strings. Boxes the engine generates have their own kinds and nodes. A table's cells use the column's id as their node; arepeathas a box of kindrepeataround its items, for hit-testing, although it has no box of its own in formatting; so does a component instance, a box of kindusearound its roots (§14.2), which, when the roots are flex or grid items or a frame's children, is the smallest box around those on the page (0.2); list markers aremarker; the footnote area and its separator arefootnote-areaandfootnote-separator; page bands arepage-header,page-footer,page-backgroundandpage-overlay(0.2); a page stamp drawn on a page is a box of kindpagestampthe size of the page, with the stamp as node and its positioned children inside it (0.2); a chart's data points with actions arechart-pointboxes inside the chart's box, with the chart as node and the point's label astext(§17.6.6). The columns of a multi-column box add no boxes.- (0.2) Page bands and the footnote area and separator have
page:<template-id>as node. No node id has a:(§5.2), so a band never shares a name with a node. Diagnostics about the template itself, such aslayout/margin-grown, keep the template's id. - (0.2) A table's rows are
header-row,row,footer-row,group-headerandgroup-footer, acontinuationHeader's rowscontinued-header, and acontinuationFooter's rowscontinuation-row(styled asfooter-row, §8.6). A row's node is its table. - (0.2) Rows and cells written out in the document (a static table's,
headerRows,footerRowsand the row bands of groups and continuation slots) also have aname: their own id qualified by the table's node,<table>/<row>and<table>/<row>/<cell>. A cell without an id has noname. Two rows in different lists with the same id share a name. - (0.2)
textis the box's text as drawn (§20): soft wraps are spaces, and only a forced break is\n. - (0.2) A page stamp's box is listed among the page's boxes, not inside the box of the cell or item it sits in, and only on the pages where it draws (§16.13). Its instance key is the one of the content it sits in, such as
invoices[4]. actionslists what a viewer does when the box is clicked or hovered (§17.6), evaluated for that instance:toggle(with its toggle keys and current state),navigate,setParams,tooltip, andsorton a sortable header cell.filterslists the regions' viewer filters, with their options and current selection. Both are left out when empty.
22.3 Instance keys
Repeated content is identified by an instance key: the chain of region and group positions leading to it, such as table-charges[group:Water][3] or table-accounts[2]/slip. Instance keys are stable for the same data, so diagnostics and test assertions can point at a specific repeated instance.
Grammar (0.1.1):
- A key starts with the id of the outermost region or component instance.
[group:<key>]follows for each group level, outermost first.<key>is the group key as locale-invariant text (0.2: before 0.2 it was formatted in the document locale): a number in plain decimal, with.as the decimal point, no grouping and no trailing zeros (1234.5, so1and1.00, one group, are one key); a date as ISO 8601 (2026-10-31); a timestamp as RFC 3339 in UTC; a boolean astrueorfalse; text as it is. Each\is written as\\and each]as\]. A key reads the same in every locale, so a saved toggle or test selector survives a change of locale. Headers still show the key formatted for the reader (§11.6).- A
/inside[…]is part of the key: readers split a key at/only outside brackets, so[group:a/b]is the groupa/b, not a nested region (0.2: stated). [<n>]is the item's 0-based position within its innermost group (or within the region, if it has no groups), afterfilter,sortandlimitare applied.- A nested region adds
/<region-id>followed by its own segments. - In a matrix, row groups add
[group:<key>]and column groups[column:<key>], the keys written as above. A total line adds[total](rows) or[column-total](columns) after the groups it totals. A cell's key is its row line's key followed by its column line's segments, as insales[group:North][total][column:2026]. - A component or subreport instance adds
/<instance-id>, as a nested region does, as intable-accounts[2]/slip. The nodes inside it are named<instance-id>/<inner-id>(§14.2), such asslip/barcode-ref. (0.1.2) - A key names the repeated position, not the node (0.1.2). Every node in the detail of item 3 of
table-xhas the keytable-x[3], and the box says which node it is. Likewise the nodeslip/slip-amountinside a component instance has the instance's own key:slip, ortable-x[2]/slipwhen the instance is in a region. - A region's
separatortakes the key of the item that follows it. (0.1.2) - (0.2) Content drawn again on each page is an instance of its own on each page. Each box in the copy has its key followed by
[page:<n>], with<n>the page number, or, when it has no key, the id of the node that repeats it:- A page band's content and the footnote area and separator:
page:a4[page:3], orhistory[2][page:3]for an item of a region in the band. - A header, group header or
continuationHeaderrepeated on a continuation page, and acontinuationFooter:table-x[page:2], ortable-x[group:Water][page:2]for a repeated group header. The first, unrepeated copy keeps its key. - A page stamp drawn from such a repeated header: its key, or the stamp's id when it has none, followed by
[page:<n>](paid-stamp[page:2]). - Each copy's fragments are numbered from 0. A footnote keeps its call's key on every page.
- A page band's content and the footnote area and separator:
23. Extensions
- Extension element types and functions are namespaced:
"type": "x-acme:map",acme.TaxCode(x). - An extension registers a JSON Schema for its element properties, its slot rules, how it behaves in layout (block or positioned, breakable or not) and an implementation per output format.
- The designer and AI tools learn extension elements from their schema and
description, with no designer code needed for the property panel. - An engine that doesn't know an extension renders a placeholder box of the element's declared size and raises
ref/unknown-extension. - Properties prefixed
x-are allowed on any node and at the top level, and are kept as-is by tools. Engines ignore them unless an extension claims them.
(0.1.2)
- An extension's functions are named after its namespace without the
x-: the namespacex-acmeregistersacme.TaxCode. - The host says which namespaces a render may use; by default it may use none. An element of a namespace that isn't allowed is treated like an unknown one: a placeholder and a
ref/unknown-extensionwarning at/type. Calling a function of such a namespace is anexpr/unknown-functionerror. - A placeholder is as wide as its container's content box and 24pt tall, unless its style sets
widthorheight. - An extension MAY give an implementation for HTML, DOCX or XLSX. In a format it has none for, its drawing is embedded instead: an SVG figure in HTML flow, a picture in DOCX, an image in XLSX.
24. Versioning and canonical form
24.1 Versioning
rfmlholds the language version asMAJOR.MINOR. Minor versions only add features. Major versions may change or remove them.- Every version change ships with a migration function. Engines MUST migrate older documents on load. Hosts SHOULD store the migrated document on the next save and record the migration in history.
- A document with a newer minor version than the engine supports is rendered if every feature it uses is known; otherwise it is rejected with
schema/unsupported-version.
(0.1.2)
- Migrations run in order, one version at a time, before the document is validated (§3 step 1). A resolved subreport is migrated the same way. An older version with no migration path, or a newer major version, is
schema/unsupported-versionat/rfml. - A newer minor version uses a feature the engine doesn't know when validation finds an unknown property, element type or part, an unsupported style property, or a value it can't read (
schema/unknown-*,schema/unsupported-*,schema/invalid-value). Those diagnostics stay, andschema/unsupported-versionat/rfmlis added. A newer minor document is not migrated: it keeps its version. - An engine lists every version it reads, the ones it migrates included, and says which migrations ran on a document.
(0.2)
- A minor version's migration step is empty, since it only adds features. A document of the older minor version is read as it is and keeps its version: nothing is rewritten, the step isn't reported as a migration, and hosts have nothing to store. The 0.1→0.2 step is such a step.
- A document may use only the features of the version it declares. A property, element type, style property or value form added in a later minor version is a
schema/unsupported-versionerror where it is used, with a fix that setsrfmlto the version that added it: a new element type is versioned as a new property is, at itstype. The closed list of what 0.2 adds to documents:- a page template's and a variant's
overlay(§9.1); - the
pagestampelement (§6.13), with itsonce; - the document's
meta.designer(§19.1); - a page template's
meta(§9.1), and dotted lock paths in anymeta.locked(§19.3); - a dataset's
schemaFrom(§10.1); orphansandwidowsonrepeatand boundtable(§12.1);- the style properties
text-fit,margin-inline-start,margin-inline-end,padding-inline-start,padding-inline-end,inset-inline-startandinset-inline-end(§8.4).
- a page template's and a variant's
- The table edit operations (§21.1) are not on the list: they change what tools can do to a document, not what a document may contain.
- Corrections to how documents are laid out and written are errata, not features: they apply to every document an engine reads, whatever version it declares. The version gate covers syntax only. 0.2's errata include a component instance adding no box of its own (§14.2), the
[page:<n>]instance keys of content drawn on each page and locale-invariant group keys (§22.3), stretched flex and grid rows in DOCX (§17.2), merging a document class over a theme class (§8.1), and physical insets in right-to-left frames (§6.12). A saved toggle key or test selector that named the old form names nothing after them. - Rule changes 0.2 makes to names are checked in every document too, since engines can't read the old forms: parameter and dataset names and row aliases are expression names (§11.4), and nodes and page templates share their ids (§5.2).
- A tool that adds the first such property sets
rfmlin the same batch, so the document never declares a version older than the features it uses. A document that uses none of them keeps declaring"0.1", and engines that read only 0.1 go on reading it. - An engine that reads only 0.1 treats a 0.2 document as any newer minor version: it is rendered when it uses none of the additions, and rejected with
schema/unsupported-versionwhen it does.
24.2 Canonical form
To keep diffs, git history and operation logs clean, tools MUST write documents in canonical form:
- UTF-8, LF line endings, 2-space indentation, one property per line.
- Properties in the order listed in this specification and the JSON Schema; extension properties last, sorted.
- Default values left out (e.g.
repeatHeader: trueon tables). - Inline content normalised as in §7.2.
- No duplicate keys; numbers written as their shortest exact form.
(0.1.2)
- Node property order.
type,id,if,class,style,rules, then the element's own properties in the order of this specification, thenlang,running,export,actionsandmeta, thenx-*properties sorted. A column's order isid,if,width,header,class,style,rules,cell,cellChildren,footer,sortable,sortBy. A group writes its other properties beforeheaderandfooter. The JSON Schema lists every object's properties in canonical order. - Style blocks. Declarations follow the order the JSON Schema lists the §8.4 properties in (
css.props), which keeps each shorthand before its longhands, withpartslast. - Content. Besides §7.2, empty strings in an array are dropped, and an empty array is written as
"". - Numbers. A number with no fractional part is written as an integer, and other numbers keep the digits they were written with (
1.50stays1.50). A value that may be a string or a number, such as a style value, a token or an asset'sweight, is written as a number when the string is a plain decimal number ("600"becomes600,"0"becomes0). - Defaults. Only defaults this specification or the JSON Schema states are left out.
25. Security
RFML documents are written by people and AI agents with different levels of trust, and they render data that may itself be untrusted. Engines and hosts protect three things: the host (from resource exhaustion and from file or network access), the readers of outputs (from harmful content in PDF or HTML), and the data (from leaking between reports, tenants or agents).
25.1 Rules that always hold
These are not configurable. Engines and hosts MUST enforce them.
- Data is never code. Strings from data, parameters and view state are never parsed as expressions, interpolations, markup, styles or JSON documents.
{{ }}inside a data value is printed as literal text. Only text in the document itself is parsed. - No scripts anywhere.
- Every SVG the engine decodes is sanitised, whatever its source: assets, library images, an image's
srcfrom data, anddata:URLs (0.2: stated). Scripts, event attributes,foreignObjectand references to external resources are removed, and every output (PDF, HTML, DOCX) is written from the sanitised SVG, never the original bytes. Removing anything raisessecurity/asset-sanitized(§25.3). - HTML output contains no scripts or event handlers from the document. The only script is the host's own viewer.
- PDF output contains no JavaScript, launch, submit-form or import actions. It embeds files only as PDF/A-3 attachments declared in
outputs.pdf.
- Every SVG the engine decodes is sanitised, whatever its source: assets, library images, an image's
- Allowed link targets.
https:,mailto:,#<node-id>andreport()targets.http:only if the host allows it.javascript:,data:andfile:are never allowed in links, and link values that come from data are checked the same way. - Pure expressions. Expressions have no I/O, no randomness, and no clock except
now. - No file access by name. Engines read only assets the host has resolved. Relative paths cannot leave the document bundle (
..is rejected), and nothing in data can name a file. - Isolation. A render sees only its document, its resolved imports and assets, and the data supplied for it. A subreport sees only the data and parameters mapped to it.
- Fail closed. When a limit is exceeded, the render stops with a
security/limiterror. It never returns partial output as if it were complete. Previews MAY show what was rendered so far, clearly marked as incomplete.
25.2 Limits
Engines MUST enforce these limits. Hosts MAY lower or raise them, per host, tenant or report.
| Limit | Default |
|---|---|
| Expression evaluation steps per render | 10,000,000 |
| Expression result size | 16 MiB of text per string, 10,000,000 items per list (0.2: stated) |
| Nodes after expansion (instance tree) | 1,000,000 |
| Pages per render | 2,000 |
| Render time | 60 seconds |
| Data per render (all datasets) | 200 MB |
| Image size | 50 megapixels and 20 MB per image |
| SVG size | 250,000 XML nodes, nested images included, and 1,000,000 elements once every <use> is expanded (0.2) |
| Subreport nesting depth | 5 |
| Layout re-passes for page values | 3 (§16.9) |
(0.2: stated)
- An evaluation step is the evaluation of one node of an expression's syntax tree; a function that walks a list (an aggregate,
Lookup, a comprehension) also counts one step per item. So every engine counts the same steps for the same expression and data. - An expression that exceeds a limit is
expr/limit(§11.1); anything else that does issecurity/limit. - Render time is the only limit measured on a clock, so it is the only one that can differ between machines. Conformance runs (§26) don't apply it. A render that runs out of time stops with
security/limitand returns no output (§25.1): it is never cut short and returned as if complete, so time never changes a layout.
25.3 Assets
- Remote
http(s)assets are off by default. A host that enables them SHOULD use an allowlist of origins, and SHOULD fetch remote assets when the document is published, storing them with a content hash, so renders don't make network requests. - Image decoders MUST guard against decompression bombs: pixel count and decoded size are checked before full decoding.
- An image over a limit is a
security/limiterror. An image the engine can't read is left out with asecurity/asset-rejectedwarning. An SVG that loses scripts, event attributes,foreignObjector external references to sanitising raises asecurity/asset-sanitizedwarning. Compressed SVG (svgz) is not accepted, and images nested inside an SVG are checked by the same rules. (0.1.2) - Fonts SHOULD be sanitised before use, as browsers do, and fonts that fail sanitising are rejected with
security/font-rejected.
25.4 AI agents
- Everything an agent reads from a document (descriptions, notes, text), from sample data and from rendered output is untrusted input, not instructions. Hosts SHOULD mark it as such in tool results, so that text such as "ignore previous instructions" inside a report or its data has no authority.
- Agents change documents only through edit operations (§21), under their own identity, with the same locks and permissions as people. Hosts MAY require a person to approve an agent's changes before publishing.
- By default, agents see the source catalogue (names, parameters, schemas) and sample data, not production data. Previews rendered with production data, and diagnostics or layout models containing its values, are given to an agent only if host policy allows it for that agent's role.
- Hosts SHOULD apply rate and size limits to agent edit batches and renders.
25.5 Diagnostics
Security problems use security/* codes. Diagnostic messages MUST NOT include connection details, credentials or data the requesting author isn't allowed to see.
26. Conformance levels
| Level | Includes |
|---|---|
| Core | Document structure, box, text, heading, span, link, br, list/item, static and bound table, repeat, groups, image, rule, pagebreak, styles and tokens, page templates with variants, params, datasets, the expression profile but for the functions below, diagnostics, layout model, edit operations, PDF output, tagged PDF (0.2: moved from Standard) |
| Standard | Core, plus frame, pagestamp, chart, barcode, toc, footnotes and footnote(), multi-column flow, components and libraries, subreport, running values and page.running(), tests, i18n and T(), DOCX and XLSX outputs |
| Advanced | Standard, plus matrix, PDF/A and PDF/UA, HTML output, interactivity and view state, extension hosting, and optionally Vega-Lite charts (§13) |
(0.2) Core's expression profile is the whole of §11 except T, footnote and page.running, which come with the Standard features they read; at Core they are expr/unknown-function. Tagged PDF is Core, since outputs.pdf.tagged defaults to true and every PDF an engine writes is tagged unless the document says otherwise.
Conformance is shown by passing the RFML test suite for that level. The reference implementation keeps the suite in its conformance/ directory (0.2): schema/ (valid and invalid documents, with the diagnostics each expects), expr/, layout/, ops/ and markup/, plus bundles/ of whole documents with assets. The suite has five parts:
- Schema: valid and invalid documents.
- Expressions: expression, input and expected value, shared by every implementation (Rust engine and JavaScript language service alike).
- Layout: document and data, expected layout model within a tolerance.
- Operations: document, batch, expected document or diagnostics.
- Markup round trip: JSON and markup pairs that must convert into each other exactly.
Live co-editing (§21.3) has no part of the suite yet. A collaboration part (concurrent batches, the merged document they must converge on, and the diagnostics) comes with the reference CRDT binding; until then §21.3 is normative but untested. (0.2: stated)
27. Markup notation
The markup notation is an exact, lossless way of writing an RFML document that looks like HTML. Canonical JSON (§24.2) remains the stored form: storage, git, edit operations and co-editing all work on JSON. The markup notation is a view for developers, code review and documentation.
27.1 Files and round trip
- Markup files use the extension
.rfmland the media typeapplication/vnd.rfml+xml. JSON files use.rfml.jsonandapplication/vnd.rfml+json. - Tools MUST convert in both directions without loss:
parse(format(doc))equalsdocfor any valid canonical JSON document.format(parse(markup))equals the canonical markup form of that document (§27.6).
- The conformance suite includes round-trip tests for every element, property type and slot (
conformance/markup/in the reference implementation, §26).
27.2 Syntax
The notation is well-formed XML 1.0. It is not parsed with HTML's error-tolerant rules.
- Names are case-sensitive. Every element is closed or self-closing (
<br/>). Attribute values are quoted. <and&inside attribute values and text are written as<and&. Formatters do this automatically, sorow.amount < 0is stored in markup asrow.amount < 0.- The root element is
<rfml version="0.2" id="…">, itsversionthe document'srfml. - A comment placed directly before an element becomes that node's
meta.note, and is written back the same way. Comments anywhere else are an error (markup/stray-comment), because they would have no place in the JSON. - Comment details (0.1.2): one space is dropped from each end of the comment's text. A note that can't be written as an XML comment (it contains
--or a carriage return) stays in the node'smetaJSON. A comment before a node that also hasmeta.noteismarkup/duplicate. - The reader is strict (0.1.2): a document type declaration, an entity other than the five XML predefines, or a processing instruction is
markup/syntax, and reading stops at the first well-formedness error. An XML declaration is allowed at the start, and CDATA sections and character references are allowed.
27.3 Mapping nodes
| JSON | Markup |
|---|---|
A node of type t | Element <t>. Extension types keep their namespace: <x-acme:map> |
| Scalar property (string, number, boolean, expression) | Attribute. The value is read with the type the schema gives that property, so level="2" is a number and keepTogether="true" a boolean |
class | Space-separated attribute |
style | CSS declaration text: style="color: var(--brand); font-weight: 700". Part styles go in a child <prop name="style.parts"> holding JSON |
children slot | The element's child elements |
Named slot (header, detail, footer, empty, separator, continuationHeader, continuationFooter, background, overlay, …) | A wrapper element with the slot's name, holding the slot's nodes. Slot names are defined per element type, so they never clash with node types |
Primary content (content of text, heading, span, link, footnote; a column's cell) | The element's text and inline children (<span>, <link>, <br/>, <footnote>, <field-image/>) |
Other Content properties (a column's header and footer, alt, label, …) | An attribute when the value is a plain string; otherwise a child element with the property's name holding inline content |
Arrays of objects with ids (columns, groups, series, params, filters, rows, cells) | Child elements named by the singular form: <column>, <group>, <series>, <param>, <filter>, <row>, <cell> |
Other object or array properties (sort, rules, actions, props, export, meta, running, sourceParams) | An attribute holding JSON, or a child <prop name="…"> holding JSON |
x-* properties | Attributes holding JSON |
Whitespace in inline content collapses as in HTML: leading and trailing whitespace is dropped and runs of whitespace become one space. Content whose whitespace matters is written with xml:space="preserve".
Mapping details (0.1.2):
- The root's attributes
versionandschemaholdrfmland$schema. - Unwrapped child elements: a matrix's
rows,columnsandcellsare<row>,<column>and<cell>; a page'svariantsare<variant name="…">. An instance'sslotsare<slots>holding<slot name="…">elements with the nodes. - Wrapped child elements: a table's
header,footer,headerRows,footerRowsand continuation slots, and a group'sheaderandfooter, are wrapper elements holding<row>elements, or nodes where the slot takes nodes. A column'scellChildrenis a wrapper holding nodes. A static cell'scontentis its text, and itschildrenare its child elements. - A region's or group's
varsand a node'srunningare JSON attributes. Only the document'svarsuse<vars>and<var>. - An empty
textis writtencontent="", since<text id="t"/>has no content at all. An empty list that is normally unwrapped is written as<prop name="children">[]</prop>. - Whitespace collapses separately in each string, so each is trimmed at its own ends. A formatter writes
xml:space="preserve"on the element whose content needs its whitespace kept; it applies to everything inside, which is then written on one line. - An extension element's properties are attributes holding strict JSON, such as
label='"Brisbane"'. - A
pagestampis<pagestamp>, with its children as child elements, like aframe(0.2). - Any property whose usual form can't hold its value exactly is written as
<prop name="…">holding JSON, so the round trip is always exact.
27.4 Mapping the document
| JSON | Markup |
|---|---|
meta | <meta> with scalar fields as attributes and description as text |
params | <param> elements |
data | <dataset name="…" source="…"> elements, with <schema>, <sample> and <sourceParams> children holding JSON |
vars | <vars> with <var name="…">expression</var> children |
styles | A <styles> element holding CSS-style text (§27.5) |
assets | <asset id="…" kind="…" src="…"/> elements |
imports | <import from="…" version="…" components="a b"/> elements |
components | <component name="…"> with <props> (JSON), <parts> (JSON) and <template> (nodes) children |
i18n, outputs, footnotes | Elements with the same name holding JSON |
pages | <page> elements, with <header>, <footer>, <background>, <overlay> (0.2) and <variant name="first"> children |
body | <body> |
tests | <test name="…"> with <data>/<params> (JSON) and one <assert> per expectation |
27.5 Styles block
<styles> uses CSS syntax for a fixed set of rule forms. These are not selectors: each form maps to exactly one place in styles.
css
@import "theme:corporate@^2";
:root { --brand: #0085CB; --ink: #1F2933; }
document { font-family: Inter; font-size: 9pt; }
table { border-collapse: collapse; }
table::part(header-cell) { font-weight: 600; }
.money { text-align: right; }
.compact::part(cell) { padding: 2pt 4pt; }| Rule form | Place in styles |
|---|---|
@import "…"; | extends |
:root { --<name>: …; } | tokens |
<element-type> { … } (document for the root) | defaults.<element-type> |
<element-type>::part(<part>) { … } | defaults.<element-type>.parts.<part> |
.<class> { … } | classes.<class> |
.<class>::part(<part>) { … } | classes.<class>.parts.<part> |
- Any other selector form is an error (
markup/unsupported-rule). - There is at most one
@import(a second ismarkup/duplicate), and:rootholds only--<token>declarations (anything else ismarkup/invalid-value). (0.1.2) - A selector that appears more than once has its declarations merged in order. A property declared twice for the same place raises a warning (
markup/duplicate), and the last declaration wins. (0.1.2) - CSS comments (
/* … */) are not allowed in the block (markup/stray-comment), because they have no place in the JSON. (0.1.2) - Styles that the CSS form can't express exactly, such as a token value containing
;or an emptydefaultsobject, are written as<prop name="styles">holding JSON instead. (0.1.2)
27.6 Canonical markup
Formatters write markup in one canonical form, so markup diffs are as clean as JSON diffs:
- 2-space indentation, lines up to 100 characters, and one attribute per line when an element's start tag doesn't fit on one line.
- Attributes in canonical JSON property order (§24.2).
- JSON-valued properties as attributes when the JSON fits on one line in 80 characters, otherwise as
<prop>children. - Default values left out, as in JSON.
- A start tag that doesn't fit keeps its first attribute on the tag's line and aligns the others under it, as in §27.7. Lines never break inside
{{ … }}. (0.1.2)
27.7 Example
A bound table in markup notation:
html
<table id="table-charges" source="vars.charges" as="line"
sort='[{"by": "line.service"}]'>
<group id="by-service" by="line.service" keepTogether="true">
<header><text id="svc-name" class="group-title">{{ group.key }}</text></header>
<footer>
<text id="svc-total" class="money">Subtotal {{ Format(Sum(group.rows.map(r, r.amount)), 'currency') }}</text>
</footer>
</group>
<column id="col-desc" width="3fr" header="Description">{{ line.description }}</column>
<column id="col-amount" width="1fr" header="Amount" class="money"
footer="{{ Format(Sum(region.rows.map(r, r.amount)), 'currency') }}">
{{ Format(line.amount, 'currency') }}
</column>
</table>The same table in canonical JSON:
json
{
"type": "table",
"id": "table-charges",
"source": "vars.charges",
"as": "line",
"sort": [{ "by": "line.service" }],
"groups": [{
"id": "by-service",
"by": "line.service",
"keepTogether": true,
"header": [{ "type": "text", "id": "svc-name", "class": ["group-title"], "content": "{{ group.key }}" }],
"footer": [{ "type": "text", "id": "svc-total", "class": ["money"],
"content": "Subtotal {{ Format(Sum(group.rows.map(r, r.amount)), 'currency') }}" }]
}],
"columns": [
{ "id": "col-desc", "width": "3fr", "header": "Description", "cell": "{{ line.description }}" },
{ "id": "col-amount", "width": "1fr", "header": "Amount", "class": ["money"],
"cell": "{{ Format(line.amount, 'currency') }}",
"footer": "{{ Format(Sum(region.rows.map(r, r.amount)), 'currency') }}" }
]
}28. Complete example
A two-page utility bill: masthead, account summary, grouped charges, a usage chart, and a payment slip on the last page.
json
{
"$schema": "https://reportforge.dev/schema/rfml/0.1.json",
"rfml": "0.1",
"id": "water-bill",
"meta": {
"title": "Water bill",
"description": "Quarterly residential water bill with charges by service, usage history and a tear-off payment slip.",
"locale": "en-AU",
"currency": "AUD"
},
"params": [
{ "name": "copy", "type": "string", "label": "Copy", "default": "'customer'",
"options": [ { "value": "customer", "label": "Customer copy" }, { "value": "archive", "label": "Archive copy" } ] }
],
"data": {
"bill": {
"schema": { "$ref": "schemas/bill.json" },
"sample": "samples/bill-typical.json",
"description": "One customer's bill for one billing period."
}
},
"vars": {
"charges": "data.bill.lines.filter(l, l.kind == 'charge')",
"totalDue": "Sum(vars.charges.map(l, l.amount)) - data.bill.credits"
},
"styles": {
"tokens": { "brand": "#0085CB", "ink": "#1F2933", "muted": "#6B7280", "panel": "#EAF5FB", "negative": "#B42318" },
"defaults": {
"document": { "color": "var(--ink)", "font-family": "Inter", "font-size": "9pt", "line-height": 1.35 },
"heading": { "margin": "0 0 4pt", "color": "var(--brand)" }
},
"classes": {
"money": { "font-variant-numeric": "tabular-nums", "text-align": "right" },
"muted": { "color": "var(--muted)" },
"panel": { "padding": "8pt", "border-radius": "4pt", "background-color": "var(--panel)", "break-inside": "avoid" },
"group-title": { "padding-top": "4pt", "font-weight": 600 }
}
},
"assets": {
"logo": { "kind": "image", "src": "library:brand/larkmere.svg" },
"inter": { "kind": "font", "src": "library:fonts/inter-variable.ttf", "family": "Inter", "weight": "100 900" }
},
"components": {
"remittance-slip": {
"description": "Tear-off payment slip with payment reference, amount due and barcode.",
"props": {
"type": "object",
"required": ["reference", "amount", "dueDate"],
"properties": {
"reference": { "type": "string" },
"amount": { "type": "number" },
"dueDate": { "type": "string", "format": "date" }
}
},
"template": [
{ "type": "frame", "id": "slip-frame",
"style": { "height": "75mm", "padding-top": "6mm", "border-top": "1pt dashed var(--muted)" },
"children": [
{ "type": "heading", "id": "slip-title", "style": { "top": 0, "left": 0 }, "level": 2, "content": "Payment slip" },
{ "type": "text", "id": "slip-ref", "style": { "width": "90mm", "top": "12mm", "left": 0 },
"content": "Reference {{ props.reference }}" },
{ "type": "text", "id": "slip-amount", "style": { "width": "90mm", "font-size": "12pt", "font-weight": 700, "top": "20mm", "left": 0 },
"content": "{{ Format(props.amount, 'currency') }} due {{ Format(props.dueDate, 'date:medium') }}" },
{ "type": "barcode", "id": "slip-barcode", "style": { "width": "70mm", "height": "18mm", "top": "12mm", "right": 0 },
"symbology": "code128", "value": "props.reference", "showText": true }
] }
]
}
},
"pages": [
{
"id": "default",
"size": "A4",
"margin": "16mm 15mm 18mm",
"footer": [
{ "type": "box", "id": "footer-bar", "style": { "display": "flex", "justify-content": "space-between" },
"children": [
{ "type": "text", "id": "footer-account", "class": ["muted"], "content": "Account {{ data.bill.accountNumber }}" },
{ "type": "text", "id": "footer-page", "class": ["muted"], "content": "Page {{ page.number }} of {{ page.count }}" }
] }
],
"background": [
{ "type": "text", "id": "archive-mark", "if": "params.copy == 'archive'",
"style": { "font-size": "48pt", "top": "120mm", "left": "40mm", "rotate": "-30deg", "opacity": 0.12 },
"content": "ARCHIVE COPY" }
]
}
],
"body": [
{ "type": "box", "id": "masthead", "style": { "display": "flex", "justify-content": "space-between", "align-items": "flex-start" },
"children": [
{ "type": "image", "id": "logo", "style": { "width": "42mm" }, "asset": "logo", "alt": "Larkmere Water" },
{ "type": "box", "id": "customer", "children": [
{ "type": "text", "id": "customer-name", "style": { "font-weight": 600 }, "content": "{{ data.bill.customer.name }}" },
{ "type": "text", "id": "customer-address", "content": "{{ Join(data.bill.customer.addressLines, ', ') }}" }
] }
] },
{ "type": "heading", "id": "title", "level": 1, "content": "Your water bill" },
{ "type": "box", "id": "summary", "class": ["panel"],
"style": { "display": "grid", "gap": "8pt", "grid-template-columns": "1fr 1fr 1fr" },
"children": [
{ "type": "text", "id": "summary-period", "content": [
"Billing period", { "type": "br" },
"{{ Format(data.bill.periodStart, 'date:medium') }} – {{ Format(data.bill.periodEnd, 'date:medium') }}" ] },
{ "type": "text", "id": "summary-due-date", "content": [
"Due date", { "type": "br" }, "{{ Format(data.bill.dueDate, 'date:long') }}" ] },
{ "type": "text", "id": "text-total-due", "style": { "font-size": "14pt", "font-weight": 700 },
"rules": [ { "if": "vars.totalDue < 0", "style": { "color": "var(--negative)" } } ],
"content": "Amount due {{ Format(vars.totalDue, 'currency') }}" }
] },
{ "type": "heading", "id": "charges-heading", "level": 2, "content": "Charges" },
{ "type": "table", "id": "table-charges", "source": "vars.charges", "as": "line",
"sort": [ { "by": "line.service" } ],
"groups": [ {
"id": "by-service", "by": "line.service", "keepTogether": true,
"header": [ { "type": "text", "id": "svc-name", "class": ["group-title"], "content": "{{ group.key }}" } ],
"footer": [ { "type": "text", "id": "svc-total", "class": ["money"],
"content": "Subtotal {{ Format(Sum(group.rows.map(r, r.amount)), 'currency') }}" } ]
} ],
"columns": [
{ "id": "col-desc", "width": "3fr", "header": "Description", "cell": "{{ line.description }}" },
{ "id": "col-qty", "width": "1fr", "header": "Usage", "class": ["money"],
"cell": "{{ If(IsBlank(line.quantity), '', Format(line.quantity, 'number:0') + ' kL') }}" },
{ "id": "col-amount", "width": "1fr", "header": "Amount", "class": ["money"],
"rules": [ { "if": "line.amount < 0", "style": { "color": "var(--negative)" } } ],
"cell": "{{ Format(line.amount, 'currency') }}",
"footer": "{{ Format(Sum(region.rows.map(r, r.amount)), 'currency') }}" }
],
"empty": [ { "type": "text", "id": "no-charges", "content": "No charges this period." } ] },
{ "type": "heading", "id": "usage-heading", "level": 2, "content": "Your usage" },
{ "type": "chart", "id": "chart-usage", "if": "data.bill.usageHistory.size() > 0", "style": { "height": "55mm" },
"source": "data.bill.usageHistory", "as": "q", "kind": "column",
"category": "q.label",
"series": [ { "id": "kl", "name": "Kilolitres", "value": "q.kilolitres" } ],
"axes": { "y": { "title": "kL per day", "format": "number:1" } },
"legend": { "position": "none" },
"alt": "Average daily water use for the last {{ data.bill.usageHistory.size() }} quarters.",
"meta": { "description": "Average daily usage per quarter, newest on the right." } },
{ "type": "text", "id": "text-no-history", "if": "data.bill.usageHistory.size() == 0", "class": ["muted"],
"content": "Usage history will appear after your first full quarter." },
{ "type": "use", "id": "slip", "style": { "break-before": "page" },
"component": "remittance-slip",
"props": { "reference": "data.bill.paymentRef", "amount": "vars.totalDue", "dueDate": "data.bill.dueDate" },
"meta": { "locked": ["component", "props"] } }
],
"outputs": {
"pdf": { "standard": "PDF/UA-1" },
"xlsx": { "sheets": [ { "name": "Charges", "from": "table-charges", "freeze": "A2" } ] }
},
"tests": [
{ "name": "typical bill fits on two pages",
"data": { "bill": "samples/bill-typical.json" },
"expect": [
"pages.size() <= 2",
"diagnostics.all(d, d.severity != 'error')",
"node('slip/slip-frame').page == pages.size()"
],
"snapshot": true },
{ "name": "credit balance shows in red",
"data": { "bill": "samples/bill-credit.json" },
"expect": [ "text('text-total-due').contains('-')", "diagnostics.all(d, d.severity != 'error')" ] }
]
}29. Open questions
Listed for the next rounds of refinement. Settled questions record the decision and its date.
- Name. Settled 2026-10-19: keep RFML. Neutral alternatives (Folio, Recto, Quire, PRML) were considered and none stood out. The specification itself stays product-neutral, so the name can change later without changing the language.
- Decimal by default. Settled 2026-10-19: fractional numbers are exact decimals, whole numbers stay integers, and floating point is used only when asked for (
12.5d, or schema"format": "double"). Mixing decimal and double needs an explicit conversion. Rounding is half away from zero by default. Details in §11.2. Cost accepted: a decimal type must be added to both CEL engines. - Selectors. Settled 2026-10-19: no selectors. Elements expose fixed, named parts (§8.6) that defaults, classes, inline styles and rules can style, and components can expose parts of their own. Every style still traces to one element and one source, which keeps the property panel and AI explanations simple.
- Interpolation. Settled 2026-10-19:
{{ expr }}in strings is the only form; there are no value nodes. Strings stay readable and diff-friendly, and the designer parses them into chips and writes back only the edited chip. Scanning and escaping rules are in §7.1. - Layout algorithm. Settled 2026-10-19: formatting follows CSS by reference (Box, Flexbox L1, Grid L1, Text/Inline L3) with listed differences, such as no margin collapsing and a fixed
line-height: normal. Pagination is defined by RFML: break opportunity classes, a greedy deterministic break choice with an explicit order for relaxing keep rules, row splitting, repeating headers and continuation slots. Conformance means the same page per box, within ±0.5pt (§16). The layout suite is inconformance/layout/(§16.10). - Footnotes, multi-column flow and floats. Settled 2026-10-19: footnotes (§7.3, §16.11) and multi-column flow (§16.12) are in 0.1 at the Standard level. Floats are permanently out: flex and grid cover putting text beside an image, without making layout hard to predict.
- Interactivity. Settled 2026-10-19: full interactivity is in 0.1 at the Advanced level (§17.6): drill-down toggles, drill-through with
report()targets, setting parameters by clicking, tooltips, viewer sorting and viewer filters. Actions never run scripts: they change a view state that is part of the render input, so results stay deterministic, shareable, and the same in every format (static outputs show the initial state). - Data adapters. Settled 2026-10-19: a dataset may name a host data source (
source, withsourceParams), and the host resolves it per environment from a source catalogue that publishes parameters, a schema and sample data (§10.4). Queries and connection details never appear in documents. A companion specification for defining sources may follow. - Collaboration. Settled 2026-10-19: live co-editing by people and agents is in scope (§21.3). Nodes are keyed by id and hold their own parent, slot and fractional order, so concurrent moves never duplicate a node. Properties and style keys merge independently, last writer wins. Text content merges character by character, and expressions merge as whole values. Locks are enforced by the sync server, undo is per author, and saved and published versions stay canonical JSON. Yjs and Yrs are the reference binding.
- Markup notation. Settled 2026-10-19: the markup notation is normative and lossless in both directions (§27), with canonical JSON remaining the stored form. It is well-formed XML with a fixed mapping for nodes, slots, content and document sections, a CSS-syntax styles block, comments mapped to
meta.note, and a canonical formatting so markup diffs stay clean. The reference implementation has the parser and formatter, and the round-trip suite is inconformance/markup/(§27.1). - Security. Settled 2026-10-19: a new §25 fixes rules that always hold (data is never code; no scripts in SVG, HTML or PDF; an allowed set of link schemes; pure expressions; no file access by name; isolation; fail closed). It defines resource limits with defaults hosts may change, rules for assets, and rules for AI agents: their inputs are untrusted, they edit only through operations, and they don't see production data by default.
- Migration from earlier Reportforge templates. Settled 2026-10-19: no migration. Reportforge had no deployed templates, so its earlier template format was removed outright and the example templates were rewritten in RFML.
30. Revision history
0.2 (2026-10-28)
A minor version, for the designer and for forms: page overlays and page stamps, designer state that is saved with the document, edit operations for table rows, columns and cells, and, after an outside review of the 2026-10-24 draft, lock paths, schemaFrom, item orphans and widows, text-fit and logical sides. A document that uses none of the additions §24.1 lists still declares "rfml": "0.1"; the table operations edit any document and need no version of their own. The review also settled rules the draft left open or stated in two ways; those apply to every document. Each change is marked (0.2) in its section, or (0.2: stated) where it writes down a rule the reference engine already followed.
| Section | Change |
|---|---|
| §6.12 | Overlay children are positioned and checked as background children are |
| §6.13 | Added pagestamp: a point in the flow whose children are positioned against the page it lands on, with its if in the row's scope; the first instance of each stamp on a page draws |
| §16.13 | How page stamps are registered, laid out and painted |
| §9.1 | Added overlay to page templates and variants: a full-page frame laid out on each page and painted above everything else, each child shown by its if |
| §14.2 | A component instance adds no box: its roots are laid out where it is, as flex or grid items when it is in one, and take its style, classes and rules after their own |
| §16.11 | Footnotes are not allowed in an overlay |
| §17.2 | DOCX draws the overlay in front of the text from the section's header (output/overlay-anchored), and a page stamp as an anchored drawing in its own paragraph |
| §17.2 | A decorated flex or grid item that stretches to its row is its table cell, with spacer columns and rows around it, so it stretches in Word too |
| §17.3 | XLSX leaves the overlay and page stamps out, each with an output/unsupported warning |
| §17.5 | Paged HTML draws the overlay as a layer above the page; flow leaves it out with a warning, and shows page stamps where they sit, unpositioned |
| §17.5 | Flow HTML writes a component instance as its roots, without an element of its own |
| §18.1 | The overlay is a Pagination artifact of subtype Watermark; a page stamp is content read where it sits, or a Layout artifact when decorative |
| §19.1 | Added the document's meta.designer, designer-only state that engines ignore |
| §21.1 | Added the table operations insertRows, removeRows, insertColumns, removeColumns, mergeCells and splitCell, which keep row and column spans consistent and refuse a merge that cuts a merged cell (edit/table-span) |
| §20 | text() is the text as drawn, with soft wraps as spaces; the test functions take static rows and cells by qualified name and page bands as page:<template-id>; an id no box can have is a ref/unknown-node warning |
| §22.2 | Added the page-overlay and pagestamp box kinds. Page bands and the footnote area are named page:<template-id>; static rows and cells carry a qualified name; continuation footer rows are continuation-row. A component instance's use box is drawn around its roots, also when they are flex or grid items |
| §22.3 | Each page's copy of a page band or a repeated header is an instance of its own, keyed with [page:<n>] |
| §24.1 | Minor versions migrate with an empty step and keep their version; a document may use only the features of the version it declares |
| §27.3 | The <pagestamp> element in markup |
| §27.4 | The <overlay> element in markup |
Changes from the review of the 2026-10-24 draft (2026-10-28):
| Section | Change |
|---|---|
| Introduction, §24.1 | One closed list of what 0.2 adds; a new element type is versioned as a new property; layout and output corrections are errata for every document |
| §1.2, §3 | Render settings (schema mode, error policy, link base, extensions, samples, limits) are render inputs. Data is bound before parameters, so options can read it; sourceParams read only parameters and the render context; a failed source call is a host error |
| §1.3, §8.4, §16.1 | Absolute positioning is stated for a frame's children and the children of a page background, overlay or stamp |
| §5.1 | running is in the common properties |
| §5.2, §21.1 | Nodes and page templates share one id namespace; footnote ids are their own, and may repeat |
| §6.5 | The static grid's packing rule; overlapping spans and spans past the grid are schema/invalid-value |
| §6.10 | A heading rendered several times gives an entry per instance |
| §6.12 | left and right stay physical in right-to-left frames (the engine had mirrored them); inset-inline-* follow the direction |
| §6.13, §16.13 | Added once: page | item; only drawn instances are read; paint order and format behaviour stated |
| §7 | #id links go to the first rendered instance; one whose target rendered nothing is output/link-unresolved; ?instance= is reserved |
| §7.1, §11.7 | expr/empty-interpolation named; engines default to the fail policy, which hosts SHOULD use for production |
| §7.3 | Footnote ids, schema/footnote-id; the symbol sequence; a section is a template run |
| §8.1 | A document class merges over a theme class of its name; unknown classes and tokens are reported once the theme is resolved |
| §8.4 | Added text-fit and the logical sides; the JSON Schema is normative for values; style properties carry their version; the effects of display: none, visibility: hidden and opacity: 0 |
| §9.1, §19.3 | Page templates take meta and locks; lock entries may be paths such as style.color |
| §9.1, §9.3 | Variants don't layer; the body has no last-page test |
| §10.1, §10.4 | Added schemaFrom: "source"; {"$ref": "source"} is schema/deprecated in 0.2 documents; samples bind as data does |
| §10.3, §11.4, §12.1, §13 | Parameter and dataset names and row aliases are expression names; reserved aliases; prop names that aren't are a warning |
| §11.2, §11.5, §11.6 | Stated: promoted ints, % on decimals, If and Switch laziness, CountDistinct skips nulls, Median and Percentile, date units, Today(), ParseDate, one running value per item, 'percent', 'currency' without a code |
| §12.1 | Added orphans and widows on repeat and bound table; split rows in fragment.rows |
| §12.5 | fragment.rows on a column chunk holds whole row lines |
| §13 | What each chart kind reads; Vega-Lite is optional at Advanced, and the reference engine draws a placeholder |
| §14.2 | schema/per-root-style; a component that should be one box has a single root |
| §15 | The four bundled fonts and the names that stand in for them; ref/unknown-font |
| §16.1, §16.2, §16.4 | Percentage heights in table cells are auto; blank pages count; rows joined by a span are one unit |
| §17.1, §18.1 | A field image always needs alt |
| §17.2, §17.3, §17.5 | A stamp in a frame is recovery, not a feature; setParams and text-fit report output/unsupported; how a repeat becomes a sheet |
| §21.1 | set reaches static cells by position; renameId moves designer guides |
| §21.3, §26 | Row lists and cells with ids are keyed collections; order key grammar; collab/id-clash; the collaboration suite comes later |
| §22.3 | Group and column keys are locale-invariant, and / inside brackets is literal |
| §25.1, §25.2 | Every decoded SVG is sanitised and outputs use the sanitised one; an SVG size limit; a step defined; result sizes; render time stops, never clips, and conformance runs don't apply it |
| §26 | Tagged PDF is Core; T, footnote and page.running come with their Standard features; the suite is in conformance/ |
| §27.2, §29 | The markup root shows version="0.2"; the layout and markup suites exist |
0.1.2 (2026-10-21)
Errata that write down how the reference engine settled questions 0.1.1 left open. Each change is marked (0.1.2) in its section.
The errata pass also found places where the engine disagreed with the spec or with itself. They were decided on 2026-10-21, and the engine was changed where it differed. Each row's "Decided" note lists them.
| Section | Change |
|---|---|
| §1.4 | Interactivity is in scope (§17.6); scripts are not |
| §2 | How examples are laid out |
| §5.1 | type and id are required on every node except slot |
| §5.2 | Node id scopes; ids in keyed lists are unique within their list (schema/duplicate-id). Decided: a static row's cell ids are unique within the row |
| §6.5 | Static and bound table forms don't mix |
| §6.9 | Default barcode sizes; output/barcode-invalid |
| §6.10 | leader draws dots when not set; which headings are listed; entry text, numbers and layout. Decided: leader defaults to dots in the schema, so canonical form leaves it out; headings in continuation slots aren't listed |
| §6.12 | Frame checks; how children are positioned, painted, rotated and clipped; positioning outside frames warns |
| §7.3 | Footnote parts are styled in styles.defaults.footnote; the body inherits from footnote-area; footnotes not allowed in page bands |
| §8.1 | Engine defaults; the body inherits from the root; rule classes before rule styles; document classes replace theme classes; longhands beat shorthands; unresolved tokens; unknown classes and tokens warn. Decided: white-space is inherited; a heading's break-after: avoid and the chart axis and gridline lines are engine defaults |
| §8.4 | Decided: max-height applies to blocks |
| §8.5 | A region's rules are read together |
| §8.6 | Column styles reach header and footer cells; table row parts; row-odd counts from 1. Decided: static cells are placed counting row spans; column rules apply to static and band rows |
| §9.1 | Band placement; first per section; left is even; blank pages; backgrounds per page. Decided: bands draw charts, barcodes and extension elements, and report their diagnostics once. Added footerDistance: the footer is placed from the bottom of the page |
| §9.2 | Which properties defer page values |
| §9.4 | Repeated headers set running values again |
| §10.1 | Undeclared datasets warn; undeclared params and vars are errors. Decided: samples are used only when the host asks; otherwise a missing dataset is bind/missing-dataset |
| §10.3 | type is required; parameter scopes, with computed choices reading the item as row; multiple defaults |
| §11.3 | meta.locale beats the render context's locale; where the row alias, item, group and fragment are visible; group and region fields; more places where page values are not allowed. Decided: chart and axis titles and series names too; checkers report page values in component props |
| §11.4 | expr/var-order and expr/cycle; variable errors reported once |
| §11.5 | Running functions reset per innermost group; where they work, including group footers and continuation slots |
| §12.1 | fragment.rows in continuation headers; processing order; sorting; empty |
| §12.4 | Group identity and order; breakBefore; header keeps |
| §12.5 | Matrix headers don't repeat by default; invalid format; viewer filters and sorts |
| §13 | Default chart size; the category axis; palette tokens; gridlines; missing alt. Decided: default axis and gridline lines; stroke: none draws no line |
| §14.2 | How props are bound; component cycles; instance keys inside components |
| §14.4 | How a subreport binds, styles and reports its child; addressing; cycles. Decided: T reads the child's i18n, then the parent's |
| §15 | file assets and asset description |
| §16.1 | Line metrics from the primary font; definite heights; list markers; columns ignored on flex and grid |
| §16.2 | More monolithic boxes |
| §16.3 | Region and group headers keep with the next box. Decided: an explicit break-after: auto turns a heading's keep off; a run of headers stays whole with the next box |
| §16.6 | Decided: variants match in the order blank, last, first, left/right, so last wins on a one-page section |
| §16.7 | A kept-together element that doesn't fit an empty page breaks by relaxing R3, as §16.3 says, rather than being monolithic |
| §16.8 | Repeated headers and continuation slots are placed whole and aren't progress. Decided: both continuation slots are repeated content, tagged as Pagination artifacts |
| §16.9 | First-pass values; when a pass is final; the warning's severity. Decided: a deferred value in a subreport translates with the child's i18n first, as other values there do |
| §16.11 | Footnotes in page headers, footers and backgrounds are an error (schema/not-allowed-here); numbering and shared markers; footnote-only pages. Decided: so is a component used there whose template has a footnote |
| §16.12 | A nested multi-column box is laid out as one column |
| §17.1 | attachments and view; what the PDF standards require. Decided: the PDF/UA document checks, and the a11y/missing-alt errors PDF/UA adds, are errors only at design time and in PDF renders; other formats get warnings |
| §17.2 | DOCX page fields, variants, frames, backgrounds and group headers. Decided: page-dependent property values warn, as run values do |
| §17.3 | XLSX sheets, names, typed cells, freezing, filters, outlines, matrices and footnotes. Decided: output/sheet-unmatched; a dropped freeze warns |
| §17.5 | html-paged and html-flow; page values in flow. Decided: page-dependent property values in flow warn too |
| §17.6.1 | The base view state per format; how bad view state is reported; what is left out |
| §17.6.3 | Toggle targets and keys; a group-header toggle hides that group's rows; column toggles; indicators; XLSX outlines. Decided: ids a toggle can't show or hide are interaction/unknown-target |
| §17.6.4 | Report links without a link base, in every format; rejected targets |
| §17.6.5 | Sorting doesn't change what limit keeps; what can be sorted; filters apply before limit; selections. Decided: XLSX header cells have no sort indicator |
| §17.6.6 | Decided: chart actions are evaluated per data point, with the row alias and series bound; each drawn point with an action is a chart-point box and, in HTML, a rect with the action hooks |
| §18.1 | Severities of missing alt and heading skips; tagged artifacts. Decided: continuation slots are Pagination artifacts |
| §18.2 | T() falls back from the effective locale. Decided: in a subreport, the child's messages come first; checkers checking a subreport as part of its parent count the parent's keys, and on its own a missing key is a warning |
| §19.3 | What a lock covers |
| §20 | Test result fields; missing test data; snapshots; extensions |
| §21.1 | How operations address slots and positions, and what each changes |
| §21.2 | A batch is rejected when it adds errors. Decided: missing alt text on nodes the batch inserts doesn't count |
| §22.1 | Diagnostics on repeated content are reported once. Decided: output/sheet-unmatched |
| §22.2 | Page coordinates; box kinds; actions and filters. Decided: chart-point boxes carry chart actions per data point |
| §22.3 | Keys name positions, not nodes; instances inside components; separators |
| §23 | Function names; allowed namespaces; placeholders; formats without an implementation |
| §24.1 | Migrations run before validation, subreports too; what counts as an unknown feature of a newer minor version; engines list the versions they read |
| §24.2 | Canonical property order, style blocks, content, numbers and defaults |
| §25.3 | Image and SVG diagnostics |
| §27.2 | Comments and the strict XML reader |
| §27.3 | Mapping details |
| §27.5 | The example parses: CSS comments are not allowed; one @import; :root holds tokens; repeated selectors merge |
| §27.6 | Long start tags; no breaks inside {{ }} |
| §28 | The example is in canonical property order |
0.1.1 (2026-10-19)
Clarifications to 0.1, marked (0.1.1) where they appear.