Document model

Every ScriptPub work parses into one canonical model. This is the data structure the file reads into and serializes from, and everything else — an editor, a reader, diff/merge, printing — operates on it.

ParsedDocument = {
  config: FormatConfig,       // the format — see /format/config
  content: ContentBlock[],    // one flat, ordered stream of blocks
  sections?: SectionSpan[],   // resolved section membership (see below)
  css?: string,               // the document's own stylesheet text — see /format/styling
}
  • content is one flat stream of blocks, in document order. A block never contains a section — sections are body-level spans over the stream, not containers inside blocks.
  • sections carries the resolved section membership as spans. Absent or empty means an unsectioned document (the whole stream).
  • css is the document’s own sheet — the text of <style id="scriptpub-style"> minus the machine-regenerated spub-base layer. See The stylesheet.

An engine may populate additional in-memory projections on a parsed document — ordered publication-file spans (files) and resolved navigation (toc) — when an app-level assembler or a package codec produces the document. They are never serialized into a .spub.html: an authored table of contents is real content (the toc section, a <nav> of real links), and a single file has no file spans.

ContentBlock

A block is one paragraph-level unit of the stream. Which of runs / rows it carries follows the block kind’s declared shape; a void block carries neither.

Field Type Description
kind* string The block kind — a config.blocks[].id, or the default Paragraph kind.
runs Run[] Inline content — text shape.
rows Cell[][] Rows of cells of blocks — grid shape (tables, side-by-side, wrappers).
attrs BlockAttrs Fixed-vocabulary attributes, including authored classes, EPUB semantics, accessibility, language, and custom data.

The three shapes

A block type declares one shape. There are exactly three:

Shape Content field Covers
text runs Paragraphs, headings, cues, dialogue — the default.
void (none — attrs only) Rules, media — images, audio, video (src and the other media attrs).
grid rows Tables, side-by-side / dual dialogue, card grids, <figure> / <details> / <blockquote>.

A grid is the one construct that covers tables, side-by-side dialogue, card grids, and single-cell wrappers alike. Its presentation — table vs css-grid vs masonry vs flow — is styling (display, grid-template-columns, …), not a distinct shape: a table is a grid; side-by-side dialogue is a one-row grid; a <figure> is a single-cell grid.

A grid nests exactly one level deep: a grid’s cells hold leaf blocks — text / void (plus lists via attrs) — never another grid. Cells carry the shared element attributes in addition to spans, header state, and alignment. This bounded nesting is deliberate — it keeps the model flat enough to diff, paginate, and reason about, while still expressing side-by-side dialogue and grids.

In a reflowable paged view, a semantic HTML table grid fragments between rows when it is taller than the available page area. Rows connected by rowspan form one indivisible row group, and leading grid.headerRows repeat on each continuation page. An authored break-inside: avoid (real CSS, in the sheet or on the element) still makes the complete table atomic. Other grid presentations remain atomic unless their format defines a presentation-specific fragmentation rule.

Lists are flat blocks, not a grid

A list item is a plain text block carrying list metadata — not a nested structure:

{ "kind": "paragraph", "runs": [ … ], "attrs": { "list": { "type": "ordered", "depth": 2 } } }

type is "ordered" or "unordered"; depth (default 1) expresses nesting as data. Ordered lists may additionally carry start on their first block and an explicit item value, preserving HTML/EPUB numbering without turning the list into a container. The file spelling synthesizes <ul>/<ol>/<li> on save and dissolves them on parse — the model never stores a <ul> as a block. Ordered lists number automatically, dovetailing with behavior.numbered.

Definition lists follow the same flatness: <dt> and <dd> are ordinary built-in text kinds (“term” / “definition”), and the <dl> wrapper is structural, never a kind — it dissolves on parse and one <dl> regroups each consecutive run of term/definition blocks on write (HTML’s optional name-value <div> wrappers inside a <dl> are transparent and not preserved).

Run

A run is a stretch of text — or one atomic inline object — under one mark stack. Exactly one of text / object is present.

Field Type Description
text string The text (a text run). May contain literal newlines; the file encodes them per syntax.html.newline (<br /> by default).
object InlineObject An atomic inline object (an object run) — an inline image or mathematical expression. One caret position; never split; carries its own shared element attributes.
marks* MarkUse[] The mark stack, outermost first.

A MarkUse is either a bare mark id ("em") or, when the mark carries attributes, an object { id, attrs } — a link’s href, ruby’s rt, a revision rev, or a publisher class / EPUB semantic / ARIA attribute. Marks nest freely, like HTML, and apply to object runs exactly as to text (a linked inline image or emphasized formula remains one object run under those marks). See Marks & links, Media, and Mathematics.

{
  "kind": "line",
  "runs": [
    { "text": "And every light that says ", "marks": [] },
    { "text": "come home", "marks": ["em"] },
    { "text": " says ", "marks": [] },
    { "text": "stay away", "marks": ["strong"] },
    { "text": " the same.", "marks": [] }
  ]
}

Cell

A grid cell is a list of leaf blocks, optionally spanning, marked as a header, or aligned:

Cell = {
  blocks: ContentBlock[],
  attrs?: {
    colspan?: number,
    rowspan?: number,
    header?: boolean,
    align?: "start" | "center" | "end",
  },
}

A grid block’s rows is a Cell[][] — an array of rows, each an array of cells. A cell’s blocks are leaf blocks (text / void, plus lists via attrs) — a grid never nests inside a grid.

SectionSpan

Sections are body-level elements in the file, but the resolved membership is stored as half-open spans over content:

SectionSpan = {
  section: string,                     // a section id — standard or custom
  start: number,                       // first block index (inclusive)
  end: number,                         // one-past-last block index (exclusive)
  matter?: "front" | "body" | "back",  // CUSTOM sections only (data-spub-matter)
}
  • section is a section id — one of the standard vocabulary or a custom id.
  • matter overrides the publication partition for a custom section (standard sections derive matter from the vocabulary table); it serializes as the data-spub-matter attribute.
  • Any block index covered by no span is an edge block — a direct <body> child outside every section element.

Spans are derived at parse and serialized as the body’s element structure, never stored as data. The grouped, section-by-section view a reader uses is likewise derived from these spans, never stored as nesting.

What is deliberately absent

  • No pagination directive. Page breaks and keeps are real CSS fragmentation properties (break-before / break-after / break-inside) in the sheet or on an element’s style attribute.
  • No serialized files / toc. They exist only as in-memory projections of app-level assembly or package codecs (above).
  • No section nesting, no block-level section field. Membership is the span list; blocks stay exactly as specified.