Escape

Event stream

What a render emits. Nothing in the stream is markup-escaped, because escaping belongs to the target at its own edge.

ReportEventStream

signature
interface ReportEventStream {
  (data?: unknown): Generator<ReportEvent, void, undefined>
  readonly names: readonly string[]
  readonly readPaths: ReadPaths
  readonly functions: readonly FunctionSignature[]
  readonly paths: readonly QueryPath[]
}

What a target’s compile receives.

ReportEvent

signature
type ReportEvent =
  | ReportStartEvent
  | ItemEvent
  | ImageEvent
  | SplitStartEvent
  | SplitEndEvent
  | GroupStartEvent
  | GroupEndEvent
  | TableStartEvent
  | RowEvent
  | TotalRowEvent
  | TableEndEvent
  | ReportEndEvent

ReportStartEvent

signature
interface ReportStartEvent {
  type: 'report-start'
  params: Record<string, JsonValue>
  aggregates: Record<string, unknown>
  page?: PageBandRenderers
  columns?: number
  style?: Record<string, unknown>
  marking?: string
  margin?: number | PageMargin
  size?: PaperName
  orientation?: Orientation
  headerHeight?: number
  locale?: string
  currency?: string
  timeZone?: string
}

marking carries the unlicensed wording when the instance has no valid key. margin, size and orientation are the document’s paper, present only where it declared them and in the shape the author wrote. A target settles what it needs from this event in its handler, before pulling the stream further.

ItemEvent and ImageEvent

signature
interface ItemEvent {
  type: 'item'
  role: ItemRole
  path: string
  tokens: Token[]
  style?: Record<string, unknown>
  run?: Record<string, unknown>
}

interface ImageEvent {
  type: 'image'
  role: ItemRole
  path: string
  bytes: Uint8Array
  format: 'png' | 'jpeg' | 'svg'
  width: number
  height: number
  fit: ImageFit
  alt?: Token[]
  style?: Record<string, unknown>
  run?: Record<string, unknown>
}

The engine reads format, width and height off the bytes’ own header, so no target parses the PNG IHDR, JPEG frame header, or SVG root for itself. Bytes too short to state a size never reach a target at all: that’s one render error on every target alike. Past the size, the engine vouches for nothing. Bytes a target can’t write are that target’s render error, which it mints with imageError.

SplitStartEvent and SplitEndEvent

signature
interface SplitStartEvent {
  type: 'split-start'
  role: ItemRole
  path: string
  slots: { width?: number }[]
  style?: Record<string, unknown>
}

interface SplitEndEvent { type: 'split-end' }

A grid reaches the stream as one split per row, and path then names the row. A target that draws splits draws a grid with no change.

GroupStartEvent and GroupEndEvent

signature
interface GroupStartEvent {
  type: 'group-start'
  name: string
  path: string
  depth: number
  key: unknown
  aggregates: Record<string, unknown>
  break?: 'page'
  breakAfter?: 'page'
  reset?: 'page'
  columns?: number
  label?: string
  collapsed?: true
}

interface GroupEndEvent {
  type: 'group-end'
  name: string
  depth: number
  hollow?: true
}

The stream states the two page edges, never the four positions a group’s break names. break is present where a page turns before the instance, and breakAfter where one turns after it.

label is the group’s label resolved for this instance. It’s absent where the group declares none, so a target deriving a name of its own keeps deriving it.

collapsed is present, and only ever true, when the group asks that the instance open shut. A target with somewhere to put a row a reader can open again honours it. A target without such a place reads the stream through collapse() instead.

hollow is present, and only ever true, when the instance’s bands emitted nothing between its brackets. Such an instance occupies nothing, turns no page by opening, and earns no outline entry. An instance holding nothing but hollow instances is hollow too.

TableStartEvent, RowEvent, TotalRowEvent, TableEndEvent

signature
interface TableStartEvent {
  type: 'table-start'
  path: string
  header: { cells: EventCell[]; style?: Record<string, unknown> }
  columns: { width?: number }[]
}

interface RowEvent {
  type: 'row'
  cells: EventCell[]
  style?: Record<string, unknown>
  run?: Record<string, unknown>
}

interface TotalRowEvent {
  type: 'total-row'
  cells: EventCell[]
  style?: Record<string, unknown>
}

interface TableEndEvent { type: 'table-end' }

A row’s style still arrives on the row, and a cell’s on the cell. The box is the exception. The engine resolves it onto the cells before any target sees them.

Cells and tokens

EventCell

signature
interface EventCell {
  tokens: Token[]
  style?: Record<string, unknown>
  span?: number
  path?: string
}

span is present only when a cell covers more than one column.

Token

signature
interface TokenStyle { style?: Record<string, unknown> }
type PageField = 'page.number' | 'page.total'
type LiteralToken = SjabloonLiteralToken & TokenStyle
type ValueToken = SjabloonValueToken & TokenStyle & { field?: PageField }
type Token = LiteralToken | ValueToken

A cell arrives as tokens rather than as a string. A cell whose template is one interpolation is a single value token holding the value itself. That’s how a spreadsheet target writes a real numeric cell while a markup target writes escaped text. Join them with text(), or take the typed value with typed().

A token may carry style, the resolved declarations for that stretch of the cell. That’s the cell’s own with its run’s layered over them, already composed. It’s present only where it differs from the cell’s, so a consumer that never reads it stays correct. styledRuns(tokens) groups a cell back into its runs.

A value token carries field where its interpolation is exactly {{ page.number }} or {{ page.total }}. The value still holds the number this render saw. field names which page value the token stands for, so a target whose own document format numbers pages can write a live field there instead. Anything computed from a page value ({{ page.number + 1 }}) is an ordinary value and carries no field. Write the bare token wherever a live number matters. A target that paginates itself, or not at all, ignores the key.

Page bands

signature
interface PageInfo { number: number; total: number }

type PageBandEvent = ItemEvent | ImageEvent | SplitStartEvent | SplitEndEvent

interface PageBandRenderers {
  header?: (page: PageInfo) => PageBandEvent[]
  footer?: (page: PageInfo) => PageBandEvent[]
}

The engine doesn’t walk page bands. A paginating target calls the renderers from report-start once it knows the page numbers. An unpaginated target ignores them.

ItemRole

signature
type ItemRole =
  | 'report-header'
  | 'empty'
  | 'group-header'
  | 'detail'
  | 'group-footer'
  | 'report-footer'
  | 'page-header'
  | 'page-footer'

isReportBand(role) answers whether a role names one of the report’s own bands rather than a group instance’s.

Driving the stream

signature
type WalkEvent = ReportEvent | SplitEvent

type WalkHandlers = {
  [K in WalkEvent['type']]?: (event: Extract<WalkEvent, { type: K }>) => void
}

function walk(events: Iterable<WalkEvent>, handlers: WalkHandlers): Promise<void>
function splits(events: Iterable<ReportEvent>): Generator<WalkEvent, void>
function breathe(): Promise<void>

A missing handler ignores that event. Handlers are synchronous, and walk doesn’t await a promise one returns. A target running a loop of its own calls breathe between batches so a long report never blocks the host.

splits folds each split bracket into one split event carrying the opening event’s fields and its slot events under items. Everything else passes through unchanged and lazily. Read walk(splits(events), handlers) and register a split handler rather than a split-start and split-end pair. The stream itself doesn’t change.

signature
interface SplitEvent extends Omit<SplitStartEvent, 'type'> {
  type: 'split'
  items: (ItemEvent | ImageEvent)[]
}