Skip to content

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 ​

  1. Introduction
  2. Conventions
  3. Processing model
  4. Document structure
  5. Nodes
  6. Element reference
  7. Inline content
  8. Styling
  9. Pages
  10. Data and parameters
  11. Expressions
  12. Data regions
  13. Charts
  14. Components and subreports
  15. Assets and fonts
  16. Layout and pagination
  17. Outputs
  18. Accessibility and localisation
  19. Metadata, locks and authoring hints
  20. Tests
  21. Edit operations
  22. Diagnostics and the layout model
  23. Extensions
  24. Versioning and canonical form
  25. Security
  26. Conformance levels
  27. Markup notation
  28. Complete example
  29. Open questions
  30. 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 ​

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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 ​

RFMLHTML analogueDifference
boxdivLayout through display: block | flex | grid only
text, headingp, h1–h6Content is inline content (§7) with {{ }} interpolation
span, link, brspan, a, brInline only
list, itemul/ol, li
tabletableCan be static or bound to data, with groups and totals
image, ruleimg, hr
framediv with position: relativeAbsolute 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, matrixnoneData regions
chart, barcodenoneBuilt-in visual elements
use, slotcustom elements, <slot>Components with typed props
subreportiframe (loosely)Embeds another document in the page flow
pagebreakCSS break-before: pageShorthand element
pagesCSS @pagePage templates with header, footer, background and overlay slots
style propertiesCSS propertiesA subset, same names and units, no selectors
expressionsJavaScriptCEL 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 CSS kebab-case.
  • Expr means a string containing an expression (§11). Content means inline content (§7). Length means 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).

  1. Load. Parse the JSON, read rfml, and migrate older versions to the engine's version (§24).
  2. Resolve. Resolve imported components, themes, assets and subreports to pinned versions.
  3. 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.
  4. Bind data. Check each dataset against its schema in the configured mode (§10.2).
  5. 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).
  6. Expand. Evaluate vars, if conditions 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.
  7. 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).
  8. 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:

PropertyTypeRequiredPurpose
$schemastringSHOULDURL of the RFML JSON Schema version
rfmlstringMUSTLanguage version, e.g. "0.2"
idIdMUSTStable document id
metaMetaMUSTTitle, description, locale and authoring metadata (§19)
paramsParam[]MAYReport parameters (§10.3)
datamap of DatasetMAYDeclared datasets (§10.1)
varsmap of ExprMAYDocument-level computed values (§11.4)
stylesStylesMAYTokens, defaults, classes, theme (§8)
assetsmap of AssetMAYImages, fonts and other files (§15)
importsImport[]MAYComponent and theme libraries (§14.3)
componentsmap of ComponentMAYLocal component definitions (§14)
i18nmap of MessagesMAYMessage catalogues by locale (§18.2)
pagesPageTemplate[]MUSTAt least one page template (§9)
footnotesFootnoteSettingsMAYNumbering and placement of footnotes (§7.3)
bodyNode[]MUSTThe document content
outputsOutputsMAYPer-format settings (§17)
testsTest[]MAYEmbedded test cases (§20)
x-*anyMAYExtension 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:

PropertyTypePurpose
typestringElement type. MUST be a core type or a namespaced extension type
idIdUnique within the document (component internals have their own scope, §14.2)
classstring[]Named style classes, applied in order
styleStyleInline style declarations (§8)
rulesRule[]Conditional styles (§8.5)
ifExpr → boolRender the node only when this is true
langstringBCP 47 language tag for this subtree
runningmap of ExprRunning values set when the node is placed on a page (§9.4)
exportExportHintsPer-format hints (§17.4)
actionsAction[]What happens when a viewer clicks or hovers (§17.6)
metaNodeMetaLabel, 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 Id matches ^[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 target names either (§21.1): a node with a page template's id is schema/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, rows and footer, a region's groups, a chart's series, a region's filters, a matrix's rows, columns and cells) only need to be unique within that list. So do the optional ids of a static row's cells, within the row (0.1.2). A duplicate is schema/duplicate-id. (0.1.2)
  • Ids are stable: tools MUST NOT change an existing id except through the renameId operation (§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 in Content properties, never in slots.
  • Positioned: any block element, inside a frame only, with position properties (§6.12). The children of a page template's background and overlay (§9.1) and of a pagestamp (§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.

PropertyTypeNotes
childrenNode[] (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.

PropertyTypeNotes
contentContentRequired

6.3 heading ​

A heading. It takes part in document outline, bookmarks, table of contents and tagged PDF structure.

PropertyTypeNotes
level1–6Required
contentContentRequired
bookmarkboolDefault true. Adds a PDF bookmark

6.4 list and item ​

ElementPropertyTypeNotes
listmarkerdisc | circle | square | decimal | lower-alpha | upper-alpha | lower-roman | upper-roman | noneDefault disc
liststartintegerFirst number for ordered markers
listchildren(item | repeat)[]
itemchildrenNode[] (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:

PropertyTypeNotes
columnsColumn[]Column widths and per-column styles
headerRow[]Repeats on each page by default
rowsRow[]
footerRow[]
repeatHeaderboolDefault 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 columns declares, or, without columns, as many as the widest row's colspans add up to.
  • Each cell takes the next column that no rowspan from a row above covers, and covers colspan columns and rowspan rows 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 rowspan that runs past the last row of its section, and a cell that would overlap one spanning down from above are schema/invalid-value when 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 ​

PropertyTypeNotes
assetasset idStatic image from assets
srcExpr → stringDynamic image: an asset id, a library reference, or a data: URL from the data
altContentRequired unless decorative is true (a11y/missing-alt error)
decorativeboolMarks the image as an artifact in tagged PDF
fitcontain | cover | fill | noneSame 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).

PropertyTypeNotes
pagepage template idOptional

6.9 barcode ​

PropertyTypeNotes
symbologyqr | datamatrix | pdf417 | code128 | code39 | ean13 | ean8 | upca | itf | aztecRequired
valueExpr → stringRequired
showTextboolHuman-readable text under linear codes
errorCorrectionL | M | Q | HQR only
altContentAlternate text. Without it, the encoded value is the alt text
decorativeboolMarks the barcode as an artifact in tagged PDF and hides it from screen readers in HTML
  • Without a width or height in its style, a linear code is drawn with 1pt modules, quiet zones included, and 36pt bars, plus a line of text with showText. 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-invalid warning. (0.1.2)
  • A barcode always has alt text: its alt, or else its encoded value, so a barcode without alt raises no diagnostic. A payment QR code reads better with "alt": "Scan to pay invoice {{ invoice.number }}" than with its URL. A decorative barcode has none: it is an artifact in tagged PDF, aria-hidden in HTML and marked decorative in DOCX (§18.1).

6.10 toc ​

A generated table of contents built from heading elements, with page numbers.

PropertyTypeNotes
levels1–6Deepest heading level included; default 3
leaderdots | noneDefault 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 continuationHeader and continuationFooter (0.1.2). A heading's bookmark doesn'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 detail or a component used several times, gives one entry per rendered instance, each linking to that instance (0.2). A heading in a region's detail therefore lists every item; put the heading in a group's header to list each group once.
  • An entry is one monolithic box, styled by the entry and entry-<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 ​

  • repeat, the bound table, and matrix: §12
  • chart: §13
  • use, slot, subreport: §14
  • pagestamp: §6.13

6.12 frame ​

A fixed-size container whose children are positioned absolutely, for forms, payment slips, labels and pre-printed stationery.

PropertyTypeNotes
childrenNode[] (positioned)
  • A frame MUST have a definite width (or fill its parent's width) and a definite height.
  • A frame never breaks across pages: break-inside: avoid is implied and cannot be overridden.
  • Each child MUST set top or bottom, left, right, inset-inline-start or inset-inline-end (0.2), and SHOULD set width. Positions are relative to the frame's padding box.
  • Children that overflow the frame are clipped, with a layout/frame-overflow diagnostic.
  • z-index orders overlapping children; default is document order.

(0.1.2)

  • A frame without a height, or a child without a vertical inset (top or bottom) or a horizontal one (left or right), is a schema/missing-property error at /style. The values may come from the node's own style, its classes or styles.defaults. The same holds for the children of a page template's background, and of its overlay (0.2).
  • Children are positioned as in CSS absolute positioning, with the frame's padding box as their containing block. A child with both left and right is stretched between them; a child with one of them and no width shrinks to fit its content. Percentages refer to the padding box.
  • z-index changes only the painting order. Reading order and the layout model stay in document order (§18.1).
  • rotate turns a child about the centre of its border box. Overflow is checked before rotation.
  • layout/frame-overflow is a warning, raised once for each child that overflows.
  • Insets stay physical in right-to-left text (§16.1): left measures from the frame's left edge whatever its direction. inset-inline-start and inset-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 background or overlay (0.2), nor a page stamp's child, raises a schema/not-allowed-here warning. A page stamp's own z-index is allowed (§6.13). Classes and styles.defaults aren'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.

PropertyTypeNotes
childrenNode[] (positioned)Positioned against the page, as a page overlay's children are
decorativeboolDefault false. Marks the stamp as an artifact: tagged output leaves it out of the structure, and HTML hides it from screen readers
oncepage | itemDefault 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 detail and group bands, a bound table's headerRows, footerRows, group bands and a column's cellChildren, 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-here error in a page template's header, footer, background or overlay (its own or a variant's), anywhere inside a frame, and inside another pagestamp. 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 if is an ordinary condition (§5.1), evaluated where the stamp sits, so it sees the row alias, item, group and the region's variables. Like any if, 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 top or bottom and left or right, insets and percentages are of the page, z-index orders them among themselves, and what overflows the page is clipped.
  • Of the stamp's own style, only z-index, opacity and visibility apply. z-index orders 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's rules read 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 decorative stamp 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 if holds; DOCX writes the instances the layout drew; XLSX leaves stamps out with an output/unsupported warning (§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. Use br for a forced line break, or set white-space: pre-line on the node.
  • href may 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 (an if that failed, a region with no items, display: none), the link is left out and its text kept, with an output/link-unresolved warning (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(-*) and border-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 takes width and height.

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 null inserts nothing.
  • An interpolation error raises a diagnostic. Under the fail error policy the render fails; under continue the 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 title if there is one), and otherwise a short form of the expression or the node's meta.label.
  • A Format pattern 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 footnotes settings:

    json
    "footnotes": { "numbering": "decimal", "restart": "document", "maxHeight": "50%" }
    SettingValuesDefault
    numberingdecimal | lower-alpha | lower-roman | symbols (*, †, ‡, §, …)decimal
    restartdocument | section | pagedocument
    maxHeightLength or % of the content area50%

    (0.2: stated) symbols runs * † ‡ § ‖ , then the same six doubled (** … ¶¶), then tripled, and so on without end. A section for restart is 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.

  • marker replaces 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 id and 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 id whose rendered content differs get a marker each, and footnote(id) returns the first one's. Two footnotes written with the same id and different content raise a schema/footnote-id warning, 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-separator and footnote-body style 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 style or class of its own. Its parts are styled in styles.defaults.footnote.parts, in the document or its theme. By default the marker is vertical-align: super; font-size: 70%. A footnote's body inherits from the footnote-area part, 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):

  1. Engine defaults.
  2. The theme named by styles.extends, if any.
  3. styles.defaults[<element type>], and styles.defaults.document for the root.
  4. Each class in the node's class, in order.
  5. The node's style.
  6. 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: 10pt and color: #000. Headings add font-weight: 700 and break-after: avoid (§16.3), the footnote marker has its own default (§7.3), and so do the chart parts axis and gridline (§13). Being engine defaults, they give way to every later source: break-after: auto on a heading turns its keep off (0.1.2). Layout defaults are given where they apply: the line of a rule in §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's content part, which styles only the page's content box.
  • A rule's class entries apply before the rule's own style.
  • A document class with the same name as a theme class is applied right after it, as styles.defaults are 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-value warning. A token that refers to itself through other tokens is a schema/token-cycle error, 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 parts key mapping part names to further style blocks (without their own parts). The same shape is used in defaults, classes, a node's style, and in rules.
  • 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-]*$.
  • defaults keys 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 support cmyk() 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 ​

GroupProperties
Textcolor, 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
Spacingmargin(-*), padding(-*), margin-inline-start, margin-inline-end, padding-inline-start, padding-inline-end (0.2)
Sizewidth, height, min-width, max-width, min-height, max-height, box-sizing
Borderborder(-*), border-radius, outline
Backgroundbackground-color, background-image (asset reference), background-size, background-position, background-repeat
Layoutdisplay (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-columncolumns, column-count, column-width, column-gap, column-rule(-*), column-fill (balance | auto), column-span (none | all) (§16.12)
Paginationbreak-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 overflowvisibility (visible | hidden), opacity, overflow (visible | clip), text-overflow (clip | ellipsis), text-fit (none | shrink) (0.2)
Tableborder-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 direction declaration, 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) as left and right do (§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: nonevisibility: hiddenopacity: 0
Box and spaceNoneLaid out; takes its spaceLaid out; takes its space
DrawnNoNo, nor anything inside it: visibility isn't inherited, so a descendant can't show itselfYes, transparent
Read in tagged output, links and actionsNo; a link to it is unresolvedNo; viewers attach none of its actionsYes
Its footnotes, table of contents entries, bookmarksNoneNoneAs for any content
Its running values and page stampsNoneSet and registered, since it is placedAs for any content
Its boxes in the layout modelNonePresent, so a designer can select itPresent

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: slice does; the middle pieces are open at both sides. box-decoration-break applies 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.

ElementParts, from general to specific
tablerow, row-odd, row-even; cell; header-row, header-cell; footer-row, footer-cell; group-header, group-footer; continued-header; empty
repeatheader; item, item-odd, item-even; separator; footer; group-header, group-footer; empty
matrixcorner; row-header, column-header; cell; total-row, total-column, total-cell
listitem, marker
tocentry, entry-1 … entry-6, leader, page-number
charttitle, axis, axis-label, gridline, legend, legend-label, data-label
barcodetext
page templatesheader, footer, content (set in pages[].style.parts)
any element with a toggle actiontoggle-indicator
sortable table columnssort-indicator (on the header cell)
regions with filtersfilter-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 cell and then header-cell; an even row matches row and then row-even.
  • Group parts nest: in nested groups, group-header applies at every level. The scope variable group.level lets rules tell levels apart.
  • A table column's own style, class and rules apply to that column's cells after the table's cell part styles. They apply to its header and footer cells too. parts in 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's rules are 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, item or group) 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 (not row), with cells cell then header-cell; footer rows match footer-row, with cells cell then footer-cell. Group band rows match group-header or group-footer, with cells cell. A continuationHeader's rows match continued-header, and a continuationFooter's rows match footer-row.
  • row-odd and row-even (and item-odd, item-even) count from 1 within the innermost group, so the first row is odd, as item.odd is (§11.3). Rows hidden by if or a toggle aren't counted. (0.1.2)
  • Chart parts accept only color, font-*, opacity, stroke (line colour), stroke-width and stroke-dasharray. Other properties raise schema/unsupported-part-property.
  • A list's marker part accepts only the text properties (§8.4). Other properties raise schema/unsupported-part-property.
  • The chart parts gridline and axis carry engine defaults (§8.1): gridline stroke: #dde1e6; stroke-width: 0.5pt, axis stroke: #8c939d; stroke-width: 0.75pt. An explicit stroke: none removes 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.

PropertyTypeNotes
idIdRequired
sizeA3 | A4 | A5 | B5 | letter | legal | tabloid or { "width": Length, "height": Length }Required
orientationportrait | landscapeDefault portrait
marginCSS margin shorthand
footerDistanceLengthFrom the page's bottom edge to the footer's bottom edge (0.1.2)
headerNode[] (block)Placed in the top margin area
footerNode[] (block)Placed in the bottom margin area
backgroundNode[] (positioned)Drawn behind content, as a full-page frame (watermarks, letterheads)
overlayNode[] (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)
bleedLengthFor print outputs
metaNodeMetaLabel, 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 last variant 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-grown is a warning, raised once per page. (0.1.2)
  • The footer is placed from the bottom of the page: its bottom edge is footerDistance above the page's bottom edge, and a taller footer grows upwards. A percentage is of the page width, as for margins. When footerDistance isn'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)
  • first applies to the first page of each section. left and right go 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 first alone, so its margins and bands are first's, falling back to the template's, never to right's. To give it both, repeat the shared settings and bands in each variant.
  • A blank page (§16.2) without a blank variant uses the variant it would otherwise have, with that variant's header, footer and background. (0.1.2)
  • The background is laid out again on each page, so page values work in it. It is painted before anything else on the page. A variant's background replaces 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 overlay is laid out as the background is: 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), and z-index orders them among themselves.
  • It is painted last, above the content, the header, the footer and the footnote area.
  • A variant's overlay replaces the template's, and an empty list removes it. A blank page without a blank variant has the overlay of the variant it would otherwise have, as it has its background.
  • Each child's if decides whether it shows, so a stamp can depend on data, parameters or the output format ("if": "params.status != 'final'"). As anywhere, if can'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 a pagestamp in 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:

ValueMeaning
page.number1-based page number within the document
page.countTotal pages in the document
page.sectionNumber, page.sectionCountSame, within the current template section
page.templateId 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.

PropertyTypeNotes
sourceSourceRefOptional. A host data source that supplies this dataset (§10.4)
sourceParamsmap of ExprValues passed to the source. Evaluated with params, now, locale and timezone only
schemaJSON Schema (2020-12), or { "$ref": "<bundle path>" }Optional. MAY be inferred and MAY be partial
schemaFromsource(0.2) The schema is the one the dataset's source publishes (§10.4). Needs source, and rules out schema
sampleinline value or asset/library referenceSample data for the designer, tests, previews and AI agents
descriptionstringPurpose of the dataset, for people and agents
requiredboolDefault 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 ​

PropertyTypeNotes
nameexpression name (§11.4)Read as params.<name>. ^[A-Za-z_][A-Za-z0-9_]*$ (0.2: was Id)
typestring | number | integer | boolean | date | datetimeRequired (0.1.2)
labelContentPrompt text
descriptionstring
defaultExprMay refer to now and earlier params
requiredboolDefault false
multipleboolThe 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
hiddenboolNot shown in prompts; set by the host or by a parent subreport
  • Parameters are ordered. A parameter's default and options MAY refer to parameters earlier in the list, which gives cascading parameters.
  • Scopes (0.1.2): a default sees earlier parameters, now, locale and timezone. Computed options add data: source is evaluated in that scope, and value and label are evaluated once per item of the source, with the item as row. A reference to a later parameter is an expr/var-order error. None of them can use page.
  • The default of a multiple parameter 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 SourceRef is 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 sourceParams and 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 sourceParams key the source doesn't declare, a missing required source parameter, or a type mismatch is an error (bind/source-param). An unknown source is ref/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. Use Contains, StartsWith and EndsWith. (0.1.1)
  • The engine MUST enforce limits on evaluation cost and result size, and fail with expr/limit when 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 / 4 is 2.5, 1 / 3 is 0.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 a date if the schema says "format": "date", and as a timestamp if it says "format": "date-time".

How JSON numbers in data are typed:

Schema saysValue 12Value 12.50
"type": "integer"interror (bind/type-mismatch)
"type": "number"decimaldecimal
"type": "number", "format": "double"doubledouble
no schemaintdecimal

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:

  • int with decimal promotes to decimal. 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.50 is 2.50, and 2 * 1.50 is 3.00 (0.2: stated).
  • decimal with double is a type error. Convert explicitly with Decimal(x) or Number(x). The type checker reports it at design time as expr/mixed-precision.
  • Comparisons follow the same rules, so Sum(rows.map(r, r.amount)) == 0.3 compares 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 ​

NameAvailableMeaning
dataeverywhereThe datasets
paramseverywhereParameter values
varseverywhereDocument and region variables (§11.4)
noweverywhereRender time, fixed for the whole render
locale, timezoneeverywhereThe effective locale and time zone: a node's lang, then meta.locale, then the render context (0.1.1)
doceverywheredoc.id, doc.title, doc.version
row alias (row by default)inside a region's detailThe current item. Set with as
iteminside 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)
groupinside 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)
regioninside a region{ rows, count } after the region's filter, the viewer's filters, sort and limit, before grouping (0.1.2)
propsinside a componentThe component's props
pagedeferred contextsSee §9.2
fragmenta region's continuation slotsItems on this page and before it (§12.1)
outputeverywhere{ 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, sort and per-item vars, a group's by and sort, a viewer filter's by, a repeat's detail, and a column's cell, cellChildren, rules and sortBy. It is not visible in headers, footers, headerRows, footerRows, separator, empty or the continuation slots. Matrix cells see cell instead (§12.5).
  • item is visible in the detail and in the headers that repeat: a repeat's header, a table's column headers and headerRows, group headers, and the continuation slots. Its index (from 0), number (from 1), first, last, odd and even are positions within the innermost group, or within the region if it has no groups.
  • group is visible in group headers and footers, the detail, a group's sort and vars, and the per-row vars of a repeat or table. A group's vars join vars in 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's cell, cellChildren and rules. 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 a ref/unknown-var error.
  • fragment is 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 vars are 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 vars are evaluated after the groups around it, so they may read those. (0.1.2)
  • A repeat's or table's per-row vars are evaluated for each item after its groups', so they may read the row alias, item, group and 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-order error; a fix reorders the variables when there is no cycle. A cycle is an expr/cycle error, 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 is schema/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 a schema/invalid-value warning, 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)).

GroupFunctions
AggregatesSum, 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)
RunningRunningSum(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
LogicIf(cond, a, b), Switch(value, k1, v1, …, default), Coalesce(a, b, …), IsBlank(x)
TextUpper, Lower, Proper, Trim, Left, Right, Mid, Len, Replace, Contains, StartsWith, EndsWith, Split, Join, PadLeft, PadRight
NumberRound(x, digits, mode?), RoundUp, RoundDown, Floor, Ceiling, Abs, Mod, Power, Sqrt
DateToday(), 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)
LookupLookup(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)
Navigationreport(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
FormatFormat(value, pattern, locale?) (§11.6)
LocalisationT(key, args?) reads i18n messages (§18.2)
ConversionNumber(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):

  • If evaluates its condition, then only the branch it picks, so If(true, 1, 1/0) is 1. A condition that is an error makes If that error; one that isn't a bool is expr/type. Switch and Coalesce stop at the first match the same way.
  • Aggregates. CountDistinct skips nulls, as every aggregate but Count does (before 0.2 the reference engine counted null as a value). Median(list) is Percentile(list, 0.5). Percentile(list, p) takes p from 0 to 1 and interpolates as spreadsheets' PERCENTILE.INC does: sorted values v, rank p × (n − 1), and the value between v[floor(rank)] and the next, proportionally. So the median of an even count is the mean of the two middle values. A p outside 0 to 1 is an error value.
  • Dates. DateAdd(d, n, unit) adds n units: day, week, month or year (plural forms too) to a date, and also hour, minute and second to 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 from a to b, positive when b is later and truncated towards zero: day, week, month, quarter, year, and for two timestamps also hour, minute and second. Today() is the calendar date of now in the effective timezone. ParseDate on 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 vars and 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 continuationHeader and continuationFooter they 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 is x at the last of those items.
  • Anywhere else, including inside components, a call is an expr/evaluation error.
  • 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 if hid 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).
  • RunningSum and RunningAvg skip nulls. Previous is null on 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.

PatternExample 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. IsBlank and CEL has() 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 is fail (0.2). Hosts SHOULD use fail for published and production renders, so a bill can't go out with a blank amount, and MAY use continue for 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 if that evaluates to an error counts as false, and raises its diagnostic like any other error, so under fail it 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 ​

PropertyTypeNotes
sourceExpr → listRequired. Evaluated in the enclosing scope
asexpression name (§11.4)Row alias, default row. Not a reserved name (0.2: was Id)
filterExpr → boolApplied first
sort{ "by": Expr, "direction"?: "asc" | "desc", "nulls"?: "first" | "last" }[]Applied after filtering. Stable
limitintegerApplied after sorting
groupsGroup[]Nested groups, outermost first (§12.4)
varsmap of ExprPer-item variables
emptyNode[]Rendered instead when there are no items
filtersViewerFilter[]Filters the viewer offers to readers (§17.6.5)
continuationFooterNode[] | Row[]Placed at the bottom of each page the region continues past, e.g. "Carried forward"
continuationHeaderNode[] | Row[]Placed at the top of each continuation page, after repeated headers, e.g. "Brought forward"
orphans, widowsinteger ≥ 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 filter applies first, then the viewer's filters (§17.6.5), then sort, then limit, then grouping.
  • Sorting is stable and never fails. Nulls sort last whatever the direction, unless nulls is first. Values of different kinds sort by kind: null, booleans, numbers, strings, dates, timestamps, durations, bytes, then anything else. Numbers compare by value across int, decimal and double.
  • empty replaces only the items. The region's header and footer, and a table's column header and footer rows, are still rendered.

12.2 repeat ​

PropertyTypeNotes
headerNode[]Before the items. Repeats on each page if repeatHeader is true
detailNode[]Rendered for each item. Required
footerNode[]After the items
repeatHeaderboolDefault false
separatorNode[]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:

PropertyTypeNotes
columnsColumn[]Required
repeatHeaderboolDefault true
headerRows, footerRowsRow[]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 header and footer are either Row[] (cells aligned to the columns) or Node[] (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 1 and 1.00 are the same group. Groups are first taken in the order their keys first appear, then sorted stably by sort, which defaults to the key ascending. A null key sorts last.
  • breakBefore: page starts 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.

PropertyTypeNotes
rowsGroup[]Required. Outermost first
columnsGroup[]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
cornerContentTop-left header
repeatRowHeaders, repeatColumnHeadersboolRepeat 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 (its parent is the enclosing group on the same axis) and the group's vars; with no header, it shows the group key.
  • Each cell is evaluated once per row line and column line, with cell.rows holding the items in both (possibly none). format formats the value as Format(value, format) does; typed outputs keep the value. A format that isn't a valid pattern is an expr/invalid-pattern error. (0.1.2)
  • Viewer filters apply to a matrix. Viewer sorting doesn't: a sort on a matrix is ignored, with an interaction/unknown-target warning. (0.1.2)
  • totals.rows adds a subtotal line after the lines of each group that has inner groups, and a grand total line at the end; totals.columns does the same for columns. A total's label (totals.label, default Total) sits at the level below the group it totals and covers the inner levels; the grand total's covers every level. A total line's cell.rows holds 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. With repeatRowHeaders, 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.rows holds 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.

PropertyTypeNotes
sourceExpr → listRequired
asexpression name (§11.4)Row alias (0.2: was Id)
kindcolumn | bar | line | area | pie | donut | scatter | comboRequired
categoryExprCategory (x) value per row
seriesSeries[]Required
stackednone | normal | percent
axes{ "x"?: Axis, "y"?: Axis, "y2"?: Axis }
legend{ "position": "top" | "bottom" | "left" | "right" | "none" }
labels{ "show": bool, "format"?: string }Data labels
titleContent
palettevar(--token)[] or coloursDefault comes from the theme
altContentText 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):

kindcategoryseriesstackedaxes
column, bar, line, areaThe x axis's valuesAny number; axis: y2 puts one on the second value axisSeries of the same kind on the same axis stackx, y, y2
comboThe x axis's valuesEach series' kind (column, bar, line, area, scatter; default column)As above, per kindx, y, y2
scatterThe x value: a numeric axis when every category is a number, else a category axisAny number, drawn as pointsIgnored: points don't stackx, y, y2
pie, donutThe slice labels, also listed in the legendThe first series only; its zero, negative and null values draw no sliceIgnoredIgnored

(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.x is always the category axis, and y and y2 the value axes, also when kind: bar draws the bars horizontally.
  • Without a palette, series take the tokens --chart-1 to --chart-12 from 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-point boxes (§17.6.6) and in the native charts of DOCX and XLSX.
  • The axis and gridline parts have engine defaults (§8.1): axis lines stroke: #8c939d; stroke-width: 0.75pt, gridlines stroke: #dde1e6; stroke-width: 0.5pt. stroke: none on a chart part draws no line. A part that sets only stroke keeps the default width. (0.1.2)
  • A chart without alt, or with an empty alt, raises an a11y/missing-alt warning, 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" } */ ]
  }
}
  • props is 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 props plus 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; children is the default content.
  • parts MAY 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, style and rules, 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 in lenient mode (§10.2): a mismatch is a bind/type-mismatch warning at /props/<name>, and a string with "format": "date" becomes a date.
  • 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/cycle error.
  • An inner node's instance key is the instance's own key (§22.3): the node slip/slip-amount has the key slip, or table-x[2]/slip when 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, style and rules apply 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: page breaks before each root, and a border draws round each. A checker warns with schema/per-root-style when an instance of a multi-root component sets one of those in its own style or rules (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-shadowed warning.

14.4 subreport ​

Embeds another RFML document in the current page flow.

PropertyTypeNotes
reportstringDocument reference, e.g. "library:statements/line-items"
versionsemver rangePinned at publish
paramsmap of ExprValues for the child's parameters
datamap of ExprValues 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 body is laid out in place of the subreport node. Its root style is its own theme and styles.defaults.document over the subreport node's style.
  • params and data are evaluated in the caller's scope. The values in data are the child's datasets as they are; they are not bound again. params are bound against the child's parameter declarations. The child evaluates its own vars, and sees doc as itself. now, timezone and output are the parent's; the child formats with its own meta.locale, else the parent's locale. T in the child looks for each key in the child's i18n first, then the parent's (§18.2) (0.1.2).
  • A params key the child doesn't declare is ref/unknown-param at /params/<name>, and a data key it doesn't declare is ref/unknown-dataset at /data/<name>. A required child parameter without a default that params leaves out is schema/missing-property at /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/cycle error. Nesting deeper than the limit (§25.2) stops the render with security/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" }
}
  • kind is image, font or file. A file asset is any other file, such as one attached to a PDF/A-3 output (§17.1). An asset MAY have a description, which such an attachment carries. (0.1.2)
  • src is a host library reference, a relative path inside a document bundle, or a data: URL. Remote http(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-family in styles refers to the family of 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-family list, 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, cursive and fantasy for Noto Sans; monospace and ui-monospace for Noto Sans Mono; Helvetica, Helvetica Neue, Arial and Liberation Sans for Arimo, which has their metrics; Courier and Courier New for Noto Sans Mono.
  • A family in a font-family value that matches no font asset, no theme or render font, no bundled family and none of those names raises ref/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:

TopicRFML rule
Margin collapsingMargins never collapse. Vertical space between siblings is the sum of their margins.
Floats, positionNo 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 heightsResolve 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: normalExactly 1.2 × font-size, not taken from font metrics, so line heights don't change when a font is substituted.
Inline box metricsAscent 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 shapingOpenType shaping with kerning and standard ligatures, HarfBuzz-compatible. No estimated text widths.
Line breakingUnicode UAX #14; hyphenation with hyphens: auto when the locale has patterns.
Bidirectional textUnicode 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 fallbackThe 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:

ClassWhere
ABetween adjacent block siblings in a block or column flex flow
BBetween two lines of a paragraph
CBetween rows: table rows, grid rows, region items, wrapped flex lines
DInside 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:

  1. Find the last break opportunity before the overflow point.

  2. Check it against these rules:

    • R1 Orphans and widows. A paragraph split leaves at least orphans lines before and widows lines after (default 2 each).
    • R2 Keep with neighbour. No break between boxes where the earlier one has break-after: avoid or the later one has break-before: avoid. Headings have break-after: avoid by default, as an engine default (§8.1), so an explicit break-after: auto on 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 with keepTogether, or a region item.
  3. 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.

  4. 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-break warning 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.

  5. 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: auto set 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 rowspan are 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), with layout/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. clone draws 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 a layout/overflow error.
  • clip: the box is clipped to the content area, with a layout/overflow error.

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:

  1. Its repeating headers are placed at the top of the content area, outermost first: the table header or region header (with repeatHeader), then each open group's header (with repeatHeader).
  2. Then its continuationHeader, if it has one (§12.1).
  3. On the page it is leaving, its continuationFooter is 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 a layout/footnote-split info 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, background or overlay (0.2), its own or a variant's, including content passed to a component's slot there. Checkers report it as a schema/not-allowed-here error 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's component (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 an output/unsupported info diagnostic. (0.1.2)
  • Markers are numbered in document order, starting again at the start of each document, section or page as footnotes.restart says. Footnotes that share an id and 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: column and break-after: column force a column break.
  • With column-fill: balance (default), the columns on the box's last page are balanced to roughly equal height. With auto, columns fill one after another.
  • A child with column-span: all interrupts the columns, spans the full width, and the columns resume below it.
  • Multi-column boxes cannot be nested inside each other (layout/nested-columns error). 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-rule draws 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.continued in a repeated header, fragment in a continuation slot). Its children are clipped to the page, and a child that overflows it raises a layout/frame-overflow warning, once per render.
  • Stamps belong to the layout of the page that registered them. A page laid out again (the last variant, §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-index order (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:

PropertyValues
standardnone | PDF/A-2b | PDF/A-3b | PDF/UA-1 | PDF/A-2a+UA-1
taggedbool, default true
compress, embedFontsbool
metadata{ "author"?, "subject"?, "keywords"? } (title comes from meta.title)
attachmentsasset ids, of kind: "file" assets or images (§15). Embedded only with PDF/A-3b (0.1.2)
viewA 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-1 and PDF/A-2a+UA-1 always produce a tagged PDF. Setting tagged: false with either is an error (a11y/untagged-pdf). With either standard, these are also errors: no meta.title, or a blank one (a11y/missing-title); no meta.locale and no locale in the render context (a11y/missing-lang); an image or chart without alt text that isn't decorative, or a field image without alt text (a11y/missing-alt). A field image has no decorative: it always needs alt (§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-pdf and the a11y/missing-alt errors 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/standard error. When the error policy is continue (§11.7), the PDF is written without the standard, and an output/standard error at /outputs/pdf/standard says so.
  • attachments with a standard other than PDF/A-3b are left out, with an output/unsupported warning. An attachment whose asset has no bytes is left out with a ref/unknown-asset warning.

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).
  • text and heading become paragraphs with styles; table becomes a Word table with a repeating header row; list becomes a Word list.
  • Charts become native Word charts where the chart model allows, otherwise images.
  • frame becomes 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: page are kept.

Mapping details (0.1.2):

  • page.number, page.count and page.sectionCount become 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 an output/unsupported warning. That holds for property values too (a link's href, an image's src, a barcode's value), not only for text (0.1.2).
  • Variants: first becomes Word's first-page header and footer, and left and right its even- and odd-page ones. Word has no last-page header or footer, so the bands of a last variant are placed as floating content where they were laid out on the last page, with an output/variant-unsupported warning. A blank variant, and a variant's own margin, 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 an output/frame-anchored warning.
  • A page template's background is drawn behind the text from the section's header.
  • A page template's overlay is 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 an output/overlay-anchored info 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 an output/frame-anchored info diagnostic says it moves with the cell. A stamp inside a frame is a schema/not-allowed-here error (§6.13); one that still reaches a text box (through a component the checker couldn't resolve) is written as flowing content, with an output/frame-anchored warning (0.2: stated as recovery).
  • A setParams action (§17.6) has nothing to act on in a Word document, so it is left out with an output/unsupported info diagnostic; PDF does the same (0.2).
  • text-fit (§8.4) is left out, with an output/unsupported info diagnostic: Word can't shrink text to fit (0.2).
  • A group's repeatHeader has no Word equivalent. The header is written once, with an output/unsupported info 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 height is auto and it is aligned stretch) 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-color and 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 an output/unsupported info diagnostic on the node.

17.3 XLSX ​

XLSX is written from data regions, not page layout:

  • By default each top-level bound table, repeat and matrix becomes a worksheet, named from its meta.label or 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 bound table, repeat and matrix that 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. With sheets, 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 after meta.title.
  • Sheet names. A sheet's name, else the region's export.xlsx.sheet hint, else its meta.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 Format pattern 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 the Format pattern, 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. freeze names 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. A freeze the sheet sets that is dropped, or that isn't a cell reference, raises an output/unsupported warning at /outputs/xlsx/sheets/<i>/freeze; the implicit freeze of a repeating header is dropped silently. (0.1.2)
  • Unmatched sheets. A sheets entry whose from is no bound table, repeat or matrix of the render gives no sheet, with an output/sheet-unmatched warning at /outputs/xlsx/sheets/<i>/from. A from that names no node at all is a ref/unknown-node error at design time. (0.1.2)
  • Filters and outlines. autoFilter: true, or a values viewer 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 overlay is left out. When an overlay child would show, an output/unsupported warning at the template's overlay says so, once per template and variant. (0.2)
  • Page stamps. A pagestamp is left out, with an output/unsupported warning once per stamp node that shows: a stamp is content, so losing it warns as losing an overlay does. (0.2)
  • Repeats. A repeat sheet 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 bound table.

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-paged and html-flow. Both are settled by outputs.html.
  • The output contains no scripts and allows none. Links follow §25.1, and a rejected link raises a security/link-rejected warning.
  • In paged, decorated inline boxes are drawn as the PDF draws them. In flow, they are CSS inline boxes the browser lays out, with the same padding, borders and slicing (§8.4.1).
  • flow has no pages. A value that depends on the page is written as it was on the page where the node was laid out, with an output/unsupported warning, property values (a link's href, an image's src, a barcode's value) 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 of display: contents that carries them. (0.2)
  • In paged, a page's overlay is a positioned layer above the rest of the page, hidden from screen readers as other artifacts are. flow leaves page templates out, the overlay included; when an overlay child would show, an output/unsupported warning 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 is decorative. flow has no pages: a stamp's children are shown where the stamp sits, unpositioned, with an output/unsupported info diagnostic once per stamp node; a decorative stamp is aria-hidden. (0.2)
  • In paged, text-fit text is drawn at the size the layout chose. flow can't measure, so it sets the text at its full size, with an output/unsupported info 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>.view gives 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.view serves PDF and the layout model and page images, outputs.html.view both HTML formats, and outputs.docx.view and outputs.xlsx.view their 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-type warning. An entry naming a region, column, filter or toggle target that doesn't exist is skipped with an interaction/unknown-target warning.
  • 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.

EffectFieldsMeaning
toggletoggle: node id(s); initial: expanded | collapsed | Expr → bool; indicator: bool (default true)Show or hide the target nodes. Drill-down
navigatenavigate: Expr → target; target: self | new (default self)Go to another report, a URL or an internal anchor. Drill-through
setParamssetParams: map of ExprChange parameter values and render again, e.g. clicking a chart bar selects that period
tooltiptooltip: ContentText 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 if were 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 the toggle-indicator part.
  • 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-target error. 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 if doesn'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: false turns 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.href and navigate take the same targets: an https: or mailto: URL, '#<node-id>', or report(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-unresolved warning.
  • 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-unresolved warning.
  • A navigate or href value that is not #<node-id>, an https: or mailto: URL, or a report target is left out, with a security/link-rejected warning, 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
}
  • values offers the distinct values of by across the region's rows. range works on numbers and dates. search matches 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 its empty slot if nothing is left.
  • In XLSX, values filters 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 end null (range), or a string (search). null turns the filter off, even when it has an initial; so do an empty list, an empty string and [null, null]. (0.1.2)
  • values offers the distinct values over the rows left by the region's own filter. (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 and series to { id, name } of the series. An action whose if doesn'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-point inside 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. Its text is the point's label (category, series: value, without the series when the chart has one), and its actions are 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-point as a transparent rect over the chart, with the same data-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 frame are 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).
  • heading levels produce the PDF structure tree. Engines SHOULD warn when heading levels skip (a11y/heading-skip).
  • image and chart MUST have alt unless marked decorative. A field-image has no decorative and always needs alt, which the schema requires (0.2: stated). A barcode MAY have alt; without it, its encoded value is its alt text (§6.9).
  • Severities (0.1.2): an image without alt that isn't decorative is an a11y/missing-alt error. A chart without alt, and an empty alt on an image, chart or field image, are a11y/missing-alt warnings, 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-skip is 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, continuationHeader and continuationFooter, 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 is decorative, 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.
  • lang on 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 to meta.locale and its parents, then to the key with a i18n/missing warning. (0.1.2)
  • Messages use ICU MessageFormat, so plurals and selects work: T('items', { 'n': region.count }).
  • In a subreport, T looks for a key in the child's i18n first and then in the parent's, each along the same locale chain, with the child's meta.locale as the document locale (§14.4). (0.1.2)
  • The locale is the effective one (§11.3): a node's lang, then meta.locale, then the render context. A message found only under meta.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/missing at 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>), and T calls 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 i18n lacks but an enclosing document's has counts as present, and T calls 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 own i18n stays an i18n/missing warning there, never an error. (0.1.2)
  • Formatting follows the locale (§11.6). Right-to-left text is supported through direction and the Unicode bidi algorithm (§16.1). lang does not set the direction, as in CSS: an Arabic subtree sets direction: rtl too. 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 ​

PropertyTypeNotes
titlestringRequired
descriptionstringWhat the report is for, written for people and AI agents
localeBCP 47Default formatting locale
currencyISO 4217Default currency
tagsstring[]
owner, versionstringInformational; the host's version history is authoritative
designerobjectDesigner-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 ​

PropertyTypeNotes
labelstringName shown in the designer outline
notestringA free-form author note. Written as a comment in markup notation (§27.2)
descriptionstringIntent of this node ("Shows the customer's last 8 quarters of usage")
lockedtrue 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)
designerobjectDesigner-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/locked error naming the node and property. Setting or clearing meta.locked itself requires the right to unlock. (0.1.1)
  • What a lock covers (0.1.2):
    • locked: true refuses every change to the node, including inserting nodes into its slots or moving them out, except changes to meta.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 covers id.
    • A node locked on some properties only can still be moved.
    • The references renameId rewrites 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 is schema/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 whole style is 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.
  • Page templates (0.2) take meta with locked as nodes do (§9.1). Their locks are checked for set and unset on the template, for inserting into and removing from its bands, and for setDocument under /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')" ]
  }
]
  • expect items 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 }, or null if 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 as page:<template-id> (§22.2).
  • snapshot: true asks the host to compare page images against an approved baseline.
  • Result details (0.1.2):
    • node(id) also has kind, instance and fragment. 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 data value is a bundle path or an inline value. A path that isn't in the bundle fails that test with bind/missing-dataset.
    • A test with snapshot: true fails 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 or br) as a line break (\n). A word split by hyphenation or overflow-wrap is 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 and nodes() 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-node warning: 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 ​

OperationFieldsEffect
insertparent, slot, index?, nodeInsert a node (and its subtree)
removetargetRemove a node and its subtree
movetarget, parent, slot, index?Move a node
settarget, path, valueSet a property; path is a JSON Pointer within the node, e.g. /style/color
unsettarget, pathRemove an optional property
renameIdtarget, newIdRename an id and update every reference
wraptargets[], wrapperPut adjacent siblings inside a new container
unwraptargetReplace a container with its children
converttarget, toTypeChange type while keeping compatible properties (e.g. repeat ↔ bound table)
extractComponenttargets[], name, propsTurn nodes into a component and an instance
inlineComponenttargetReplace an instance with its expanded nodes
setDocumentpath, valueChange top-level properties (styles, params, data, pages, …)
insertRowstarget, section, index?, count?Insert empty rows into a table (0.2)
removeRowstarget, section, index, count?Remove rows from a table (0.2)
insertColumnstarget, index?, count?Insert empty columns into a table (0.2)
removeColumnstarget, index, count?Remove columns from a table (0.2)
mergeCellstarget, section, row, column, rows, columnsMerge a rectangle of cells into one (0.2)
splitCelltarget, section, row, columnSplit 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 body is { "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 templates header or variants/first/footer. A slot that doesn't exist yet is created.
  • index counts positions in the slot after a moved node has left it, and is clamped to the end. Without index, the node goes last.
  • set and unset change one node's (or page template's) own properties. Their path can't go into a child node. id changes only through renameId, type only through convert, and nodes in a slot only through insert, remove and move. 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 set and unset on 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 no id to be edited.
  • setDocument with a null value removes the property. It can't change the body's nodes.
  • renameId renames nodes and page templates. Besides id-valued references (toggle targets, pagebreak.page, the page style property, outputs.xlsx.sheets[].from), it rewrites string literals that name the node: '#<id>' in link targets, and the first argument of the test functions node, nodes, text and visible (§20). Renaming a page template also renames its key in the reference designer's meta.designer.guides (§19.1) (0.2); the rest of meta.designer is opaque and untouched. Footnote ids are not node ids, so footnote('id') is not rewritten; nor is a component's parts map, since a component's template nodes are not operation targets.
  • wrap puts adjacent siblings at the end of the wrapper's main slot (children, or detail for repeat), where the first of them was. unwrap puts the main slot's nodes where the container was; its other slots must be empty.
  • convert keeps the properties both types have and moves the main slot's content. A repeat's detail nodes become a bound table's columns and back. Required properties are filled in (content: "", level: 1).
  • extractComponent's props maps prop names to expressions. Each occurrence of an expression in the targets becomes props.<name>, and the new instance passes the expression. inlineComponent replaces an instance with a box that keeps the instance's id and common properties.
  • An operation the document can't take is an edit/invalid-op error.

Table operations (0.2):

  • target is a table (§6.5, §12.3). section names its row list: header, rows or footer for a static table, and headerRows or footerRows for 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's colspans).
  • count defaults to 1 and is at most 1000. Without index, new rows and columns go last, and an index past the end is clamped.
  • New rows get the ids row-<n> and new columns column-<n>, with the smallest n not 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.
  • mergeCells merges the rows × columns rectangle whose top left is row, column. The merged cell keeps the top-left cell's other properties. Its content is the merged cells' content in reading order, with a br between, or, when any of them has children, all their children, with each content as a text node.
  • splitCell splits the cell covering row, column into 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-span error 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 (set on /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.

PartShapeMerge behaviour
nodesMap of node id → node recordAdding a node with a new id never conflicts
node recordtype, parent, slot, order, plus one entry per propertyEach property merges independently; last writer wins
parent, slot, orderParent node id (or a document slot such as body), slot name, and a fractional index stringLast 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)MapEach 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 childrenConcurrent 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 valueCells with ids merge as items; the rest of the row merges as one value
Other arrays (sort, rules, class, actions)Whole valueLast writer wins
ContentRich text: characters, with spans and links as marks and br, footnote and field-image as embedded itemsCharacter-level merge, so concurrent typing in the same paragraph is kept
Expression properties (Expr)Whole stringLast 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 a collab/cycle-resolved warning. 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 order is a fractional index: a non-empty string of the digits 0–9, A–Z, a–z, valued in that order, that doesn't end in 0. 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 -2 appended (or -3, and so on, the first free), with a collab/id-clash warning 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 familyRaised 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.
  • fragment numbers the pieces of a node split across pages, from 0.
  • Boxes are nested to match the node tree.

(0.1.2)

  • x and y are page coordinates, not relative to the parent box.
  • kind is 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; a repeat has a box of kind repeat around its items, for hit-testing, although it has no box of its own in formatting; so does a component instance, a box of kind use around 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 are marker; the footnote area and its separator are footnote-area and footnote-separator; page bands are page-header, page-footer, page-background and page-overlay (0.2); a page stamp drawn on a page is a box of kind pagestamp the size of the page, with the stamp as node and its positioned children inside it (0.2); a chart's data points with actions are chart-point boxes inside the chart's box, with the chart as node and the point's label as text (§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 as layout/margin-grown, keep the template's id.
  • (0.2) A table's rows are header-row, row, footer-row, group-header and group-footer, a continuationHeader's rows continued-header, and a continuationFooter's rows continuation-row (styled as footer-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, footerRows and the row bands of groups and continuation slots) also have a name: their own id qualified by the table's node, <table>/<row> and <table>/<row>/<cell>. A cell without an id has no name. Two rows in different lists with the same id share a name.
  • (0.2) text is 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].
  • actions lists 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, and sort on a sortable header cell. filters lists 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, so 1 and 1.00, one group, are one key); a date as ISO 8601 (2026-10-31); a timestamp as RFC 3339 in UTC; a boolean as true or false; 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 group a/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), after filter, sort and limit are 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 in sales[group:North][total][column:2026].
  • A component or subreport instance adds /<instance-id>, as a nested region does, as in table-accounts[2]/slip. The nodes inside it are named <instance-id>/<inner-id> (§14.2), such as slip/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-x has the key table-x[3], and the box says which node it is. Likewise the node slip/slip-amount inside a component instance has the instance's own key: slip, or table-x[2]/slip when the instance is in a region.
  • A region's separator takes 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], or history[2][page:3] for an item of a region in the band.
    • A header, group header or continuationHeader repeated on a continuation page, and a continuationFooter: table-x[page:2], or table-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.

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 namespace x-acme registers acme.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-extension warning at /type. Calling a function of such a namespace is an expr/unknown-function error.
  • A placeholder is as wide as its container's content box and 24pt tall, unless its style sets width or height.
  • 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 ​

  • rfml holds the language version as MAJOR.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-version at /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, and schema/unsupported-version at /rfml is 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-version error where it is used, with a fix that sets rfml to the version that added it: a new element type is versioned as a new property is, at its type. The closed list of what 0.2 adds to documents:
    • a page template's and a variant's overlay (§9.1);
    • the pagestamp element (§6.13), with its once;
    • the document's meta.designer (§19.1);
    • a page template's meta (§9.1), and dotted lock paths in any meta.locked (§19.3);
    • a dataset's schemaFrom (§10.1);
    • orphans and widows on repeat and bound table (§12.1);
    • the style properties text-fit, margin-inline-start, margin-inline-end, padding-inline-start, padding-inline-end, inset-inline-start and inset-inline-end (§8.4).
  • 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 rfml in 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-version when 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: true on 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, then lang, running, export, actions and meta, then x-* properties sorted. A column's order is id, if, width, header, class, style, rules, cell, cellChildren, footer, sortable, sortBy. A group writes its other properties before header and footer. 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, with parts last.
  • 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.50 stays 1.50). A value that may be a string or a number, such as a style value, a token or an asset's weight, is written as a number when the string is a plain decimal number ("600" becomes 600, "0" becomes 0).
  • 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.

  1. 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.
  2. No scripts anywhere.
    • Every SVG the engine decodes is sanitised, whatever its source: assets, library images, an image's src from data, and data: URLs (0.2: stated). Scripts, event attributes, foreignObject and references to external resources are removed, and every output (PDF, HTML, DOCX) is written from the sanitised SVG, never the original bytes. Removing anything raises security/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.
  3. Allowed link targets. https:, mailto:, #<node-id> and report() targets. http: only if the host allows it. javascript:, data: and file: are never allowed in links, and link values that come from data are checked the same way.
  4. Pure expressions. Expressions have no I/O, no randomness, and no clock except now.
  5. 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.
  6. 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.
  7. Fail closed. When a limit is exceeded, the render stops with a security/limit error. 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.

LimitDefault
Expression evaluation steps per render10,000,000
Expression result size16 MiB of text per string, 10,000,000 items per list (0.2: stated)
Nodes after expansion (instance tree)1,000,000
Pages per render2,000
Render time60 seconds
Data per render (all datasets)200 MB
Image size50 megapixels and 20 MB per image
SVG size250,000 XML nodes, nested images included, and 1,000,000 elements once every <use> is expanded (0.2)
Subreport nesting depth5
Layout re-passes for page values3 (§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 is security/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/limit and 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/limit error. An image the engine can't read is left out with a security/asset-rejected warning. An SVG that loses scripts, event attributes, foreignObject or external references to sanitising raises a security/asset-sanitized warning. 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 ​

LevelIncludes
CoreDocument 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)
StandardCore, 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
AdvancedStandard, 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:

  1. Schema: valid and invalid documents.
  2. Expressions: expression, input and expected value, shared by every implementation (Rust engine and JavaScript language service alike).
  3. Layout: document and data, expected layout model within a tolerance.
  4. Operations: document, batch, expected document or diagnostics.
  5. 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 .rfml and the media type application/vnd.rfml+xml. JSON files use .rfml.json and application/vnd.rfml+json.
  • Tools MUST convert in both directions without loss:
    • parse(format(doc)) equals doc for 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 &lt; and &amp;. Formatters do this automatically, so row.amount < 0 is stored in markup as row.amount &lt; 0.
  • The root element is <rfml version="0.2" id="…">, its version the document's rfml.
  • 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's meta JSON. A comment before a node that also has meta.note is markup/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 ​

JSONMarkup
A node of type tElement <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
classSpace-separated attribute
styleCSS declaration text: style="color: var(--brand); font-weight: 700". Part styles go in a child <prop name="style.parts"> holding JSON
children slotThe 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-* propertiesAttributes 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 version and schema hold rfml and $schema.
  • Unwrapped child elements: a matrix's rows, columns and cells are <row>, <column> and <cell>; a page's variants are <variant name="…">. An instance's slots are <slots> holding <slot name="…"> elements with the nodes.
  • Wrapped child elements: a table's header, footer, headerRows, footerRows and continuation slots, and a group's header and footer, are wrapper elements holding <row> elements, or nodes where the slot takes nodes. A column's cellChildren is a wrapper holding nodes. A static cell's content is its text, and its children are its child elements.
  • A region's or group's vars and a node's running are JSON attributes. Only the document's vars use <vars> and <var>.
  • An empty text is written content="", 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 pagestamp is <pagestamp>, with its children as child elements, like a frame (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 ​

JSONMarkup
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
stylesA <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, footnotesElements 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 formPlace 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 is markup/duplicate), and :root holds only --<token> declarations (anything else is markup/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 empty defaults object, 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 in conformance/layout/ (§16.10).
  6. 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.
  7. 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).
  8. Data adapters. Settled 2026-10-19: a dataset may name a host data source (source, with sourceParams), 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.
  9. 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.
  10. 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 in conformance/markup/ (§27.1).
  11. 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.
  12. 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.

SectionChange
§6.12Overlay children are positioned and checked as background children are
§6.13Added 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.13How page stamps are registered, laid out and painted
§9.1Added 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.2A 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.11Footnotes are not allowed in an overlay
§17.2DOCX 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.2A 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.3XLSX leaves the overlay and page stamps out, each with an output/unsupported warning
§17.5Paged 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.5Flow HTML writes a component instance as its roots, without an element of its own
§18.1The overlay is a Pagination artifact of subtype Watermark; a page stamp is content read where it sits, or a Layout artifact when decorative
§19.1Added the document's meta.designer, designer-only state that engines ignore
§21.1Added 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)
§20text() 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.2Added 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.3Each page's copy of a page band or a repeated header is an instance of its own, keyed with [page:<n>]
§24.1Minor versions migrate with an empty step and keep their version; a document may use only the features of the version it declares
§27.3The <pagestamp> element in markup
§27.4The <overlay> element in markup

Changes from the review of the 2026-10-24 draft (2026-10-28):

SectionChange
Introduction, §24.1One 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, §3Render 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.1Absolute positioning is stated for a frame's children and the children of a page background, overlay or stamp
§5.1running is in the common properties
§5.2, §21.1Nodes and page templates share one id namespace; footnote ids are their own, and may repeat
§6.5The static grid's packing rule; overlapping spans and spans past the grid are schema/invalid-value
§6.10A heading rendered several times gives an entry per instance
§6.12left and right stay physical in right-to-left frames (the engine had mirrored them); inset-inline-* follow the direction
§6.13, §16.13Added 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.7expr/empty-interpolation named; engines default to the fail policy, which hosts SHOULD use for production
§7.3Footnote ids, schema/footnote-id; the symbol sequence; a section is a template run
§8.1A document class merges over a theme class of its name; unknown classes and tokens are reported once the theme is resolved
§8.4Added 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.3Page templates take meta and locks; lock entries may be paths such as style.color
§9.1, §9.3Variants don't layer; the body has no last-page test
§10.1, §10.4Added schemaFrom: "source"; {"$ref": "source"} is schema/deprecated in 0.2 documents; samples bind as data does
§10.3, §11.4, §12.1, §13Parameter and dataset names and row aliases are expression names; reserved aliases; prop names that aren't are a warning
§11.2, §11.5, §11.6Stated: 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.1Added orphans and widows on repeat and bound table; split rows in fragment.rows
§12.5fragment.rows on a column chunk holds whole row lines
§13What each chart kind reads; Vega-Lite is optional at Advanced, and the reference engine draws a placeholder
§14.2schema/per-root-style; a component that should be one box has a single root
§15The four bundled fonts and the names that stand in for them; ref/unknown-font
§16.1, §16.2, §16.4Percentage heights in table cells are auto; blank pages count; rows joined by a span are one unit
§17.1, §18.1A field image always needs alt
§17.2, §17.3, §17.5A stamp in a frame is recovery, not a feature; setParams and text-fit report output/unsupported; how a repeat becomes a sheet
§21.1set reaches static cells by position; renameId moves designer guides
§21.3, §26Row lists and cells with ids are keyed collections; order key grammar; collab/id-clash; the collaboration suite comes later
§22.3Group and column keys are locale-invariant, and / inside brackets is literal
§25.1, §25.2Every 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
§26Tagged PDF is Core; T, footnote and page.running come with their Standard features; the suite is in conformance/
§27.2, §29The 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.

SectionChange
§1.4Interactivity is in scope (§17.6); scripts are not
§2How examples are laid out
§5.1type and id are required on every node except slot
§5.2Node 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.5Static and bound table forms don't mix
§6.9Default barcode sizes; output/barcode-invalid
§6.10leader 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.12Frame checks; how children are positioned, painted, rotated and clipped; positioning outside frames warns
§7.3Footnote parts are styled in styles.defaults.footnote; the body inherits from footnote-area; footnotes not allowed in page bands
§8.1Engine 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.4Decided: max-height applies to blocks
§8.5A region's rules are read together
§8.6Column 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.1Band 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.2Which properties defer page values
§9.4Repeated headers set running values again
§10.1Undeclared 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.3type is required; parameter scopes, with computed choices reading the item as row; multiple defaults
§11.3meta.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.4expr/var-order and expr/cycle; variable errors reported once
§11.5Running functions reset per innermost group; where they work, including group footers and continuation slots
§12.1fragment.rows in continuation headers; processing order; sorting; empty
§12.4Group identity and order; breakBefore; header keeps
§12.5Matrix headers don't repeat by default; invalid format; viewer filters and sorts
§13Default chart size; the category axis; palette tokens; gridlines; missing alt. Decided: default axis and gridline lines; stroke: none draws no line
§14.2How props are bound; component cycles; instance keys inside components
§14.4How a subreport binds, styles and reports its child; addressing; cycles. Decided: T reads the child's i18n, then the parent's
§15file assets and asset description
§16.1Line metrics from the primary font; definite heights; list markers; columns ignored on flex and grid
§16.2More monolithic boxes
§16.3Region 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.6Decided: variants match in the order blank, last, first, left/right, so last wins on a one-page section
§16.7A kept-together element that doesn't fit an empty page breaks by relaxing R3, as §16.3 says, rather than being monolithic
§16.8Repeated headers and continuation slots are placed whole and aren't progress. Decided: both continuation slots are repeated content, tagged as Pagination artifacts
§16.9First-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.11Footnotes 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.12A nested multi-column box is laid out as one column
§17.1attachments 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.2DOCX page fields, variants, frames, backgrounds and group headers. Decided: page-dependent property values warn, as run values do
§17.3XLSX sheets, names, typed cells, freezing, filters, outlines, matrices and footnotes. Decided: output/sheet-unmatched; a dropped freeze warns
§17.5html-paged and html-flow; page values in flow. Decided: page-dependent property values in flow warn too
§17.6.1The base view state per format; how bad view state is reported; what is left out
§17.6.3Toggle 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.4Report links without a link base, in every format; rejected targets
§17.6.5Sorting 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.6Decided: 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.1Severities of missing alt and heading skips; tagged artifacts. Decided: continuation slots are Pagination artifacts
§18.2T() 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.3What a lock covers
§20Test result fields; missing test data; snapshots; extensions
§21.1How operations address slots and positions, and what each changes
§21.2A batch is rejected when it adds errors. Decided: missing alt text on nodes the batch inserts doesn't count
§22.1Diagnostics on repeated content are reported once. Decided: output/sheet-unmatched
§22.2Page coordinates; box kinds; actions and filters. Decided: chart-point boxes carry chart actions per data point
§22.3Keys name positions, not nodes; instances inside components; separators
§23Function names; allowed namespaces; placeholders; formats without an implementation
§24.1Migrations run before validation, subreports too; what counts as an unknown feature of a newer minor version; engines list the versions they read
§24.2Canonical property order, style blocks, content, numbers and defaults
§25.3Image and SVG diagnostics
§27.2Comments and the strict XML reader
§27.3Mapping details
§27.5The example parses: CSS comments are not allowed; one @import; :root holds tokens; repeated selectors merge
§27.6Long start tags; no breaks inside {{ }}
§28The example is in canonical property order

0.1.1 (2026-10-19) ​

Clarifications to 0.1, marked (0.1.1) where they appear.