cosmiciron

vmprint-ast-layout

Practitioner's guide for constructing documents with the VMPrint JSON AST 1.1. Use when creating or editing vmprint JSON documents. Covers page geometry, element types (story, table, zone-map, strip, image), floats, drop caps, column spans, headers/footers with strip layouts, multilingual text, pagination control, and the document scripting system.

cosmiciron 548 20 Updated 4w ago

Resources

4
GitHub

Install

npx skillscat add cosmiciron/vmprint/vmprint-ast-layout

Install via the SkillsCat registry.

SKILL.md

VMPrint AST Layout Skill

A practitioner's guide to constructing sophisticated layouts with the VMPrint JSON AST version 1.1. Use this alongside the regression fixtures in engine/tests/fixtures/regression/ and engine/tests/fixtures/scripting/ (working examples for every feature).

Schema authority: The interfaces embedded in this skill (§ "Exact … interface" blocks) are the canonical contracts for allowed keys. Do not guess at key names — if a key is not listed in one of those interface blocks, it does not exist. The pitfalls section documents keys that have been hallucinated in the past.

AST 1.1 breaking changes from 1.0: placement replaces properties.layout; image, dropCap, columnSpan, table are now top-level element keys instead of nested inside properties.


1. Root Structure

Every document is a single JSON object:

{
  "documentVersion": "1.1",
  "layout": { ... },
  "fonts":  { ... },
  "styles": { "myType": { ... } },
  "elements": [ ... ],
  "header": { ... },
  "footer": { ... }
}
  • documentVersion — always "1.1"
  • layout — page geometry and typographic defaults
  • fonts — optional; only needed for custom font files
  • styles — maps element type strings to base styles; anything not here defaults to layout values
  • elements — the content tree
  • header / footer — optional running regions

Optional scripting keys at root level: methods, scriptVars, onBeforeLayout, onAfterSettle.


2. Page Geometry

"layout": {
  "pageSize": { "width": 720, "height": 405 },
  "orientation": "landscape",
  "margins": { "top": 34, "right": 50, "bottom": 34, "left": 50 },
  "pageTemplates": [
    {
      "pageIndex": 1,
      "pageSize": { "width": 280, "height": 420 },
      "margins": { "top": 34, "right": 22, "bottom": 34, "left": 22 }
    }
  ],
  "fontFamily": "Arimo",
  "fontSize": 10,
  "lineHeight": 1.35,
  "pageBackground": "#fdf6e3"
}

pageSize accepts "A4", "LETTER", or { "width": N, "height": N } in points. Standard sizes:

Name Points
A4 portrait 595 × 842
LETTER portrait 612 × 792
16:9 landscape 720 × 405

pageTemplates can override pageSize, orientation, and/or margins for
matching pages. pageIndex is zero-based; selectors such as "first", "odd",
"even", and "all" are also supported. The engine resolves the active page
geometry before measuring that page, so odd-sized pages change real flow space
and render as matching PDF media boxes.

Content area math (critical for fitting content on page):

contentWidth  = pageWidth  - marginLeft - marginRight
contentHeight = pageHeight - marginTop  - marginBottom

For a 720 × 405 page with margins {top:34, right:50, bottom:34, left:50}:

  • contentWidth = 720 - 50 - 50 = 620 pt
  • contentHeight = 405 - 34 - 34 = 337 pt

In a 3-column story with gutter 12:

  • columnWidth = (620 - 12 × 2) / 3 = 198.67 pt

Line height in points = fontSize × lineHeight. For 10pt / 1.35: 13.5 pt per line.

Lines per column = floor(contentHeight / lineHeightPt).


3. Fonts

Built-in font families available without registration:
Arimo, Tinos, Cousine, Caladea, Carlito, Noto Sans JP (CJK), Noto Sans Arabic, Noto Sans Thai, Noto Sans Devanagari.

Reference a built-in family by name in fontFamily; no fonts block needed:

"fonts": { "regular": "Arimo" }

For custom font files, register by role:

"fonts": {
  "regular":    "path/to/font.ttf",
  "bold":       "path/to/font-bold.ttf",
  "italic":     "path/to/font-italic.ttf",
  "bolditalic": "path/to/font-bolditalic.ttf"
}

The engine maps fontWeight/fontStyle to these slots at render time. For named non-system fonts in inline spans use fontFamily on properties.style of a "text" child.

Standard PDF 1.4 built-in fonts (zero-embed, Latin-only docs via StandardFontManager):
Helvetica, Times New Roman, Courier — common aliases like Arial → Helvetica resolve automatically.


4. Styles Table

Every type string is a key into styles. Style resolution: styles[element.type] (base) merged with properties.style (override).

"styles": {
  "heading": {
    "fontSize": 22, "fontWeight": "bold",
    "marginBottom": 14, "keepWithNext": true,
    "hyphenation": "off"
  },
  "body": {
    "fontSize": 10, "marginBottom": 10,
    "allowLineSplit": true, "orphans": 2, "widows": 2,
    "textAlign": "justify"
  },
  "kicker": {
    "fontSize": 6.5, "letterSpacing": 1.2, "fontFamily": "Cousine",
    "textAlign": "center", "marginBottom": 9, "keepWithNext": true
  },
  "table-cell": {
    "fontFamily": "Cousine", "fontSize": 7,
    "paddingTop": 3, "paddingBottom": 3, "paddingLeft": 4, "paddingRight": 4
  }
}

Any element type you invent is valid — just add it to styles.


5. Block Element Types

{ "type": "heading", "content": "My Title" }
{ "type": "body", "content": "Plain paragraph." }
{ "type": "body", "content": "", "children": [ ... ] }

Special structural types handled by the engine:

type Role
"story" Multi-column DTP zone; carries columns, gutter, balance as top-level keys
"table" Table container; optional table top-level key for config
"table-row" Row inside a table
"table-cell" Cell inside a row; supports colSpan, rowSpan in properties
"zone-map" Independent-region layout; zoneLayout + zones[] at top level
"strip" Horizontal slot layout; stripLayout + slots[] at top level
"image" Block or inline image; image data at top level

All other type strings are user-defined and look up styles only.

Use "content": "" (empty string) on container elements (table, story, zone-map, strip). Container elements and image elements do not require content but it's harmless to include it.


6. Top-Level Element Keys (AST 1.1)

In AST 1.1, several configuration objects moved out of properties and became top-level keys on the element. This is the most important change from 1.0.

Key Element type(s) Purpose
content all Text content string
children all block Inline runs or child block elements
name any Scripting target name
type all Element type string
image "image" Image data (data, mimeType, fit)
table "table" Table config (headerRows, repeatHeader, columns, etc.)
dropCap any block Drop cap config
columnSpan story children "all" or number
placement story children Float/absolute placement directive
columns "story" Column count
gutter "story" Inter-column gap
balance "story" Equal-height column balancing
zones "zone-map" Array of zone definitions
zoneLayout "zone-map" Column sizing + gap config
slots "strip" Array of slot definitions
stripLayout "strip" Track sizing + gap config
properties all style, sourceId, colSpan, rowSpan, semanticRole, etc.

Exact ElementProperties interface (no other keys are valid):

interface ElementProperties {
    style?: Partial<ElementStyle>;   // inline style overrides
    colSpan?: number;                // table-cell only
    rowSpan?: number;                // table-cell only
    sourceId?: string;
    linkTarget?: string;             // inline text/inline elements
    semanticRole?: string;           // "header" on table-row
    reflowKey?: string;
    keepWithNext?: boolean;
    marginTop?: number;
    marginBottom?: number;
    pageOverrides?: {
        header?: PageRegionContent | null;
        footer?: PageRegionContent | null;
    };
    language?: string;               // code blocks
}

colSpan and rowSpan remain inside properties (not top-level).


7. Inline Runs (Rich Text)

When a paragraph has mixed styling, use children instead of content. Set "content": "" at the paragraph level.

{
  "type": "body",
  "content": "",
  "children": [
    { "type": "text", "content": "Normal text, then " },
    { "type": "text", "content": "bold", "properties": { "style": { "fontWeight": 700 } } },
    { "type": "text", "content": " and " },
    { "type": "text", "content": "italic", "properties": { "style": { "fontStyle": "italic" } } },
    { "type": "text", "content": " and a " },
    {
      "type": "text",
      "content": "code span",
      "properties": { "style": {
        "fontFamily": "Cousine", "fontSize": 8.2,
        "backgroundColor": "#ecdfc8", "color": "#4a2c0a",
        "paddingLeft": 2, "paddingRight": 2
      }}
    },
    { "type": "text", "content": " and done." }
  ]
}

Inline styles quick-reference

Effect Style property
Bold "fontWeight": 700 or "bold"
Italic "fontStyle": "italic"
Color "color": "#rrggbb"
Highlight "backgroundColor": "#rrggbb"
Monospace "fontFamily": "Cousine"
Larger/smaller "fontSize": N
Letter spacing "letterSpacing": N
Padding (code span) "paddingLeft": N, "paddingRight": N

Inline box (pill / badge)

{
  "type": "inline-box",
  "content": "LATIN",
  "properties": { "style": {
    "fontSize": 6, "fontFamily": "Cousine",
    "backgroundColor": "#e0e8f0", "color": "#2a4a6a",
    "padding": 2,
    "borderWidth": 0.5, "borderColor": "#9ab",
    "borderRadius": 2,
    "verticalAlign": "baseline", "baselineShift": 0,
    "inlineMarginLeft": 2, "inlineMarginRight": 2
  }}
}

Inline image

image is a top-level key in AST 1.1. Size and alignment go in properties.style.

{
  "type": "image",
  "content": "",
  "image": {
    "data": "<base64>",
    "mimeType": "image/png",
    "fit": "contain"
  },
  "properties": {
    "style": {
      "width": 16, "height": 16,
      "verticalAlign": "baseline", "baselineShift": 0,
      "inlineMarginLeft": 1, "inlineMarginRight": 3
    }
  }
}

verticalAlign options: "baseline", "text-top", "middle", "text-bottom", "bottom".

GOTCHA: Do NOT use \u202f (narrow no-break space U+202F) in content strings — it is not in most font glyph sets and renders as □. Use a regular space or omit it.


8. Story: Multi-Column DTP Layout

{
  "type": "story",
  "content": "",
  "columns": 3,
  "gutter": 12,
  "balance": false,
  "children": [
    { "type": "body", "content": "Column text flows here..." },
    ...
  ]
}
  • columns — number of columns (default 1)
  • gutter — gap between columns in points
  • balance — if true, distributes content evenly across columns (avoid with float obstacles)

Column flow: depth-first. Col 1 fills completely before col 2 begins.

Story height = height of the tallest column. Adding more text to fill empty columns is safe — it flows into subsequent columns without increasing story height, as long as col 1 is already at max.

Estimating content fill:

  • Lines per column ≈ columnHeight / (fontSize × lineHeight)
  • Words per line ≈ columnWidth / (fontSize × 0.55) (rough estimate for proportional fonts)
  • Use the layout snapshot (--emit-layout) to measure actual column heights

9. Floats and Obstacles

Any block element inside a story can be floated using the top-level placement key.

Block floats — both properties.style.width and properties.style.height are required; if either is missing the element renders as a normal block instead.

{
  "type": "pull-quote",
  "content": "Any block can float — not just images.",
  "placement": {
    "mode": "float",
    "align": "left",
    "wrap": "around",
    "gap": 8
  },
  "properties": {
    "style": { "width": 120, "height": 60 }
  }
}

Image floats — image elements may omit width/height and the engine derives them from intrinsic dimensions.

{
  "type": "obstacleImg",
  "content": "",
  "image": {
    "data": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR42mP48OQMAAVoAqEPT7KoAAAAAElFTkSuQmCC",
    "mimeType": "image/png",
    "fit": "fill"
  },
  "placement": {
    "mode": "float",
    "align": "left",
    "wrap": "around",
    "gap": 10
  },
  "properties": {
    "sourceId": "my-float",
    "style": { "width": 78, "height": 50 }
  }
}

The float element goes in story.children. Exclusion field = style.width + gap.

wrap options: "around" (text wraps both sides), "top-bottom" (no side wrap), "none".
align options: "left", "right", "center".

COLUMN WIDTH WARNING: Before floating an element with wrap: "around", verify that columnWidth - floatWidth - gap leaves at least ~80 pt for the wrapping text. In a 3-column layout on a 612 pt page with 60 pt margins and 16 pt gutters, each column is only ~154 pt wide — a 128 pt float leaves ~14 pt, causing single-character line breaks. For pull-quotes in multi-column stories, split the story into two story blocks and place the pull-quote as a standalone element between them (see the columnSpan pitfalls section). Only use wrap: "around" floats when the float width is well under half the column width.

CRITICAL ORDERING RULE: If you combine a drop cap paragraph with a float obstacle, the drop cap paragraph MUST be the FIRST child of the story, and the float obstacle MUST come AFTER it. Reversing this causes both to anchor at the same y coordinate (visual overlap).

"children": [
  {
    "type": "body",
    "content": "Drop cap paragraph...",
    "dropCap": { "enabled": true, "lines": 3, "gap": 5,
      "characterStyle": { "fontFamily": "Tinos", "fontWeight": 700, "color": "#7a3a52" }
    }
  },
  {
    "type": "pull-quote",
    "content": "...",
    "placement": { "mode": "float", "align": "left", "wrap": "around", "gap": 8 },
    "properties": { "style": { "width": 120, "height": 60 } }
  },
  { "type": "body", "content": "Second paragraph wraps around obstacle..." }
]

Story-absolute positioning (pins image element at exact coordinates within the story):

{
  "type": "image",
  "image": { ... },
  "placement": { "mode": "story-absolute", "x": 120, "y": 45, "wrap": "none" }
}

Fixture: 20-block-floats-and-column-span.json — block floats alongside column spans in a 3-column story.


9a. Column Spans

A child of a multi-column story can break the column flow and span the full story width using the top-level columnSpan key:

{
  "type": "section-heading",
  "content": "Part Two",
  "columnSpan": "all"
}

What happens:

  1. All content above the span element is laid out normally in columns.
  2. The spanning element is placed at full story width at the current cursor position.
  3. Column flow resets to column 1 at the bottom edge of the span. Subsequent children resume as a fresh N-column arrangement.

columnSpan accepts:

  • "all" — spans every column (recommended).
  • A number ≥ 2 — treated as full-width in the current implementation.

NOTE: columnSpan interacts with balance: true. Use balance: false (the default) in stories that contain spanning elements.

Fixture: 20-block-floats-and-column-span.json


10. Drop Caps

dropCap is a top-level element key in AST 1.1.

{
  "type": "body",
  "content": "Every element arrived through collision...",
  "dropCap": {
    "enabled": true,
    "lines": 3,
    "gap": 5,
    "characterStyle": {
      "fontFamily": "Tinos",
      "fontWeight": 700,
      "color": "#7a3a52"
    }
  }
}
  • lines — how many body lines tall the drop cap spans (default 3)
  • characters — number of leading characters to enlarge (default 1)
  • gap — horizontal gap in points between cap and body text
  • characterStyle — overrides only the enlarged character(s)

11. Tables

table config is a top-level element key in AST 1.1. A table element without it uses defaults.

{
  "type": "table",
  "content": "",
  "table": {
    "headerRows": 1,
    "repeatHeader": true,
    "columnGap": 6,
    "rowGap": 0,
    "columns": [
      { "mode": "fixed", "value": 80 },
      { "mode": "flex", "fr": 2, "min": 60 },
      { "mode": "flex", "fr": 1 }
    ],
    "cellStyle": { "fontFamily": "Cousine", "fontSize": 7, "paddingLeft": 4 },
    "headerCellStyle": { "fontWeight": 700, "fontSize": 7, "color": "#fff", "backgroundColor": "#444" }
  },
  "children": [
    {
      "type": "table-row",
      "content": "",
      "properties": { "semanticRole": "header" },
      "children": [
        { "type": "table-cell", "content": "Name" },
        { "type": "table-cell", "content": "Description" },
        { "type": "table-cell", "content": "Value" }
      ]
    },
    {
      "type": "table-row",
      "content": "",
      "children": [
        { "type": "table-cell", "content": "Alice", "properties": { "rowSpan": 2 } },
        { "type": "table-cell", "content": "Section header", "properties": { "colSpan": 2 } }
      ]
    },
    {
      "type": "table-row",
      "content": "",
      "children": [
        { "type": "table-cell", "content": "Detail" },
        { "type": "table-cell", "content": "42" }
      ]
    }
  ]
}

Key details:

  • semanticRole: "header" on the row — required to mark the header row (in properties)
  • repeatHeader: true — repeats header rows on continuation pages
  • colSpan and rowSpan are in properties (not top-level, not properties.style)
  • Column mode: "fixed" (exact value pt), "flex" (fractional share via fr), "auto" (size to content)
  • Table cells can have children (inline runs) instead of content, same as paragraphs

Exact table config interface (no other keys are valid):

interface TableLayoutOptions {
    headerRows?: number;
    repeatHeader?: boolean;
    columnGap?: number;
    rowGap?: number;
    columns?: TableColumnSizing[];
    cellStyle?: Partial<ElementStyle>;
    headerCellStyle?: Partial<ElementStyle>;
}
  • alternateRowStyle, borderWidth, borderColor, width, height do not exist in this interface.
  • To add borders or dimensions to the table element, use properties.style on the table element itself: "properties": { "style": { "borderWidth": 0.4, "borderColor": "#ccc" } }.
  • To stripe alternate rows, use "properties": { "style": { "backgroundColor": "..." } } on individual table-row elements.
  • Column definitions use TableColumnSizing objects — { "mode": "fixed", "value": 110 }, never { "width": 110 }.

Section rows (full-width label row spanning all columns):

{
  "type": "table-row",
  "content": "",
  "children": [
    {
      "type": "table-cell",
      "content": "SECTION HEADER",
      "properties": {
        "colSpan": 5,
        "style": { "backgroundColor": "#3a2a2a", "color": "#fff", "fontWeight": 700 }
      }
    }
  ]
}

12. Zone Map: Independent Layout Regions

A zone-map divides a horizontal strip of the page into independent layout columns. Each zone runs its own non-paginating layout pass — content in zone A has no knowledge of zone B. The zone-map always moves to the next page as a unit if it does not fit.

zones and zoneLayout are top-level element keys.

Two-column sidebar

{
  "type": "zone-map",
  "properties": {
    "style": { "marginTop": 12, "marginBottom": 12 }
  },
  "zones": [
    {
      "id": "main",
      "elements": [
        { "type": "h2", "content": "Main Content" },
        { "type": "p",  "content": "Body text." }
      ]
    },
    {
      "id": "sidebar",
      "elements": [
        { "type": "sidebar-label", "content": "KEY FACT" },
        { "type": "sidebar-body",  "content": "Sidebar note." }
      ]
    }
  ],
  "zoneLayout": {
    "columns": [
      { "mode": "flex", "fr": 2 },
      { "mode": "flex", "fr": 1 }
    ],
    "gap": 16
  }
}

Exact ZoneLayoutOptions interface (no other keys are valid — tracks does not exist here, use columns):

interface ZoneLayoutOptions {
    columns?: TableColumnSizing[];   // same objects as table/strip; NOT "tracks"
    gap?: number;
    frameOverflow?: 'move-whole' | 'continue';
    worldBehavior?: 'fixed' | 'spanning' | 'expandable';
}
  • zones[] — region descriptors. Each carries id (optional) and elements[].
  • zoneLayout.columns — track sizing array (same TableColumnSizing objects as tables and strips). Omit for equal-width columns.
  • zoneLayout.gap — gap between columns in points (default 0).
  • Zone height: the tallest zone determines the zone-map's height in the document flow.

Equal three-column strip

{
  "type": "zone-map",
  "zones": [
    { "id": "a", "elements": [ { "type": "p", "content": "Col 1" } ] },
    { "id": "b", "elements": [ { "type": "p", "content": "Col 2" } ] },
    { "id": "c", "elements": [ { "type": "p", "content": "Col 3" } ] }
  ],
  "zoneLayout": { "gap": 12 }
}

When columns is omitted, all zones receive equal width.

Zone-map vs story: Use story when the same content flow snakes across columns. Use zone-map when each column has independent content (e.g. a sidebar, a three-fact strip).

Fixture: 21-zone-map-sidebar.json


13. Strip: Horizontal Slot Layout

A strip divides a row into horizontally-sized slots with independent layout contexts. Ideal for header/footer composition (logo + title, three-part folio). slots and stripLayout are top-level element keys.

{
  "type": "strip",
  "stripLayout": {
    "tracks": [
      { "mode": "fixed", "value": 16 },
      { "mode": "flex", "fr": 1 }
    ],
    "gap": 8
  },
  "slots": [
    {
      "id": "logo",
      "elements": [
        {
          "type": "image",
          "image": { "mimeType": "image/png", "fit": "contain", "data": "<base64>" },
          "properties": { "style": { "width": 12, "height": 12, "marginBottom": 0 } }
        }
      ]
    },
    {
      "id": "title",
      "elements": [
        { "type": "rh-odd", "content": "Chapter Title" }
      ]
    }
  ]
}
  • stripLayout.tracks — array of TableColumnSizing objects (shared with table.columns); never use CSS-like strings such as "1fr" or "auto" — these are invalid and will cause a validation error

    Exact StripLayoutOptions interface (no other keys are valid — height does not exist here):

    interface StripLayoutOptions { tracks?: TableColumnSizing[]; gap?: number; }

    Exact TableColumnSizing interface (no other keys are valid):

    interface TableColumnSizing {
        mode?: 'fixed' | 'auto' | 'flex';
        value?: number;   // points, used with mode: 'fixed'
        fr?: number;      // fractional share, used with mode: 'flex'
        min?: number;
        max?: number;
        basis?: number;
        minContent?: number;
        maxContent?: number;
        grow?: number;
        shrink?: number;
    }

    Common patterns: { "mode": "flex", "fr": 1 }, { "mode": "fixed", "value": 86 }, { "mode": "auto" }

  • stripLayout.gap — inter-slot gap in points

  • slots[] — each carries id (optional) and elements[]

  • Each slot is an independent layout context; content does not flow between slots

Three-part footer folio:

{
  "type": "strip",
  "stripLayout": {
    "tracks": [
      { "mode": "flex", "fr": 1 },
      { "mode": "fixed", "value": 86 },
      { "mode": "flex", "fr": 1 }
    ],
    "gap": 8
  },
  "slots": [
    { "id": "left",   "elements": [ { "type": "folio-left",  "content": "Book Title" } ] },
    { "id": "center", "elements": [ { "type": "folio-page",  "content": "Page {pageNumber} of {totalPages}" } ] },
    { "id": "right",  "elements": [ { "type": "folio-right", "content": "Chapter Name" } ] }
  ]
}

Fixture: 17-header-footer-test.json — strips in headers/footers with logo + title


14. Headers and Footers

"header": {
  "firstPage": null,
  "odd": {
    "elements": [
      { "type": "rh-odd", "content": "Chapter Title" }
    ]
  },
  "even": {
    "elements": [
      { "type": "rh-even", "content": "Book Title" }
    ]
  }
},
"footer": {
  "firstPage": null,
  "default": {
    "elements": [
      {
        "type": "strip",
        "stripLayout": {
          "tracks": [
            { "mode": "flex", "fr": 1 },
            { "mode": "fixed", "value": 32 },
            { "mode": "flex", "fr": 1 }
          ]
        },
        "slots": [
          { "id": "left",   "elements": [ { "type": "folio-left",  "content": "Left text" } ] },
          { "id": "center", "elements": [ { "type": "folio-page",  "content": "{pageNumber}" } ] },
          { "id": "right",  "elements": [ { "type": "folio-right", "content": "Right text" } ] }
        ]
      }
    ]
  }
}
  • Selector priority: firstPage > odd/even > default
  • firstPage: null — suppresses header/footer on page 1
  • {pageNumber} — logical page number (counts only pages where token appears)
  • {physicalPageNumber} — absolute sheet count
  • {totalPages} — total physical page count (resolved after pagination settles)
  • headerInsetTop/Bottom, footerInsetTop/Bottom — margin insets in layout

Per-page override on an element:

{
  "type": "chapter-title",
  "content": "Chapter II",
  "properties": {
    "style": { "pageBreakBefore": true },
    "pageOverrides": {
      "header": {
        "elements": [
          {
            "type": "strip",
            "stripLayout": { "tracks": [ { "mode": "fixed", "value": 16 }, { "mode": "flex", "fr": 1 } ], "gap": 8 },
            "slots": [
              { "id": "logo",  "elements": [ { "type": "image", "image": { "mimeType": "image/png", "fit": "contain", "data": "..." }, "properties": { "style": { "width": 12, "height": 12 } } } ] },
              { "id": "title", "elements": [ { "type": "rh-odd", "content": "Chapter II" } ] }
            ]
          }
        ]
      },
      "footer": null
    }
  }
}

Override applies only to the first page the element lands on. Setting a region to null suppresses it for that page.


15. Multilingual and Scripts

Enable optical scaling in layout:

"opticalScaling": {
  "enabled": true,
  "cjk": 0.88,
  "thai": 0.92,
  "devanagari": 0.95,
  "arabic": 0.92
}

For RTL text, set direction and lang on the element:

{
  "type": "text",
  "content": "مرحباً بالعالم",
  "properties": { "style": {
    "fontFamily": "Noto Sans Arabic",
    "direction": "rtl",
    "lang": "ar"
  }}
}

Mixed-script inline paragraph:

{
  "type": "body",
  "content": "",
  "children": [
    { "type": "text", "content": "Latin baseline, then " },
    { "type": "text", "content": "مرحباً", "properties": { "style": { "fontFamily": "Noto Sans Arabic", "direction": "rtl", "lang": "ar" }}},
    { "type": "text", "content": " Arabic, " },
    { "type": "text", "content": "สวัสดี", "properties": { "style": { "fontFamily": "Noto Sans Thai", "lang": "th" }}},
    { "type": "text", "content": " Thai, " },
    { "type": "text", "content": "精確", "properties": { "style": { "fontFamily": "Noto Sans JP", "lang": "ja" }}},
    { "type": "text", "content": " CJK." }
  ]
}

16. Pagination Control

Property Where Effect
pageBreakBefore: true properties.style Force page break before this element
keepWithNext: true style or properties Keep with the following element
allowLineSplit: true style Allow paragraph to split across pages
orphans: 2 style Min lines at bottom before split
widows: 2 style Min lines at top of continuation
overflowPolicy style "clip", "move-whole", or "error"

Standard paragraph boilerplate:

"body": {
  "marginBottom": 10,
  "allowLineSplit": true,
  "orphans": 2,
  "widows": 2,
  "textAlign": "justify"
}

Continuation markers (annotate where a paragraph was split):

{
  "type": "p",
  "content": "A very long paragraph...",
  "properties": {
    "paginationContinuation": {
      "enabled": true,
      "markerAfterSplit": {
        "type": "split-marker",
        "content": "Continued on next page"
      },
      "markerBeforeContinuation": {
        "type": "split-marker",
        "content": "Continued from previous page"
      }
    }
  }
}

17. Document Scripting

Scripts are defined as YAML frontmatter before the JSON body. The format is a single file: YAML front matter (between --- lines) followed by the JSON document.

File format

---
TITLE: "My Value"
methods:
  onLoad(): |
    setContent("greeting", TITLE)
  summary_onMessage(from, msg): |
    setContent(self, `Received: ${msg.payload.text}`)
  greeter_onCreate(): |
    sendMessage("summary", { subject: "greet", payload: { text: "Hello!" } })
  onReady(): |
    const count = elementsByType("h1").length
    deleteElement("placeholder")
---
{
  "documentVersion": "1.1",
  ...
  "elements": [
    { "type": "p", "name": "greeting",    "content": "Waiting..." },
    { "type": "p", "name": "summary",     "content": "Waiting..." },
    { "type": "p", "name": "greeter",     "content": "Sender." },
    { "type": "p", "name": "placeholder", "content": "Will be deleted." }
  ]
}

Lifecycle hooks

Method name When Context
onLoad() Before any layout Document-level
elementName_onCreate() When element is first created Actor (self = element)
onBeforeLayout() Before layout settlement Document-level
onChanged() After any DOM mutation triggers relayout Document-level
onReady() After layout fully settles Document-level
elementName_onMessage(from, msg) When element receives a message Actor (self = element)

Scripting API

// Read
doc.getPageCount()              // settled page count
elementsByType("h1")            // array of elements by type
element("myName")               // element by name (or null)
self                            // current actor element (in actor hooks)
self.content                    // element's current content

// Write content
setContent("targetName", text)  // or: setContent(self, text)

// Structural mutations (actor-local, from within _onMessage or _onCreate)
replace([...elements])          // replace self with new elements
append({ ...element })          // append after self
prepend({ ...element })         // prepend before self

// Document-level mutations (from onReady / onLoad)
replaceElement("name", [...elements])  // replace named element with array
deleteElement("name")                  // delete named element

// Messaging
sendMessage("targetName", { subject: "greet", payload: { ... } })

Elements in scripts use JavaScript object syntax (no quotes on keys), same structure as JSON elements. Elements can be named with name: for scripting targets:

{ "type": "p", "name": "myTarget", "content": "Initial text." }

Example: page count summary

---
methods:
  onReady(): |
    const pages = doc.getPageCount()
    const headings = elementsByType("h1")
    sendMessage("summary", {
      subject: "ready",
      payload: { pages, headingCount: headings.length }
    })
  summary_onMessage(from, msg): |
    if (from.name !== "doc") return
    setContent(self, `${msg.payload.pages} pages, ${msg.payload.headingCount} headings.`)
---
{ "documentVersion": "1.1", ... }

Fixtures: scripting/00-hello-world.json, scripting/01-message-growth.json, scripting/02-ready-summary.json, scripting/04-replace-showcase.json, scripting/07-live-delete-showcase.json


18. Common Layout Patterns

Kicker + Title + Story (DTP opener)

[
  {
    "type": "kicker",
    "content": "SPECIMEN BLUEPRINT  ·  ENGINE REPORT",
    "properties": { "keepWithNext": true }
  },
  {
    "type": "pageTitle",
    "content": "Measured in Points",
    "properties": { "keepWithNext": true }
  },
  {
    "type": "story",
    "content": "",
    "columns": 3,
    "gutter": 12,
    "children": [ ... ]
  }
]

Three-column story with drop cap and block float

{
  "type": "story",
  "content": "",
  "columns": 3,
  "gutter": 12,
  "children": [
    {
      "type": "body",
      "content": "Drop-cap paragraph text...",
      "dropCap": {
        "enabled": true, "lines": 3, "gap": 5,
        "characterStyle": { "fontFamily": "Tinos", "fontWeight": 700, "color": "#7a3a52" }
      }
    },
    {
      "type": "pull-quote",
      "content": "Any block element can float — no image required.",
      "placement": { "mode": "float", "align": "left", "wrap": "around", "gap": 10 },
      "properties": { "style": { "width": 78, "height": 50 } }
    },
    {
      "type": "body",
      "content": "",
      "children": [
        { "type": "text", "content": "Second paragraph wraps around the float — no " },
        { "type": "text", "content": "iteration", "properties": { "style": { "fontStyle": "italic" } } },
        { "type": "text", "content": ", no backtracking." }
      ]
    }
  ]
}

Cross-page table with repeated header and merged cells

{
  "type": "table",
  "content": "",
  "table": {
    "headerRows": 1,
    "repeatHeader": true,
    "columnGap": 6,
    "columns": [
      { "mode": "flex", "fr": 1 },
      { "mode": "flex", "fr": 1 },
      { "mode": "flex", "fr": 2 },
      { "mode": "fixed", "value": 60 },
      { "mode": "fixed", "value": 60 }
    ]
  },
  "children": [
    {
      "type": "table-row", "content": "",
      "properties": { "semanticRole": "header" },
      "children": [
        { "type": "table-cell", "content": "ID" },
        { "type": "table-cell", "content": "Type" },
        { "type": "table-cell", "content": "Description" },
        { "type": "table-cell", "content": "Origin" },
        { "type": "table-cell", "content": "Size" }
      ]
    },
    {
      "type": "table-row", "content": "",
      "children": [
        { "type": "table-cell", "content": "§1", "properties": { "colSpan": 5,
          "style": { "backgroundColor": "#3a2a2a", "color": "#fdf6e3", "fontWeight": 700 }
        }}
      ]
    }
  ]
}

Justified body text with advanced hyphenation

"layout": {
  "hyphenation": "auto",
  "justifyEngine": "advanced",
  "justifyStrategy": "auto"
}
"styles": {
  "body": {
    "textAlign": "justify",
    "allowLineSplit": true,
    "orphans": 2,
    "widows": 2,
    "hyphenation": "auto",
    "hyphenMinWordLength": 6,
    "hyphenMinPrefix": 3,
    "hyphenMinSuffix": 3
  }
}

19. Critical Gotchas

documentVersion must be "1.1"

The engine validates this field. Using "1.0" with AST 1.1 features will fail.

image, table, dropCap, columnSpan, placement are top-level keys in 1.1

These are NOT nested inside properties in AST 1.1. This is the most common migration mistake from 1.0.

// WRONG (old 1.0 style):
{ "type": "image", "properties": { "image": { ... }, "style": { ... } } }

// CORRECT (1.1 style):
{ "type": "image", "image": { ... }, "properties": { "style": { ... } } }

Block floats require explicit style.width and style.height

Non-image block floats must declare both in properties.style. If either is missing, the element silently falls through to normal block layout. Image floats can omit them.

Drop cap MUST precede float obstacle

In story children, the drop-cap paragraph must come before the float obstacle. If you reverse the order, both anchor at the same y position causing visual overlap.

color is not allowed in the layout block

The engine rejects layout.color. Body text color belongs in styles["body"].color or per-element properties.style.color.

Unicode narrow no-break space (U+202F) renders as □

Avoid \u202f in content strings. Use a regular ASCII space instead.

colSpan/rowSpan are in properties, not top-level

{ "type": "table-cell", "content": "Wide", "properties": { "colSpan": 3 } }

Putting them under properties.style has no effect.

repeatHeader requires semanticRole: "header" on the row

{ "type": "table-row", "content": "", "properties": { "semanticRole": "header" }, "children": [...] }

Without semanticRole, repeatHeader: true does nothing.

balance: true interacts badly with float obstacles

Use balance: false (the default) whenever a story contains float elements.

columnSpan interacts with balance: true

Use balance: false in stories that contain spanning elements.

Do NOT use columnSpan: "all" inside a story for pull-quotes

columnSpan inside a story positions the span at the cursor in the current column, but other columns do not update their cursor, so post-span content in columns 2+ starts at Y=0 (page top), overlapping the span visually.

For a full-width pull-quote between paragraphs, split the story and place the pull-quote as a standalone element in elements:

{ "type": "story", "columns": 3, "gutter": 16, "balance": false, "children": [ ...paragraphs before... ] },
{ "type": "pull-quote", "content": "..." },
{ "type": "story", "columns": 3, "gutter": 16, "balance": false, "children": [ ...paragraphs after... ] }

Critical: keep story 1 short enough that column 1 does NOT fill to the page bottom. After a multi-column story ends, the page cursor advances to max(col1_bottom, col2_bottom, col3_bottom). If column 1 is full (page-height exhausted), the pull-quote is pushed to the next page. Size story 1 so the total content height in column 1 is well under contentHeight − headerHeight − footerHeight. A single opening paragraph is usually ideal. Story 2 receives all remaining paragraphs and flows across pages normally.

columnSpan must NOT appear in the styles block

columnSpan is a top-level element key, not a style property. Place it on the element itself, never inside a style definition.

Header/footer selectors must wrap elements in { "elements": [...] }

The value of each selector (default, odd, even, firstPage) must be an object with an elements array — not the element directly:

// WRONG:
"header": { "default": { "type": "strip", "slots": [...] } }
// CORRECT:
"header": { "default": { "elements": [ { "type": "strip", "slots": [...] } ] } }

Use keepWithNext: true on columnSpan elements that introduce content

If a spanning banner lands near the bottom of a page with little room below it, the reset columns overflow to the next page — leaving the banner visually isolated. Add "keepWithNext": true to properties to prevent this: the engine measures whether the next flow child fits after the span and, if not, pushes the span to the next page.

{
  "type": "section-flag",
  "content": "WORLD & NATION",
  "columnSpan": "all",
  "properties": { "keepWithNext": true }
}

For a banner that opens a new story with nothing before it, placing it outside the story as a standalone element is also valid and needs no columnSpan:

{ "type": "section-flag", "content": "WORLD & NATION" },
{ "type": "story", "columns": 3, "children": [ ... ] }

Use marginTop/marginBottom for inter-element spacing, not paddingTop/paddingBottom

paddingTop on a zone-map (or any container) adds space inside the element, before its first child. It does not create space between the container and the preceding sibling. Use marginTop on properties.style to push a zone-map away from the element above it:

// WRONG — paddingTop does not separate the zone-map from a preceding section-flag:
{ "type": "zone-map", "properties": { "style": { "paddingTop": 16 } }, ... }

// CORRECT — marginTop creates space between the section-flag and the zone-map:
{ "type": "zone-map", "properties": { "style": { "marginTop": 16 } }, ... }

This applies to any block element: if you want space before an element, set marginTop on it (or marginBottom on the preceding one).

keepWithNext chains stop working across page boundaries

A chain of keepWithNext elements must fit together on a single page. Check that the total height fits contentHeight.

Page content must fit within contentHeight

Always compute: contentHeight = pageHeight - marginTop - marginBottom. Use --emit-layout to inspect actual box heights.

Scripts use YAML frontmatter; plain JSON cannot have scripts

If you need scripting, the file must be in the ---\n<yaml>\n---\n<json> format. Pure JSON files cannot contain scripts.

Actor mutations (replace, append, prepend) are actor-local

These only work from within _onMessage or _onCreate handlers. For document-level mutations use replaceElement and deleteElement from onReady.

Adding content to fill empty story columns is safe

Story height = max(column heights). If column 1 is already full, adding more text to the story just flows into columns 2 and 3 without changing the story height.


20. Workflow: Design → Measure → Adjust

  1. Sketch the layout — list elements, estimate column count and content volume
  2. Compute geometrycontentWidth, columnWidth, linesPerColumn, word count targets
  3. Write the JSON — start with layout, then styles, then elements
  4. Render with layout emit:
    node cli/dist/index.js -i doc.json -o doc.pdf --emit-layout doc.layout.json
  5. Inspect the layout JSON — check pages.length, box y and h values per page
  6. Adjust — trim or expand content, adjust margins, tweak styles
  7. Iterate — re-render, re-inspect until correct page count and visual balance

Key layout JSON fields to check:

const layout = require('./doc.layout.json');
layout.pages.length;
layout.pages[0].boxes.map(b => ({ type: b.type, y: b.y, h: b.h }));

21. Fixture Index

Regression fixtures (engine/tests/fixtures/regression/)

Fixture Demonstrates
00-all-capabilities Everything: CJK, inline styles, images, tables, story
01-text-flow-core Basic paragraphs, flow, orphan/widow
02-text-layout-advanced Advanced text layout, drop caps, page flow
03-typography-type-specimen Font weight/size/style spectrum
04-multilingual-scripts RTL, Thai, Devanagari, CJK in flow
05-page-size-letter-landscape LETTER landscape, 2-column story
06-page-size-custom-landscape Custom {width, height} page size
07-pagination-fragments Large content spanning many pages
08-dropcap-pagination Drop caps at page boundaries (top-level dropCap)
09-tables-spans-pagination colSpan, rowSpan, repeatHeader, multi-page table
10-packager-split-scenarios Split handling edge cases, top-level table config
11-story-image-floats Float images in story, wrap:around
12-inline-baseline-alignment verticalAlign, baselineShift, inline images (top-level image)
13-inline-rich-objects Inline images of various sizes in rich text
14-flow-images-multipage Block images across pages
15-story-multi-column 3-column story, balance, float obstacles
16-standard-fonts-pdf14 Standard PDF 1.4 fonts without embedding
17-header-footer-test firstPage/odd/even/default, pageOverrides, {pageNumber}, {totalPages}, strips
18-multilingual-arabic Full Arabic document, RTL, bidirectional
19-accepted-split-branching paginationContinuation, split markers
20-block-floats-and-column-span Block floats with placement, columnSpan: "all"
21-zone-map-sidebar Zone map: 70/30 flex split and equal three-column strip

Scripting fixtures (engine/tests/fixtures/scripting/)

Fixture Demonstrates
00-hello-world onLoad(), setContent(), YAML vars
01-message-growth _onCreate(), sendMessage, _onMessage, append()
02-ready-summary onReady(), elementsByType(), doc.getPageCount()
04-replace-showcase replaceElement(), onChanged(), element()
05-live-replace-message Actor-local replace() from _onMessage
06-live-insert-message Actor-local prepend() + append() from _onMessage
07-live-delete-showcase deleteElement() from onReady()

When in doubt, find the closest fixture to your task and study its JSON directly.

Categories