Escape
HomeDocsBands

Bands

Intent

A definition describes bands, not pages. The report header, the group bands, the detail band, and the footers flow in walk order. Each target decides where that flow lands on its own surface. A definition that described its own page breaks would stop being portable across page sizes and across targets that have no pages at all.

Design decisions

The band is the unit of layout. Bands flow vertically in walk order on every target. Group nesting adds no indentation. What differs between targets is only where they cut the flow.

A group band has no current row. The report header, the group bands and empty all leave @ unbound, so they read through a group handle or through $. A band that needs the whole row it grouped by declares by: "=@".

empty replaces the body, not a row. It renders once when no rows remain, and it takes the place of the whole group and detail walk, table structure included.

Flow spacing is two style names, not a box. spaceBefore and spaceAfter open gaps between band items. Padding insets a box, which is a different thing, and the two aren’t interchangeable.

A height-declared report header pins from the page top. Declaring a height fixes the band’s box, so the next band starts at the pin whether the items filled it or not. That’s what keeps a letterhead the same size on every document.

Page bands are furniture, and only a paginating target draws them. They cross the seam as closures the target calls once it knows the page numbers. A target that ignores them never evaluates them at all.

Page columns are document structure, not page geometry. A definition declares a columned region on the report or on a group, and it flows into strips. The page size stays a host option.

API walkthrough

Opening and closing the document

header is the report header band, rendered once before any row. It takes an item array, or an object pinning a height.

a pinned header
{
  "header": {
    "height": 120,
    "items": [
      { "type": "text", "value": "{{ $.input.company }}", "style": { "size": 14, "bold": true } }
    ]
  }
}

Every item in this band states its own size. Targets give the band a headline default of bold 14pt, sitting above the document’s own style, so an item declaring no size renders headline-sized. An exact "bold": false declares that default away.

A pinned header requires page.margin on the document, because the pin starts at the physical page edge.

footer is the report footer band, rendered once after the last group closes. Totals for the whole document belong here, since the detail band’s own total block draws once per table.

Grouping the body

groups is an array of group bands, outermost first. Each instance renders its header, recurses into nested groups or the detail band, then renders its footer.

break: "page" starts every instance on a fresh page. An instance already at a page top doesn’t force an empty one. reset: "page" does the same and restarts the page numbering for that instance.

Unpaginated targets map a break to their own vocabulary. The HTML target adds a class for your print stylesheet rather than ignoring it.

Drawing the rows

detail is the per-row band. Given an item array it draws stacked bands and repeats them per row. Given a TableDetail it emits a real table. That reaches HTML as thead and tbody, a PDF as a header row repeated on each page, and a workbook as a frozen pane.

Saying nothing, deliberately

empty renders once when no rows remain after data, where, the report sort and the report take.

an empty state
{
  "data": "$.orders[*]",
  "empty": [{ "type": "text", "value": "No orders for {{ $.params.year }}", "style": { "size": 10 } }],
  "detail": { "columns": [{ "header": { "value": "Order" }, "value": "{{ @.number }}" }] }
}

Report aggregates are available, because they’re computed before the engine chooses the band. The report header and footer still render around it. Groups, detail and running accumulators don’t run.

An empty array is a deliberate declaration that the body renders nothing. With empty absent, an ungrouped empty table keeps its structure and a grouped one emits nothing, since no instance opens.

Furnishing the page

Paginated targets draw page.header and page.footer once per page. Their scope is the report root plus page.number and page.total, so a band mixes report data with page position. @ stays unbound.

page.margin is the inset from the physical page edge, one number for all four sides. It’s the one piece of page geometry a document may declare, and declaring it here and on the target too is a render error.

page furniture
{
  "page": {
    "footer": [
      { "type": "text", "value": "Page {{ page.number }} of {{ page.total }}",
        "style": { "size": 8, "align": "right" } }
    ],
    "margin": 54
  }
}

Page bands are the one part of a definition validate can’t vouch for. Only a target that paginates evaluates them, so a page band that throws fails the PDF render and goes unnoticed everywhere else. Rendering a paginated target is the only thing that exercises one.

Flowing in columns

columns flows a node’s content in that many vertical strips. It fills one to its foot before moving to the next, and turns the page only once the last runs out. Declare it on the report to column the body, or on a group to column each instance. It takes an integer of two or more, and columned regions don’t nest.

The declaring node’s own bands stay full width above and below the strips.

Spacing the flow

spaceBefore and spaceAfter open blank space before or after a band item, in points. Adjacent values add. spaceBefore drops at a fresh page or strip top, so a band doesn’t start a page indented, while page-band items keep theirs.

They’re legal on item arrays including band images and splits. On a table cell, a row’s style, a header, a total or a split slot they’re a definition error. The XLSX target doesn’t read them, a grid having no flow.

The band is the unit, not the page.

npm install quario
Getting started
© 2026 quario · KvK 61815977