This document defines VMPrint's Spatial IR (Intermediate Representation) — the normalized, lowered representation of a document that sits between the author-facing, hierarchical semantic AST (DocumentInput / DocumentIR) and the physical simulation actors (PackagerUnit / LayoutProcessor) running on the engine board.
For the broader system map, see ARCHITECTURE.md. For layout engine internals, see ENGINE-INTERNALS.md.
Every document in VMPrint begins as a semantic hierarchical tree: the author-facing AST (DocumentInput). In this AST, elements express meaning and structure (e.g., a table has rows, a story has paragraphs, styles are looked up from a document-level stylesheet).
Before simulation starts, the engine compiles and normalizes this AST into the Spatial IR. The Spatial IR serves as a "board setup" for the simulation kernel:
Author-Facing AST (DocumentInput / DocumentIR)
↓
[ AST Normalization Phase ]
(Resolves styles, page templates, columns & grid tracks)
↓
Spatial IR (SpatialDocument)
↓
[ Actor Instantiation Phase ]
(Maps Spatial IR items to PackagerUnits)
↓
Live Simulation Session
| Characteristic | Author-Facing AST | Spatial IR |
|---|---|---|
| Hierarchy | Deeply nested (e.g., table -> table-row -> table-cell -> p). |
Flattened sequence of block-level layout participants (SpatialZoneContentItem). |
| Sizing / Coordinates | High-level, abstract (e.g., width: "50%", columns: 3, CSS-like track modes flex, auto). |
Pre-resolved absolute-relative offsets, solved track sizes, and coordinates in points (e.g., { x: 24, width: 120 }). |
| Styles | Inherited and referenced styles via a document-level styles table. |
Flattened, resolved, and merged inline style records on each individual item. |
| Viewports / Margins | Declared in document-level layout or polymorphic page templates. |
Lowered into concrete, compiled page regions (firstPage, odd, even, default) with absolute boundaries. |
| Provenance | Native source structure. | Traced via explicit SpatialSourceRef properties to allow bi-directional mapping during simulation. |
The Spatial IR pre-calculates layout variables that do not require full flow simulation. For example, columns in multi-column stories and tables are pre-solved using track-sizing algorithms (solveTrackSizing) based on parent content widths and column gaps. This resolves flex/auto/fixed sizing into exact relative x coordinates and width values, reducing layout logic to a physical simulation of flow height and page splitting.
Deeply nested semantic structures are flattened into a simple sequence of block-level layout participants (SpatialZoneContentItem). For example:
- A multi-column story is divided into linked zone strips (
SpatialZoneStripwithoverflow: 'linked') separated by column-spanning elements. - A table is lowered to a spatial grid (
SpatialGrid) consisting of solved column metrics and a flat list of cell objects (SpatialGridCell), each hosting its own isolated flow content.
Every block in the Spatial IR carries a SpatialSourceRef containing:
path: The exact path back to the source AST element (e.g.,elements[0].children[2]).sourceId,semanticRole,reflowKey: Metadata to locate and identity actors.__elementProperties: Original AST property values.
This bi-directional traceability allows the layout engine, simulation clock, and debugging tools to map flat, absolutely positioned output boxes back to the original AST source. This guarantees correctness when live scripts mutate the document mid-simulation: the engine can trace the change to a specific frontier, swap the actor, and resimulate downstream elements seamlessly.
The Spatial IR is defined in spatial-document.ts. The structure of a compiled document is represented by the SpatialDocument interface.
export interface SpatialDocument {
spatialIrVersion?: string;
pageTemplate?: {
header?: SpatialCompiledPageRegionSet;
footer?: SpatialCompiledPageRegionSet;
};
items: SpatialZoneContentItem[];
}Page templates (headers and footers) are compiled relative to page geometry and margins, mapping to specific selectors.
export interface SpatialCompiledPageRegionSet {
default?: SpatialCompiledPageRegion | null;
firstPage?: SpatialCompiledPageRegion | null;
odd?: SpatialCompiledPageRegion | null;
even?: SpatialCompiledPageRegion | null;
}
export interface SpatialCompiledPageRegion {
kind: 'page-region';
role: 'header' | 'footer';
selector: 'default' | 'firstPage' | 'odd' | 'even';
x: number;
y: number;
width: number;
height: number;
style?: Record<string, unknown>;
content: SpatialZoneContent;
}
export interface SpatialZoneContent {
items: SpatialZoneContentItem[];
}A SpatialZoneContentItem represents a physical layout element. It is a union of four types:
export type SpatialZoneContentItem =
| SpatialFlowBlock
| SpatialBlockObstacle
| SpatialZoneStrip
| SpatialGrid;Represents block-level elements that flow sequentially (paragraphs, headings, blockquotes, images, etc.).
export interface SpatialFlowBlock {
kind: 'flow-block';
sourceType: string; // The original AST tag name (e.g., 'p', 'h1', 'blockquote')
content: string; // Plain text content of the block
children?: Element[]; // Inline children (such as bold/italic text runs)
columnSpan?: 'all' | number;
style: Record<string, unknown>; // Flattened, merged CSS style properties
keepWithNext: boolean;
pageBreakBefore: boolean;
allowLineSplit: boolean; // Control whether the block can split across pages
overflowPolicy: 'clip' | 'move-whole' | 'error';
dropCap?: Record<string, unknown>;
paginationContinuation?: Record<string, unknown>;
pageReservationAfter?: number;
image?: {
data?: string; // Base64-encoded image payload
mimeType: string;
intrinsicWidth: number;
intrinsicHeight: number;
fit: 'contain' | 'fill';
};
pageOverrides?: {
header?: SpatialCompiledPageRegion | null;
footer?: SpatialCompiledPageRegion | null;
};
source: SpatialSourceRef;
}Represents floating or absolute layout elements that claim space and push surrounding text flow around them.
export interface SpatialBlockObstacle {
kind: 'block-obstacle';
resolvedX: number; // Solved relative X position within parent content box
width: number;
height: number;
wrap: 'around' | 'top-bottom' | 'none';
gap: number; // Space to preserve around the obstacle
yAnchor: 'at-cursor';
align: 'left' | 'center' | 'right';
mode: 'float' | 'story-absolute';
shape?: 'rect' | 'circle' | 'ellipse' | 'polygon';
path?: string; // Specific clipping path when shape is 'polygon'
exclusionAssembly?: {
members: Array<{
x: number;
y: number;
w: number;
h: number;
shape?: 'rect' | 'circle' | 'ellipse' | 'polygon';
path?: string;
zIndex?: number;
traversalInteraction?: TraversalInteractionPolicy;
resistance?: number; // Spatial resistance (0 to 1) for text wrap
}>;
};
zIndex?: number;
content: SpatialFlowBlock; // The block payload inside the obstacle
source: SpatialSourceRef;
}Represents a multi-column story layout or independent zone mapping.
export interface SpatialZoneStrip {
kind: 'zone-strip';
overflow: 'linked' | 'independent';
sourceKind: 'story' | 'zone-map';
zones: SpatialZone[];
content?: SpatialZoneContent; // Holds flowing content for 'linked' strips
balance?: boolean; // Whether to balance column heights
blockStyle?: Record<string, unknown>;
placement?: Record<string, unknown>;
frameOverflow?: 'move-whole' | 'continue';
worldBehavior?: 'fixed' | 'spanning';
source: SpatialSourceRef;
}
export interface SpatialZone {
id?: string;
x: number; // Solved relative offset from strip origin
y?: number;
width: number; // Solved width of the column zone
height?: number;
style?: Record<string, unknown>;
content?: SpatialZoneContent; // Holds independent contents for 'independent' strips
}Represents tables or tabular layouts. Grids resolve column width configurations and express cells in a flat list mapped to rows and columns.
export interface SpatialGrid {
kind: 'spatial-grid';
resolvedColumns: SpatialResolvedColumn[];
columns?: TableColumnSizing[];
columnGap: number;
rowGap: number;
headerRows: number;
repeatHeader: boolean; // Repeat header rows when splitting across viewports
cells: SpatialGridCell[]; // Flat array of cells
blockStyle?: Record<string, unknown>;
cellStyle?: Record<string, unknown>;
headerCellStyle?: Record<string, unknown>;
paginationContinuation?: Record<string, unknown>;
pageReservationAfter?: number;
source: SpatialSourceRef;
}
export interface SpatialResolvedColumn {
x: number; // Solved absolute X coordinate relative to table container
width: number; // Solved width of the column track
}
export interface SpatialGridCell {
row: number;
col: number;
rowSpan: number;
colSpan: number;
resolvedX: number; // Solved relative X position
resolvedWidth: number; // Solved cell width (accounting for spans and gaps)
rowGroup?: 'header' | 'body' | 'footer';
content: SpatialZoneContent;
style: Record<string, unknown>;
source: SpatialSourceRef;
}Guarantees bi-directional traceability, mapping the lowered element to its original position in the author-facing AST.
export interface SpatialSourceRef {
path: string; // E.g., "elements[3].children[1]"
sourceId?: string;
semanticRole?: string;
reflowKey?: string;
language?: string;
sourceSyntax?: string;
sourceRange?: Record<string, unknown>;
__elementProperties?: Record<string, unknown>; // Passthrough container for scripting
}The AST-to-Spatial-IR translation runs in spatialize.ts during testing and in the engine's initialization logic. It operates via several key steps:
- The compiler traverses each AST element.
- It looks up the element's
typein the document-levelstylessheet. - It merges the sheet style, the inline element style (
element.properties.style), and any layout-specific modifiers into a single flatstyledictionary on the targetSpatialFlowBlock,SpatialGridCell, orSpatialZoneStrip.
For tables (table) and columns (story / zone-map), the compiler calls solveTrackSizing() to resolve abstract track definitions (e.g. 1fr, auto, 100 points) into concrete widths.
- Relative coordinates (
resolvedColumns/zones) are assigned absolute offset values based on solved sizes and gaps. - Spanning cells (
colSpan/rowSpan) are assigned pre-summed widths (resolvedWidth) spanning across their target tracks.
A multi-column story is divided into linked strips separated by column-spanning elements:
- When compiling a
storyelement withcolumns > 1, the compiler sweeps its children. - If a child block is a normal flow element, it is grouped into a linked segment.
- If a child carries
columnSpan: "all"(or span count$\ge 2$ ), the compiler closes the active column segment, emits aSpatialZoneStrip(withoverflow: 'linked'), inserts the column-spanningSpatialFlowBlock, and opens a new segment. - This preserves flat flow order while allowing column-spans to act as physical break boundaries.
Once Spatial IR is compiled, the engine executes the simulation:
- Instantiation: The engine maps the flat
itemslist of theSpatialDocumentinto live physical actors (PackagerUnitsubclasses, e.g.,FlowBoxPackager,SpatialGridPackager,StoryPackager). - Page Templates: Running page headers and footers from
pageTemplateare sliced per page selector (firstPage,odd,even,default) onto the active page viewport. - Simulation Loop: The engine drives the simulation clock, negotiating height queries (
getRequiredHeight()), layout fits, and splits (split()) along the explore frontier. - Committed Output: When the simulation settles, the engine walks the settled actors and calls
emitBoxes()to yield the flat array of positionedBoxprimitives containing pre-shaped text segment runs (RichLine).
The Spatial IR retains rich text formatting, language metadata (source.language), and source syntax (source.sourceSyntax). This metadata allows the layout engine to perform font-aware text shaping, bidi analysis, and baseline alignment at layout time, yielding stable, unified baselines for multiple scripts (Latin, CJK, Devanagari, Arabic, etc.) on a single line.
As actors simulate and settle, they publish signals (e.g., heading actors publish structural indices). Observer actors (like TOC) subscribe to these signals.
- In speculative branches (e.g., widow/orphan lookahead), signals are isolated in provisional buffers.
- When a branch is rolled back, provisional signals are discarded.
- When committed, signals are promoted to the main bus, allowing observer actors to update their content reactively during the single simulation pass.
If an actor's content updates mid-simulation (such as a page number changing or script variable updating) but its required bounding dimensions remain identical:
- The engine marks it as a Tier 2 (Content-Only) update.
- It redraws the actor's boxes in-place without invalidating the dirty-frontier.
- It avoids running a resettlement sweep over downstream actors, ensuring high-performance, real-time rendering updates.