DESIGN.md@packages/postext/src/composed · git:20261020.c031ab0 · 2026-10-20 · sha256 88b99e735b4abbe4
DESIGN.md@packages/postext/src/composed git:20261020.c031ab0B
Immutable. This exact content never changes and is served at /api/v1/blob/88b99e735b4abbe4.
# Composed pages: design (P1, drnachio/postext#847)
Status: approved (P1); §5.8–§5.12 record what P4, P14, P7, P5 and P15 built. Programme tracker: drnachio/postext#846.
Draft types: [`types.ts`](./types.ts) (no runtime effect).
A composed page is a page whose form is a **template** (named frames, groups, decoration) held in
the configuration, and whose content is the Markdown between a `::::page` fence, poured into the
frames by **slot** name. Geometry never appears in the Markdown; content text never appears in the
configuration.
## 1. Vocabulary
<table>
<tr><th>Term</th><th>Lives in</th><th>What it is</th></tr>
<tr><td>template</td><td>config <code>pageTemplates[]</code></td><td>A named page (or spread) layout: frames, groups, decoration, header/footer, variants.</td></tr>
<tr><td>frame</td><td>template <code>frames[]</code></td><td>A named rectangle that receives typeset content. Has position, size rules, style, layer, fitting and continuation.</td></tr>
<tr><td>group</td><td>template <code>groups[]</code></td><td>A <code>stack</code> or <code>row</code> of frames that share space and move together.</td></tr>
<tr><td>decoration</td><td>template <code>decoration</code></td><td>A design slot of images, rules and boxes (no content text) painted with the page.</td></tr>
<tr><td>slot</td><td>Markdown</td><td>The name a piece of content asks for. Matches a frame name.</td></tr>
<tr><td>flow frame</td><td>template</td><td>The frame that receives content with no slot.</td></tr>
<tr><td>label</td><td>Markdown <code>::label</code></td><td>A short text set at a named anchor of a picture (maps, diagrams).</td></tr>
<tr><td>anchor</td><td>resource <code>anchors[]</code></td><td>A point (and optional box) on a picture, in fractions, shared by every locale.</td></tr>
</table>
## 2. Configuration
### 2.1 Templates (`pageTemplates`)
```ts
pageTemplates: PageTemplate[]
```
- `id` (required, unique), `name` (UI only).
- `extends`: id of a template whose fields this one inherits. Merge is by key: scalar fields
replace (`header`/`footer` whole); `grid` merges key by key; `frames` and `groups` merge **by
name** (a frame with the same name overrides the fields it writes, `fit`/`overflow`/`padding`
key by key and `placement` whole; `remove: true` drops it; new names are appended);
`decoration.elements` merge by element `id` (new ids appended). `spread`, `gutter` and `role` are
inherited; `id`, `name`, `extends` are not. Cycles are an error (`templateExtendsCycle`) and the
template is dropped, with every template that extends into the loop. The first template with an
id is the one it means (`templateDuplicate` for the others).
- `area`: the rectangle frame coordinates refer to: `'content'` (default, the page's content area,
mirrored per parity like the flow), `'trim'` or `'bleed'`.
- `grid` (optional): `{ columns, rows, columnGap, rowGap }` dividing `area`; frames may then be
placed by cell (`grid: { col, row, cols, rows }`, 1-based) instead of `placement`.
- `frames`, `groups`, `decoration`.
- `header` / `footer`: a design slot, `false` to hide, unset to inherit the running section's.
- `variants`: `{ recto?: TemplatePatch, verso?: TemplatePatch }`. The patch merges like `extends`
on the page of that parity, after the whole `extends` chain, the root's variant first; a spread
takes no variant. (Parity is the physical side, so left-to-right and right-to-left books
both work.)
- `spread: true`: the template describes a sheet of two facing pages. `area` spans both pages;
`gutter` gives the spine band frames must respect or may cross (`crossGutter: true` on a frame).
A spread template always starts on a verso (left page in LTR, right page in RTL books), adding a
parity blank like `breakToEvenPage` does for comic spreads.
- `overflowTemplate`: id of the template used for pages added by continuation (§5.6). Default: the
same template with only the frames named in `overflow.continueIn` filled.
- `bodyStyle`, `palette`: as heading styles have them, applied to the page.
- `role`: page role reported to design slots (`pages` filters); default `'composed'`.
### 2.2 Frames
```ts
interface FrameConfig {
name: string // slot name; unique per template
placement?: ElementPlacement // anchor/offset/size language of design slots
grid?: { col, row, cols?, rows? }
width?: FrameSize // Dimension | '%' of area | 'auto' | 'fill'
height?: FrameSize
minWidth?, maxWidth?, minHeight?, maxHeight?: Dimension
style?: string // a calloutStyles id (see 2.4); unset = frameless
layer?: number // paint order; default 1; decoration is 0; negative = below
rotate?: number // degrees, content and frame rotate together (P4)
verticalAlign?: 'top' | 'middle' | 'bottom' | 'justify'
columns?: number, columnGap?: Dimension
padding?: CalloutPaddingConfig // overrides the style's
exclude?: 'none' | 'push' // 'push': other frames' text wraps around this frame
flow?: boolean // receives content with no slot
next?: string // chain: overflow continues in this frame
takes?: 'h1'|'h2'|…|'h6'|'toc' // receives the first heading of that level with no slot, or the page's :::toc (#983)
pin?: boolean // never moves or resizes (aligned with artwork)
grow?: number, shrink?: number, priority?: number // group negotiation (§5)
fit?: { shrinkText?: boolean, minScale?: number, scaleImages?: boolean, imageMinScale?: number,
preferContinue?: boolean } // continue before 3 and 5 of the ladder (§5.4), default true
overflow?: { continueIn?: string, mark?: string | false }
writingMode?, direction? // as the page; for a vertical box on a horizontal page (#944, §5.15)
optional?: boolean // no slotEmpty warning
crossGutter?: boolean // on a spread: may cross the spine band
remove?: boolean // in a patch: drop the inherited frame
}
```
Position, in order of precedence: `grid` cell, then `placement`. `placement.anchor.to` may be
`'#otherFrame'` with the element-to-element edges design slots already know (`right-of`, `below`,
`align-top`…), so a frame can hang off another and move with it. Without either, a frame takes the
whole `area`.
Size: a `Dimension` is fixed. `'auto'` height (the default) is the content's height, clamped to
`minHeight`/`maxHeight`; `'auto'` width (#982, §5.18) is the width of the content's longest line plus
the padding, up to the room the anchor leaves and `maxWidth`. `'fill'` takes what is left up to the anchor's edge (as in design slots).
`takes` exists so the page title of a textbook topic is just `# Title` at the top of the page
fence: no slot attribute needed.
### 2.3 Groups
```ts
interface FrameGroupConfig {
name: string
kind?: 'stack' | 'row' // default 'stack'
frames?: string[] // order = order on the page (top→bottom, start→end)
gap?: Dimension
placement?, grid? // the group's own box; default: union of its frames' declared boxes
align?: 'start' | 'center' | 'end' | 'space-between' // where spare space goes
}
```
A frame belongs to at most one group. A group's name is distinct from every frame name (anchors
name both as `'#name'`). Inside a group the frames' own `placement` y (stack) or x
(row) is ignored: the group lays them out in order with `gap`. A group is itself a box that other
frames may anchor to (`'#group'`).
### 2.4 Frame styles
Decision: **frames reuse callout styles** (`calloutStyles[]`). A callout style already draws
everything the textbook boxes need: background, border, radius, padding, stripe, icon, the label
tab (`label`, with position and icon), title style, marker column, column gap. A new style family
would duplicate all of it and the Sandbox editors. The Sandbox's "frame-style library" (U5) is the
callout-style library filtered to styles used by templates, with usage counts.
Per-instance text such as a tab's words comes from the Markdown: `:::frame{slot="dinero"
label="El dinero"}` (or `title=`), exactly as for `:::callout`. A style's `title` default is a
whitelisted literal for `contentInConfig` only when it is one or two words.
Properties a callout style has that do not apply to frames (`placement`, `span`, `columns`,
`sideAtColumnEnd`, `floatBarrier`, `fixed`, `width`) are ignored when the style dresses a frame.
### 2.5 Decoration
`decoration: DesignSlot` with image, rule and box elements, and text elements whose content is only
placeholders (`{pageNumber}`, `{chapterTitle}`, `{attr.x}`) or whitelisted literals. Elements may
anchor to `'#frameName'` and then move with the frame (P14). Decoration is laid out **after** the
frames, with the frames' final boxes known; it never pushes text. `pin` on a decoration element
keeps it where the template says even if its anchor frame moves.
### 2.6 Resource anchors
`Resource.anchors` (comics, #557) gains label fields; existing comic anchors keep working:
```ts
interface ResourceAnchor {
id: string; x: number; y: number // fractions of the picture, top-left origin
head?, face? // comics, unchanged
width?: number // label measure, fraction of the picture width
align?: 'start' | 'center' | 'end' // label position relative to (x, y)
verticalAlign?: 'top' | 'middle' | 'bottom'
rotate?: number // degrees
style?: string // a labelStyles id
}
```
`labelStyles[]` (new, small): `{ id, paragraphStyle?, fontFamily?, fontSize?, fontWeight?,
fontStyle?, color?, textTransform?, letterSpacing?, halo?: { color, width }, background?,
padding?, minScale? }`. Anchors survive crops (`safeArea`) and splitter-free figure scaling because
they are fractions of the picture, mapped through the crop as `comicArtPointToPage` does.
### 2.7 Contents groups
Specified in #850: `toc.groups = { keepTogether, head: 'part' | 1…6, minEntriesAfterHead,
continuation: { enabled, mark } }`, `:::toc{keep="part" levels="1-2"}`. Independent of templates.
### 2.8 Checks (#849, not part of P2)
`checks.contentInConfig = { enabled?, allow?: string[], maxWords?: 2, maxChars?: 24 }` (#849).
Literals allowed without warning: per element at most `maxWords` words and `maxChars` characters
after removing placeholders (scripts without spaces: characters only); anything in `allow`; and,
when the check has the content, literals equal to the document's title, author or publisher
metadata. So: book title, publisher name and one- or two-word running heads ("Índice",
"Capítulo {chapterNumber}") pass; sentences fail.
### 2.9 Validation
The engine ignores unknown keys elsewhere; for `pageTemplates` it validates and warns (config
warnings, shown by `postext check` and the Sandbox): `templateDuplicate`, `templateExtendsCycle`,
`templateExtendsUnknown`, `frameDuplicate`, `frameStyleUnknown`, `frameAnchorUnknown` (anchor to a
frame or group that does not exist, or a cycle of anchors), `frameMinMax` (min > max, fixed size
outside min/max), `groupFrameUnknown`, `groupFrameTwice`, `frameChainLoop` (`next` cycle),
`overflowTargetUnknown`, `labelStyleUnknown`. Every warning carries the config path as a JSON
pointer: the entry for a duplicate (`/pageTemplates/3/frames/2`), the field at fault otherwise
(`/pageTemplates/3/frames/2/style`, `/pageTemplates/3/variants/recto/frames/0/next`), in the
template that wrote the field when it was inherited; `labelStyleUnknown` points into the resources
(`/resources/4/anchors/0/style`, with `resourceId`). `frameAnchorUnknown` also covers decoration
elements anchored to nothing the template has. Unknown keys and words of templates, frames, groups,
grids, `fit`, `overflow`, variants and label styles give `unknownConfigKey`/`unknownConfigValue`.
`resolvePageTemplate` applies the same fixes the warnings describe (anchor → `'container'`, broken
`next`/`continueIn` dropped, unknown group members skipped, unknown style → frameless), so the
layout never meets a dangling reference; min/max contradictions are left to the layout (min wins,
a fixed size is clamped).
`configVersion` goes from 14 to 15. Nothing older changes meaning, so the migration is a no-op;
the bump marks bundles that may hold templates so an older reader can warn.
## 3. Markdown
### 3.1 The page fence
```md
::::page{template="tema-cajas"}
…content…
::::
```
- **Four colons.** The parser learns fence lengths for this one directive: a line `::::page{…}`
(four or more colons) opens a composed page container; the closing line is a bare fence of the
**same** number of colons. Inside, three-colon containers (`:::frame`, `:::callout`,
`:::figure`, `:::columns`…) open and close as today. A bare `:::` can never close the page.
- `:::page{split}` (three colons) stays the comics page with its raw body, byte for byte as today.
A three-colon `:::page` carrying `template=` gets the warning `pageFenceLength` ("a composed page
needs four colons") and is parsed as before (comics), so nothing silently changes meaning.
- Attributes: `template` (required), `spread` (flag; must match the template's `spread`), `id`
(for `:ref`), and any other attribute, readable as `{attr.x}` by the template's decoration and
header/footer (a page background chosen per page: `resourceId: "{attr.fondo}"`).
- A composed page always starts a new page (or spread). Content after `::::` resumes the normal
flow on a new page.
### 3.2 Slots
```md
:::frame{slot="dinero" label="El dinero"}
Es el instrumento utilizado como medio de pago…
:::
```
- `:::frame{slot}` is a container holding full Markdown: paragraphs, lists, headings, chips,
`::resource`, `:ref`, footnotes, `:::columns`, nested callouts.
- `slot="…"` on any single block (`## Heading {slot="title"}`, `::resource{id="x" slot="foto"}`,
a fence's attributes) sends that block alone.
- Several pieces with the same slot concatenate in Markdown order.
- Content with no slot goes to the flow frame; a heading of level N with no slot goes to the frame
that `takes` it, if any, before falling to the flow frame.
- A slot that the template does not have: `slotUnknown`, and the content goes to the flow frame
(or, with no flow frame, is laid after the page in the normal flow so it is never lost).
- A frame of the template that receives nothing: `slotEmpty` (info level; a frame may be optional:
`optional: true` on the frame silences it).
### 3.3 Figures with labels
```md
:::figure{resource="mapa-europa" slot="mapa"}
::label[OCÉANO ATLÁNTICO]{at="atlantico" style="mar"}
::label[Madrid]{at="madrid"}
::label[Mar Negro]{at="62% 48%"}
:::
```
- `:::figure` places one resource (its caption and numbering as `::resource` today) and sets each
`::label` at its anchor; `at="x% y%"` is the fallback when no anchor exists.
- `style` overrides the anchor's label style. Text is ordinary inline Markdown (bold, chips).
- Outside composed pages `:::figure` works in the flow too, as a resource with labels.
- Warnings: `labelAnchorUnknown` (label names an anchor the resource lacks), `anchorUnlabelled`
(info: an anchor with a style but no label in this locale).
### 3.4 Example: textbook p. 8
```md
::::page{template="tema-cajas" fondo="banda-retos"}
# La actividad económica y el dinero
## La actividad económica
Las personas utilizan :chip[recursos]{style="clave"} que se obtienen de la naturaleza…
- Las :chip[empresas.]{style="clave"} Ofrecen bienes y servicios a cambio de un beneficio.
- Las :chip[personas y familias.]{style="clave"} Adquieren lo que ofrecen las empresas…
:::frame{slot="dinero" label="El dinero"}
Es el instrumento utilizado como medio de pago, ya que su valor es conocido…
Hasta hace pocos años, lo más normal, era pagar con dinero en **efectivo**…
:::
::resource{id="dinero-pago" slot="dinero-fotos"}
:::frame{slot="gasto" label="El gasto"}
El dinero que utilizan las personas para satisfacer sus necesidades…
- **Fijo:** es el que se produce todos los meses…
- **Variable:** puede cambiar según las necesidades…
- **Inesperado:** se producen por situaciones imprevistas…
:::
::resource{id="gasto-compras" slot="gasto-fotos"}
:::frame{slot="ahorro" label="Préstamos y ahorro"}
Si necesitas o quieres comprar algo muy caro…
:::
::resource{id="ahorro-balanza" slot="ahorro-fotos"}
::::
```
Template (config, no text):
```jsonc
{ "id": "tema-cajas", "area": "content",
"decoration": { "elements": [ { "kind": "image", "id": "fondo", "resourceId": "{attr.fondo}",
"decorative": true, "placement": { "anchor": { "to": "bleed", "edge": "top-left" },
"size": { "width": "fill", "height": "fill" } } } ] },
"frames": [
{ "name": "titulo", "takes": "h1", "style": "tab-titulo", "height": "auto" },
{ "name": "texto", "flow": true },
{ "name": "dinero", "style": "caja-azul", "padding": { "right": { "value": 62, "unit": "mm" } } },
{ "name": "dinero-fotos", "layer": 2, "width": { "value": 60, "unit": "mm" },
"placement": { "anchor": { "to": "#dinero", "edge": "top-right" },
"offset": { "y": { "value": -4, "unit": "mm" } } } },
{ "name": "gasto", "style": "caja-verde", "padding": { "left": { "value": 80, "unit": "mm" } } },
{ "name": "gasto-fotos", "layer": 2, "width": { "value": 76, "unit": "mm" },
"placement": { "anchor": { "to": "#gasto", "edge": "top-left" } } },
{ "name": "ahorro", "style": "caja-amarilla", "padding": { "right": { "value": 48, "unit": "mm" } } },
{ "name": "ahorro-fotos", "layer": 2, "width": { "value": 46, "unit": "mm" },
"placement": { "anchor": { "to": "#ahorro", "edge": "right" } } } ],
"groups": [ { "name": "pagina", "kind": "stack", "gap": { "value": 6, "unit": "mm" },
"frames": ["titulo", "texto", "dinero", "gasto", "ahorro"],
"align": "start" } ] }
```
When the Catalan text of `dinero` is two lines longer, `dinero` grows, `gasto` and `ahorro` move
down, their photos follow their anchors, and the reflow log says so. If the stack no longer fits
the page, the ladder of §5.4 runs.
### 3.5 What P6 built (#860)
- **Layout** (`composed/labels.ts`): `layOutFigureLabels(labels, parsed, rb, ctx)` runs where the
figure's block is measured (`runMeasurement`, and `buildFloatBlock` for a floated figure), on
bitmaps and SVGs. Each label gets `VDTFigureLabel.layout`: its point `(x, y)` in px from the body's
top-left (the anchor's fractions of the whole picture through `bodySource`, `pictureToBody`), its
`rotate`, its lines set as a caption is (`layoutLabelLines` in `resourceLayout.ts`: chips, marks,
refs, maths; no hyphenation; `\\` breaks) in the label's own frame, its box, fonts, colour, halo,
plate and fit scale. Kept relative to the body, so placement, frames, spreads and the memo move it
for free; painters apply `figureLabelMatrix(layout, bodyX, bodyY)`.
- **Look** (`resolveLabelLook`): body text ← borrowed paragraph style ← label style; the label's
`style` beats the anchor's. `fontStyle`/`textTransform` resolve to defaults in P2, so a borrowed
paragraph style's italics and capitals hold unless the label style turns them on (it cannot turn
them off). Line pitch 1.15 em, or the paragraph style's.
- **Fit**: wrap at `anchor.width × picture width`; the widest word sets a first scale, then 0.97
steps while a word is still divided (`hyphenated && !hardHyphen`), down to `minScale`; `overflow`
when it still does not fit. Without `width`, one line.
- **Alignment**: `start`/`end` in the label's direction (first strong letter); default centre /
middle. Anchors shared by LTR and RTL editions read best centred above or below the point.
- **After the pages**: `stampFigureLabelRects(doc)` (after `orientLinesToFrames`) fills `rect` in
the block's frame (quarter-turned blocks included); `orientResourceBlock` mirrors label layouts on
RTL pages (`mirrorLabelLayout`); `labelGlyphAt` maps a point to the glyph for `sourceAt`;
`figureLabelWarnings` gives `labelAnchorUnknown` / `anchorUnlabelled` with page and locale.
- **Outputs**: canvas and PDF paint labels in `renderResourceBlock` after the picture (halo: the
same line painter through a stroking context on canvas, `ctx.textOutline` hollow strokes in PDF);
HTML writes `<div class="pt-figure-label">` with a CSS matrix and `-webkit-text-stroke`. The
`alt`/`Alt` keeps only undrawn labels (`undrawnLabels`); tagged PDF reads a `Div` with one `P`
per drawn label after the `Figure` (`StructureFlow.figureLabelsElem`), halo and plate artifacts.
Reflowable EPUB unchanged (hidden list). Folio uses the canvas.
## 4. Document nodes
The parser emits (as built in P3, #854):
- The page fence stays in the block stream as a `containerStart` / `containerEnd` pair of
`containerName: 'composedPage'` (not in `KNOWN_CONTAINERS`, so `:::page` stays a comic page); the
start marker carries `composedPage: ComposedPageFence { template, spread, attrs, attrSources,
fenceIndex, colons }`, where `fenceIndex` is the page fence's ordinal in its chapter (stable across
locales).
- Frame containers become `containerStart/End` of name `frame`. Every block that names a slot carries
`ContentBlock.slot: { name, sourceStart, sourceEnd }` (a heading's `{slot}`, a `::resource{id slot}`,
a code fence's or any `:::` container's, a `:::frame`'s).
- `composedPages(blocks)` (`composed/document.ts`, pure) gathers one `ParsedComposedPage` per fence —
the `ComposedPageNode` of `types.ts` (`slots: Record<slot, blockIndex[]>` in Markdown order,
`unassigned`) plus `slotSources`, `firstHeadings` (for `takes`), `fence` and `unclosed`. A frame's own
markers are left out of its slot; a nested container goes whole (markers included) to the slot it
names, else to `unassigned`. It reads any block array, so P4 calls it on its transformed
`contentBlocks`.
- `:::figure` is not a container in the stream: it is read whole into one `resourceBlock`
(`resourceId` = `resource`) with `figure: FigureInfo { attrs, attrSources, labels }`, so the flow
sets it as `::resource` until P6. Each `FigureLabel` extends `FigureLabelNode` with `spans`,
`sourceMap`, `textStart/End` and `attrSources`; `at` is `{ anchor }` or `{ x, y }` in fractions of
the picture (from `at="62% 48%"`).
- Recovery: a page left open closes at the end of the text; a `::::page` met while one is open closes
it there; the closing fence closes any container still open inside the page; each is reported as
`unclosedContainer`. A closing fence of another colon count stays text. A `:::figure` whose `:::` is
missing ends at its first line that is neither a label nor blank (`unclosedContainer`, name
`figure`).
- Since P4 (#855) the layout sets a composed page on a page of its own from its template (§5.8). A
page whose template is unknown is still set in the flow, in Markdown order, on pages of its own.
The layout emits (VDT):
- `VDTPage.composed: VDTComposedPage { template, variant, spread?, frames: VDTFrame[], decoration:
VDTDesignSlot, log: ReflowEvent[], fenceIndex, sourceStart, sourceEnd }`.
- `VDTFrame { id, name, rect, rotate, layer, styleId, scale, overflowPx, continuedFrom?,
continuesTo?, blockIds, configPath, sourceStart?, sourceEnd?, push? }`. The frame's box and its
children are ordinary callout blocks in `page.floats` and `doc.blocks` (their `containerId` is the
story's synthetic container, shared by the pieces of a chain; `blockIds` ties them to the frame), so canvas, PDF
(tagging), HTML, EPUB and Sandbox caret mapping handle them without new painters; `VDTFrame`
adds what only templates know (name, config path, scale, log).
- `VDTLabel` items on the figure block (text blocks with source maps, positioned on the page).
## 5. Layout
### 5.1 Where it runs
A new branch in the main block loop, next to the comic page branch: close the flow segment, take a
fresh page (a verso pair for a spread), set `page.role = 'composed'`, keep a single empty column so
column-wide machinery sees an empty page, lay the template, push frames and children to
`page.floats` / `doc.blocks`, then force a page break. Frames are **not** page columns: banding,
balancing, footnote areas and trailing caps keep assuming a page's columns are one chain.
The callout layouter (`makeCalloutLayouter`, `splitCalloutFragment`, `CalloutCut`) is the frame
typesetter: each frame's slot content is laid as a callout of the frame's style (or a frameless
synthetic style, as `:::columns` does) at the frame's inner width and cut at its height. P4 factors
these closures out of `placeDocumentPass` into `pipeline/frameLayout.ts` with an explicit pass
context, so composed pages do not grow `build.ts` further.
### 5.2 Order of work per page
1. Resolve the template (`extends`, variant for this parity, spread sheet).
2. Gather slot content; report `slotUnknown`, `slotEmpty`.
3. Resolve fixed geometry: grid cells, fixed placements, pinned frames, group boxes.
4. Measure each text frame's natural height at its width (`auto`), in **template order**.
5. Solve groups (§5.3), then frames anchored to other frames, in dependency order (anchors form a
DAG; cycles were rejected by validation).
6. Run the reflow ladder for any frame whose content still does not fit (§5.4).
7. Typeset every frame for real (the measure of step 4 is reused when nothing changed), commit
blocks, lay decoration with the final frame boxes, lay labels.
8. Record the reflow log and warnings.
### 5.3 Groups
Stack: members get their natural heights clamped to min/max, laid top to bottom with `gap`. If the
sum exceeds the group's height, frames with `shrink > 0` give back space in ascending `priority`
(ties: later frame first) down to their minimum — a frame given less than its content needs then
enters the ladder. Spare space goes to frames with `grow > 0` in proportion, else follows `align`.
Row: members get their declared widths. A member that overflows its height takes width from
`shrink > 0` siblings (ascending `priority`) whose own content still fits at the reduced width,
found by bisection on whole millimetres; `grow` distributes spare width. Row heights are the row
box's height (`auto`: the tallest member).
`pin: true` frames never move or resize; they act as obstacles the group flows around (a stack
skips the pinned frame's band).
### 5.4 The reflow ladder
For a frame whose content does not fit, stop at the first step that makes it fit:
1. **Free space** in the frame and its group (grow to `maxHeight`, take the group's spare space).
2. **Push siblings**: in a stack, frames below move down; `shrink` frames yield.
3. **Scale flexible images** in the frame and its group between 100 % and `fit.imageMinScale`
(default 0.8), re-placing floats inside the frame.
4. **Redistribute within min/max**: lower other frames of the group towards their minimum; in a
row, take width (§5.3).
5. **Shrink the frame's text** uniformly in steps of 2.5 % down to `fit.minScale` (default 0.9;
`fit.shrinkText: false` disables). Font size, leading and spacing scale together; the frame
records `scale`.
6. **Continue** (§5.6, §5.12) when `overflow.continueIn` sends content to another page.
7. **Warn** `frameOverflow`. The content that does not fit is clipped at the frame's edge on paged
output (it is never dropped from reflowable output, the source map, or the warning).
A frame whose `overflow.continueIn` sends content to another page (`'next-page'`,
`'template:<id>'`, `'page:<id>'`) takes step 6 right after step 4, before 3 and 5 (#920,
`fit.preferContinue`, default true): free space, push, redistribute, continue, and only when it
cannot go on (the last page continuation may add, a receiver that takes none of it) smaller
pictures, then smaller text. A story set across pages keeps one size; a magazine's last page of an
article is not set smaller than the pages before it. `fit.preferContinue: false` keeps the order
above (shrink a few lines' worth, go on only when that is not enough).
The ladder is deterministic: same input, same output; steps are tried in this order with fixed
step sizes; ties break by template order. A change is confined to its page (or spread), except for
continuation, which also touches the pages it adds.
### 5.5 Reflow log
Each composed page records `ReflowEvent { frame, kind: 'grew' | 'moved' | 'resized' |
'imageScaled' | 'textScaled' | 'continued' | 'overflow', fromMm, toMm, scale?, cause?: frame }`,
exposed on `VDTComposedPage.log`, in `postext check --json`, and rendered by the Sandbox reflow
panel (U8): «dinero grew 8 mm; gasto moved down 8 mm; the photo is at 92 %».
### 5.6 Continuation
`overflow.continueIn`: `'#frame'` (another frame of the same page; equivalent to `next`),
`'next-page'` (the same frame on a following page built from `overflowTemplate`, or from the same
template with only that frame filled), `'template:<id>'`, or `'page:<id>'` (a jump, #917: a frame
of the later page fence `::::page{id="<id>"}`, past the pages between; `'page:<id>#<frame>'` names
the frame, and `<id>` may be `{attr.name}`, read from the sending fence, so one article template
serves every article: `continueIn: 'page:{attr.sigue}'` with `::::page{template="articulo"
sigue="p47"}`). The remaining content resumes from
the `CalloutCut` cursor, re-broken at the new width. A frame is cut as the running text is (#913,
`frameCutRules`, for `next` chains and continuation alike, and since #942 for a
`keepTogether: false` box of the running text too): a heading never ends a piece
(`headings.keepWithNext`), a cut between the items of a list leaves two items on each side
(`bodyText.avoidOrphansInLists` / `avoidWidowsInLists`; one when off), and a list of up to
`bodyText.shortListMaxItems` items under `shortListSplit: 'avoid'` is not cut between items. When
no cut allows it the piece ends before the heading or the list, or inside one of its items. A frame
set in columns (and any `:::columns` group) offers no column cut right after a heading either, nor
inside one: the heading opens the next column with its text (#1019). Marks: `overflow.mark` text (string, or the
localized default from the engine's continuation words, as tables' `continuesLines` uses: es
«sigue», en «continued», …; `false` for none), set at the end of the cut piece. Added pages keep
page numbering, running heads and parity rules (they are ordinary pages with `role:
'composed'`). Reflowable HTML/EPUB keep the content in one piece.
### 5.7 Warnings
<table>
<tr><th>Kind</th><th>Data</th><th>Fix proposals</th></tr>
<tr><td><code>frameOverflow</code></td><td>page, template, frame, locale, overflow mm, steps tried, final scale</td><td>allow more shrink (<code>fit.minScale</code>), raise <code>maxHeight</code>, lower a sibling's <code>minHeight</code>, continue on the next page, use another template, shorten the text</td></tr>
<tr><td><code>slotUnknown</code></td><td>page, template, slot, source range</td><td>rename the slot, add a frame</td></tr>
<tr><td><code>slotEmpty</code></td><td>page, template, frame</td><td>write content, mark the frame <code>optional</code></td></tr>
<tr><td><code>templateUnknown</code></td><td>page, template id, source range</td><td>pick an existing template</td></tr>
<tr><td><code>pageFenceLength</code></td><td>source range</td><td>use four colons</td></tr>
<tr><td><code>labelAnchorUnknown</code> / <code>anchorUnlabelled</code></td><td>resource, anchor, page, locale</td><td>add the anchor / the label</td></tr>
<tr><td><code>contentInConfig</code></td><td>owner path, element paths, chars, sample</td><td>move the text to the Markdown</td></tr>
<tr><td><code>tocGroupSplit</code></td><td>group head, page</td><td>shorten the group, change levels</td></tr>
</table>
Each proposal is data (`{ kind, path, value }`) so the Sandbox can offer it as a one-click,
confirmed config change (U8). What P5 built is in §5.11.
### 5.8 What P4 built (#855)
Recorded where the implementation settles a point the sections above leave open, or departs from them.
- **Stories.** Before layout, `composed/wrap.ts` regroups a page's content into one frameless box
(`:::callout` of the synthetic style `__postext-frame`) per *story*: the content of a frame and of
the frames its chain continues into (`next`, or `overflow.continueIn: '#frame'`, read the same),
in chain order, each frame's pieces in Markdown order. The box typesetter (`pipeline/frameLayout.ts`,
factored out of `placeDocumentPass` with no change for callouts) sets a story at each frame's width
and cuts it at its height (`CalloutCut`); a later frame of the chain re-breaks the cut paragraph at
its own width. The regrouping reads the template without a side's variant (takes, flow frame,
chains, `columns`); a frame only a variant has keeps a story of its own, and on a side whose
variant lacks it that story is set in the flow after the page.
- **Reading order.** Stories follow each other in the order of their first block, which is the
`contentIndex` order: a frame is read whole, in the order its content first appears in the
Markdown (flow text written after a box is read with the flow frame, before the box). Tagged PDF
keeps this order whatever the layers paint (`readFloat` takes a read index).
- **Geometry** (`composed/geometry.ts`). A frame with no grid cell and no placement starts at the
area's top-left, as wide as the area; its height follows its rule (`'auto'`, the default: the
content's height; `'fill'`: the area's). An `'auto'` height never runs past the edge of the area
(or trim, bleed) the frame grows towards; what does not fit there overflows the frame. A nine-point
edge on another frame (`top-right` of `#dinero`) reads that point of its box. `'auto'` width reads
as `'fill'` (the picture-width rule is left to P14). A group with no box of its own is the union of
its frames' boxes; its frames keep their own placement until P14 negotiates them.
- **Order of work.** Frames are placed and filled in dependency order (anchors, then the frame a
chain comes from), frames that push text as early as their anchors allow. When any frame pushes,
the page is composed twice: the second run starts with every pushing frame's box from the first,
so a box runs round a photo anchored to it.
- **Content no frame takes** (no flow frame) and stories whose frame is missing are set in the flow
on the page after the composed page, never dropped. A `::resource` a story holds is set in its
frame and does not float; a `:ref` in a frame to a picture set elsewhere floats from the page's end.
- **Decoration and running heads** are laid in `buildHeadersAndFooters`, on the sheet, after the
frames: decoration elements may anchor to `'#frame'` (`DesignFrames.named`), text placeholders use
the `'composed'` context kind, and `{attr.x}` reads the page fence's attributes before the chapter
heading's (also in the running heads of that page). The decoration is `VDTComposedPage.decoration`,
painted at layer 0.
- **VDT.** `VDTComposedPage` also carries `role`, `attrs`, `attrSources` (absolute) and `configPath`.
A chained frame's box is the head's id plus `-cont-<n>` so tagged PDF and reflowable EPUB join the
pieces; `VDTFrame.id` stays `cp<fence>:<name>`.
- **Overflow.** A frame that cannot hold its content and has no next frame lays it whole, draws its
box at the frame's height, and records `overflowPx`; backends clip it to the frame. `frameOverflow`
carries template, frame, fence index, overflow mm and the frame's config path (P5 adds the steps
tried and the proposals); the page log gets an `overflow` event.
- **Not applied yet:** the template's `bodyStyle` and `palette`, a frame's `writingMode` and
`direction`, `fit`, group negotiation with `grow`/`shrink`/`priority`/`pin` (P14, §5.9), `spread` (P7, §5.10),
continuation to other pages (P15), labels (P6). A `exclude: 'push'` frame turned by `rotate` pushes
with its turned box's bounds.
### 5.9 What P14 built (#856)
- **Declared and final boxes.** Each frame is placed twice in the same pass: as the template
declares it (fixed sizes; an `'auto'` height at its `minHeight` when it has one, else at its
content's height; anchors read declared boxes) and as its content needs. `VDTFrame.declaredRect`
keeps the first when it differs. The reflow log is the difference between the two, so a page whose
text fits the template's drawing logs nothing, and the same text gives the same log in any book.
A frame with an `'auto'` height and no `minHeight` declares no height of its own: it never "grows",
and the frames that follow it do not "move", whatever it holds. Templates drawn from a page give
their boxes the drawn height as `minHeight`.
- **Order of work** (`composed/groups.ts`, `composedOrder`). A group is one step: after everything
its frames and its own box anchor to, before anything anchored to the group or to one of its
frames; its frames are filled in group order (a chain inside a group is measured with what its
frame holds when the group is solved, so a later link of the chain measures empty).
- **A group with no box of its own** is elastic: a stack starts where its first frame's own
placement puts it (members' own `y` is ignored, their `x` and width kept) and runs down as far as
its frames need, to the foot of the template's area; only there do `shrink` frames yield. A row
starts at its first frame, keeps its frames' widths and grows as tall as the tallest needs.
Spare space, `grow` and `align` apply only to a group with its own box (`placement` or `grid`);
a frame of such a group with no placement of its own takes the group's box as its area.
- **Stack.** Heights: fixed, or the content's (`'auto'`), clamped to min/max; `'fill'` asks for its
`minHeight` and grows (weight `max(grow, 1)`). Too tall: frames with `shrink > 0` give space back,
lowest `priority` first; frames of one priority yield **in proportion to `shrink`** (the number
is a weight, not only a flag; §5.3's "later frame first" is not used), down to `minHeight`. When
nothing more yields, no frame runs past the group's foot: its content overflows (`frameOverflow`,
the ladder of P5). Spare space goes to `grow` frames in proportion, up to `maxHeight`, else to
`align`.
- **Row.** Frames take their declared widths and the row's height (a fixed height keeps its own).
A frame whose content runs past the row's height takes width from the spare space first, then
from `shrink` siblings, lowest priority first, each down to the narrowest whole-millimetre width
at which its own content still fits; the width a frame takes is the fewest whole millimetres that
make it fit, found by bisection; when even all the room would not make it fit it takes none. Rows
run left to right in every writing direction (right-to-left rows: later).
- **Pins.** A pinned frame takes its declared box (anchored to declared boxes) whatever its anchor
or its group does. In a group, pinned frames and pinned frames outside the group whose box meets
a frame across are obstacles: a stack sets the next frame past the pinned frame's band, a row past
its column, `gap` kept on both sides; spare space then stays at the foot (no `align`).
- **Decoration.** Elements anchored to `'#frame'` move with the frame's final box (P4 already laid
decoration after the frames); `pin: true` on a decoration element (new on design elements, read
only by composed pages) anchors it to the frame's declared box (`DesignFrames.declared`).
- **Log.** `ReflowEvent` gains `axis: 'x'` for widths and moves across; `grew` / `resized` carry
heights (or widths) in mm, `moved` the top (or left) edge in mm from the sheet's edge; the change
is rounded once to 0.1 mm and events under 0.5 mm are not logged. `cause` is the nearest frame
whose size changed among what the frame anchors to and the frames before it in its group, through
frames that only moved. Events follow template order, each frame's size before its moves, its
`overflow` last. Decoration moves are not logged (they follow their frame).
- **Surfaces.** `composedReflowLog(doc)` (per composed page: page index and label, template, fence,
events, and `lines` in English from `describeReflowEvent`) is the data API for the Sandbox reflow
panel (U8, #868); `postext check` prints one `reflow` line per page that has events and writes
the entries under `reflow` in `--json`.
- **Confinement and determinism.** Nothing of a composed page's negotiation reads another page:
boxes, logs and pages after it are unchanged by an edit on it as long as its content stays on it.
Fixed iteration order, whole-millimetre widths and memoised measures give the same VDT for the same
input.
### 5.10 Spreads (#861)
- **Where.** `placeComposedPage` (build.ts) resolves the template once as a sheet
(`resolvePageTemplate(…, { spread: true })`); a `spread` template takes `breakToEvenPage`, the
verso and the recto after it (one empty column each), as `placeComicSpread` does, and
`layoutComposedSpread` (`composed/spread.ts`) lays it. The template decides; the fence's flag
only feeds `spreadMismatch` (source check in `slotChecks.ts`, `spread` read through `extends`).
- **Sheet.** One `layoutComposedPage` run on spread coordinates: the first page's frame, the
second's set `shift` (= the trim's width) along so the trims meet at the spine. The first page is
the flow's left: the left page, or the right one on a mirrored (right-to-left) flow, whose frames
are in flow coordinates; a right-bound left-to-right book starts the sheet on its left page, the
recto. `content` is the union of both content areas (inner margins inside); `trim`/`bleed` run
outer edge to outer edge, no bleed at the spine.
- **Gutter.** `ComposedPageInput.fitBox` (one hook in composedLayout.ts, `placeFitted`) gives
`keepOffGutter`. It fits a frame's declared box and its final box alike (the band is the
template's, not a reflow, so P14's log does not report it as one), and a group member's own box
before the group negotiates (§5.9), so a stack on a spread lays its frames off the band;
a frame without `crossGutter` that meets the band is cut at the band's edge (it reached across) or
moved out whole (its inner edge was inside), on its centre's side (ties: the first page); an
`'auto'` height is re-measured at the new width; the log gets `resized`/`moved` with cause
`'gutter'`. A 0 gutter still keeps frames off the spine line.
- **Cut.** A frame goes to the page holding its bounds; a crossing frame is read on the page of its
centre (ties: first page). The facing page gets a frame record with the same id, empty
`blockIds` and `facingBlocks`: deep copies moved by `-shift`/`0`, ids `<id>-facing`, in no
`page.floats` and no `doc.blocks`, so reflowable output, search, selection and source maps meet
the text once. Blocks of frames on the second page are cloned before they are moved.
- **Paint.** `VDTComposedPage.sheetClip` (the page's bleed box) clips all of a spread page's frames
and decoration (canvas, HTML `pt-sheet-clip`, PDF `re W n`); `composedPaintOrder` yields
`facing: true` items, which the PDF tags as Layout artifacts and the HTML (written in reading order,
§8) marks `aria-hidden`/`data-facing` and leaves out of the patchable blocks. Fixed EPUB adds `rendition:spread-both` to both pages; the
page-spread side follows parity as before.
- **Decoration** is laid once (headerFooter.ts, on the first page of the pair met) over the
physical sheet (left page at 0), `#frame` anchors read the frames' physical boxes
(`flowRectToPage`, which also fixes `#frame` decoration on single right-to-left pages), then
`splitSpreadDecoration` keeps the blocks that reach each page's bleed box, moved onto its sheet.
- **Vertical pages (#926):** the sheet is laid in the pages' flow frame, as single vertical
composed pages are; the second page follows the first along the flow's y (`SpreadAxis` `'y'`:
`spreadSheet`, `keepOffGutter` and `layoutComposedSpread` transpose), the physically right page
first; trim and bleed are the flow frame's (`designFramesOn`). The decoration stays physical; its
clip is the page's `sheetClip` mapped to the sheet (`composedSheetClipOnSheet`).
- **Not done:** frames with their own `writingMode` (a horizontal card on a vertical spread);
per-side `mirrored` for frames' outer-corner icons (the first page's is used); running-head
placeholders in a spread's decoration read the first page met.
### 5.11 What P5 built (#858)
- **Where the ladder runs.** In `layoutComposedPage` (`pipeline/composedLayout.ts`), around one
laying out of the whole page (`composePage`, P4's and P14's work unchanged): a step is a *fit
state* (frame patches, text scale per story, picture scale per story, groups that redistribute)
and the page is composed again with it; compositions are memoised by state. Frames that overflow
climb the ladder one by one in template order, each step adopted on top of the ones before it
(a step that does not make the frame fit stays at its limit: less is cut), stopping at the first
that makes it fit. A budget of 96 compositions per page bounds the work. Same input, same VDT;
nothing reads another page.
- **Steps as built.** `free`: P14 already lets an `'auto'` height take the room its area and group
leave, so the step adds only that a frame with a fixed `height` and a higher `maxHeight` grows up
to it (patched to `'auto'` with its height as `minHeight`). `push`: part of every composition
(groups move and yield, anchored frames follow); reported when the frame is in a group with
others or something anchors to it. `scaleImages`: bitmaps, SVGs and video posters of the stories
of the frame and of its group's frames are set at a share of their width
(`CalloutLayoutInput.pictureScale` → `figureMaxBodyWidth`), largest 2.5 % step that fits down to
`fit.imageMinScale`, found by checking the minimum and then bisecting; floats do not exist
inside frames (P4 sets a frame's pictures inline), so "re-place floats" is the text after a
picture moving up. `redistribute`: in the frame's group every unpinned frame yields (`shrink`
read as 1 when 0) down to `max(minHeight, its content's need)`, never above its height; in a
row they give width as in P14 (each down to the narrowest width its own text fits). `shrinkText`:
only for a frame whose story sets text; the whole story (all frames of a chain) at one scale.
`continue`: reported for `overflow.continueIn: 'next-page' | 'template:…'`; #859 lays it out.
- **Text scale is geometric.** A scaled frame's story is laid out at `width / scale` and
`height / scale` and painted scaled by `scale` about the top-left of `rect`: `frameMatrix` now
composes the scale before the turn, `frameContentRect` is the box in the blocks' own coordinates
(backends clip to it). Font size, leading and every spacing shrink by exactly the same factor,
whatever the content (tables, formulas, nested boxes). The frame's chrome keeps its drawn size
(`keepChromeAt`: padding, border width, radius, stripe and tab dimensions laid out at
`1 / scale`), so padding that keeps room for photos set over a box stays that room. Blocks of a
scaled frame are in its laid-out coordinates: anything that reads block boxes directly instead of
painting through `frameMatrix` (the Sandbox's caret and selection overlay, tagged PDF bounding
boxes) sees them unscaled, as it sees a rotated frame's upright (follow-up). The source map
(`sourceAt`, `elementsForSource`, `findElement`) reads them where they are painted: a point goes
through the inverse of `frameMatrix` and is tested against `frameContentRect`, and the boxes it
returns inside the frame are scaled back upright (the turn stays in `rotate`).
- **Spreads and the memo.** On a spread the ladder runs on the sheet: the gutter cut (`fitBox`)
narrows a frame first and the ladder works from that box; a frame of the second page keeps its
`scale` when it is moved onto its sheet. The gutter events come from the composition the ladder
settles on (`ComposedPageLayout.fitted`), not from the ones it tried. The page memo keys the
inputs the ladder reads (the resolved configuration holds the other templates and the locale), so
a reused page equals a fresh one, scales and proposals' source ranges included.
- **Warning.** `frameOverflow` gains `locale`, `stepsTried`, `scale`, `imageScale`, `proposals`.
Proposals are data with set-the-pointer semantics (`path` → `value`); a Markdown change carries a
source range instead (`useTemplate`: the fence's `template` value; `shortenText`: the frame's
content). Values are tried on the page where the page can tell: `allowShrink` (largest 2.5 % step
down to 50 %, `also` turns `shrinkText` on), `raiseMax` (height + shortfall, whole mm; offered only
when it fits), `lowerMin` (a group sibling at its minimum with less content, lowered by the
shortfall, not below its content; offered when it fits), `useTemplate` (first other template, in
`pageTemplates` order, with a frame of that name where the content fits as it is; the stories
keep the page's own regrouping). `continueNextPage` and `shortenText` (lines over × characters of
a full line) are not tried. `slotUnknown` names the flow frame that takes the content; slot
warnings already carried page and source range after layout.
- **Log.** `imageScaled` (with `cause` when another frame's ladder set it) and `textScaled`
events, per frame after its size and moves, before its `overflow`; words to the tenth of a
percent.
### 5.12 What P11 built (#883)
- **Config.** `ResourcePlacement.wrap: 'contour'` (the picture at the side its `align` names) and
`FrameConfig.wrap: 'box' | 'contour'` (with `exclude: 'push'`; `ResolvedFrame.wrap` is set only for
`'contour'`). The shape is `Resource.contour`: `points` (polygon in picture fractions, wins),
`alpha` (default on for bitmaps), `threshold` (0.5), `margin` (default: the wrap gap / push gap).
- **No pixels at layout time.** The layout knows pictures by size only, so the alpha channel comes
with the resource as `contour.traced` (`ContourTrace`: up to 256 rows, leftmost/rightmost opaque
edge per row, the file id and threshold it was traced at; a stale trace is ignored). Hosts trace:
`traceResourceContour` (synchronous PNG decoding on fflate in every host, the browser decoder for
other formats), `readBundle` (`traceBundleContours`, every bitmap when a frame wraps by contour),
the Sandbox (`useContourTraces`, from the blob store). Bundles never store the trace.
- **Engine.** `contour/index.ts`: rows from polygon or trace (`contourRowsOf`), mapped to page px
through `bodySource` (safe-area crop) and `bodyRect` (scale), turned on mirrored pages
(`contourOnPage` → `VDTExclusionContour {spans, margin, quantum?}`), and per-line insets
(`contourInsetSteps`: the extent of the spans within the margin of each line's band; a line clear
of the shape gets none; CJK grid rounds to whole characters). The flow keeps its rectangular
`VDTExclusion` (band, settling, epub) and adds `contour`; `wrapInsetSteps` reads it. Frames: the
pushing frame's shape is `composed/contour.ts` `frameContour` over its laid children (pictures by
shape, everything else by box); `exclusionsFor` passes it to `calloutLayout.exclusionInsets`,
which keeps its side choice (the wider side free) and sets each line by the shape. With the
reflow ladder (§5.11) the shape is the pictures as scaled (`fit.imageMinScale`), and a frame laid
out at a text scale maps its shape to the page by that scale (`scaleContourAbout`, as
`frameMatrix`), and the exclusions it receives back into its laid-out coordinates. The page memo
(§6, #857) keys the pictures' resources whole, so `contour` and its trace are in the key. Two shapes on
either side inset both sides; text on both sides of a shape in the middle of a measure (split
lines) is not done.
- **Fallback.** A contour wrap with no known shape runs round the box with a `textWrap` warning,
reason `noContour` (once per resource per pass). Without contours the VDT is unchanged (preset
parity harness, 583 chapters, 0 diffs).
- **Where.** The ladder's step 6 (`layoutComposedPage`): a frame whose `overflow.continueIn` is
`'next-page'` or `'template:<id>'` (`continuesOffPage`) and that still overflows after
`shrinkText` is composed again with the frame in `FitState.goOn`: it is cut at its foot with
`splitCalloutFragment` (as a `next` chain is), its story's text scale and the picture scale its
own ladder set are dropped (the rest goes on, so shrinking gains nothing), and the cut
(`CalloutCut`, the pieces laid and the head's block id) is handed back as
`ComposedPageLayout.continuations`. No `frameOverflow` for it. `build.ts` (`placeComposedPage`)
adds the pages: generation after generation, one page per target template (`continuationTarget`:
`template:<id>`, else the page's `overflowTemplate`, else the same template *collapsed*), each a
fresh page after the last one laid (a spread target opens on the next verso, `breakToEvenPage`),
laid with `layoutComposedPage` and `ComposedPageInput.continuing` (no memo).
- **Receivers** (`composed/continuation.ts`, `assignReceivers`): the frame of the same name, else
the flow frame, else the first frame free; a story threaded through a chain (the sending frame
has a frame before it, `ComposedContinuation.head`) goes on where its chain starts: the frame of
the chain head's name, else the flow frame, else the sending frame's name (#919), so a body
template of three threaded columns that goes on into itself fills all three on every added page; a continuation that finds none waits for another page
of the same target. A receiver starts its chain on the added page (`chainPrev` ignores links into
it) and its story starts at the cut. `continuationTemplate` patches the added page's template:
the last frame of each receiver's chain goes on with the rule that brought the content unless it
sends content off the page itself; with `collapse` (the template laid again), every other frame is
`'auto'` with no min/max and leaves its groups, so it takes no room; on the last generation
(`MAX_CONTINUATION_PAGES`, 16) nothing goes on and a remaining overflow is reported as
`frameContinuationLimit` instead of `frameOverflow`. A receiver that takes none of its content
(the cut does not move) does not go on: the step is reverted and `frameOverflow` lists `continue`.
- **What the pages keep (#912).** A frame cut for another page gives its group's room first
(it yields down to its minimum before any other frame), so the frames after it keep their size.
Empty frames right after it in its stack (no story, not pinned: an answer space under an
activity box, `trailingFrames`) go with the rest when it lands on the same template laid again:
on the sending page they take no room, and on the added page they keep their size after the
frame that takes the content (`ComposedContinuation.trailing`). A frame that takes no room is
`VDTFrame.collapsed`, and decoration anchored to it (directly or through other elements) is not
drawn. On a template laid again, a group whose first frame collapses starts its remaining frames
where that frame was placed (`collapseFrames`), so the rest of a box does not land on the
page's kicker. A frame none of whose content fits draws nothing and takes no room (it is laid with
no height): the whole box goes on (its tab with it), never a tab and a mark over an empty piece.
The empty frames go with the rest only when there is a rest: a frame that holds all its content
once they leave keeps them after it, unless they then cannot keep their size under it, in which
case the whole box goes on with them. A frame that can go on and squeezes the frames after it in
its stack below their declared size (the stack reached the foot and its last frames were cut) is
treated by the ladder as content that does not fit (`squeezes`).
- **Marks.** The frame cut for another page is laid in its style with `continuesMarkerEnabled` (the
callout layouter's marker under a fragment that goes on): `overflow.mark` string, `false` (none),
unset = the style's `continuesMarker`, resolved from `defaultTableContinuationStrings(locale)`
(10 site locales and more). Same-page `next` chains carry none. The start of a continuation has
no mark of its own: it is a `continuation` fragment (no tab, no title), and a style with
`repeatTitle` repeats its title with `continuedSuffix`, which is the "continued from" mark.
- **VDT and ids.** Added pages are ordinary pages (`role: 'composed'`, numbered, running heads and
decoration of their template and side, `{attr.x}` of the fence) with
`VDTComposedPage.continuationOf` = the fence's own page. Frames there are `cp<n>:<name>~<k>` (k =
generation); a receiver is named by the slot that first sent the content (`cp0:dinero~2`). Blocks
go on as `<headBlockId>-cont-<pieces>` with the story's container id. The sending frame gets
`continuesTo`, the receiver `continuedFrom`, and the sending page's log a `continued` event with
`toPage` and `toFrame` (`composedReflowLog` words it with the page's printed number).
- **Memo.** A page with continuations is never kept (`layoutComposedPageMemo`), so it and its
added pages are always laid together, fresh.
- **Reflowable / tagged.** The EPUB walk opens no section for a page with `continuationOf`: its
blocks go on in the open stories (by container id), so the box is one frame and a cut paragraph
one paragraph with the page break inside. Tagged PDF already joins by container id (one `Div`).
### 5.13 What the findings round E1 built (#912, #913, #917, #919, #920)
- **Ladder order (#920).** `fit.preferContinue` (default true): for a frame that can go on to
another page the ladder runs free, push, redistribute, continue, then scaleImages and shrinkText
(§5.4). The Sandbox template editor has the toggle.
- **Receivers (#919).** `ComposedContinuation.head`: a threaded story goes on where its chain
starts (§5.12).
- **Cuts (#913).** `frameCutRules` in `splitCalloutFragment` for frames (§5.6).
- **What the pages keep (#912).** Yield-first, `trailingFrames`, `collapseFrames`,
`VDTFrame.collapsed`, whole-box moves (§5.12).
- **Jumps (#917).** `continueInPage` / `resolveContinueIn` (`templates.ts`) read the target;
`continuesOffPage` counts it, so it is the ladder's continue step. `build.ts`
(`placeComposedPage`): `fenceById` maps the chapter's planned fences by their `id`; `route` keeps
a jumping continuation in `jumps` until that fence is placed, or, when no fence of that id comes
after the sending page, raises `continueTargetMissing` (WARNING_SINCE 1.37.0; `before` when the
fence is earlier) and sends it on as `'next-page'`. The receiving fence is laid with its own
stories plus the jumping ones (`assignJumpReceivers`: the frame `#<frame>` names, else the frame
of the sending chain head's name, else the first frame its own content leaves empty) and
`ComposedContinuationInput.jump`: its frames keep the fence's ids (`cp<n>:<name>`, no `~k`), the
receiving chain's end goes on as `'next-page'` (pages added after it, numbered from it), and a
story that finds no free frame goes on there too. Such a page is never memoised. The sending
frame gets `continuesTo`, its page's log a `continued` event with the jump page; the EPUB walk
and tagged PDF already join the pieces by container id.
- **Page numbers in marks (#917, #925, #915).** `overflow.mark` may hold `{page}` (the page the
rest goes on on), and `overflow.fromMark` (new; the receiving frame's, else the sending frame's)
sets words at the head of the first piece a frame lays of a story that came from another page,
`{page}` being the page it comes from (`CalloutLayoutInput.continuedMark`, laid as the
continuation marker is, pagination furniture). Both are laid out with a guessed number of the
right width and tagged `VDTDesignTextBlock.pageMark`; once the pages are numbered `fillPageMarks`
(`build.ts`) writes the printed label of the page `continuesTo` / `continuedFrom` points to and
measures the lines again, keeping their alignment.
### 5.14 Findings round E2 (#882)
- **Empty frames in stacks (#911).** When the tiers have nothing more to give, a stack cuts its
frames from the foot up, each down to `GroupItem.floor` (its `minHeight`; a fixed height without
one keeps it), before any frame is clamped at the foot. A frame left below that least height
overflows by the cut (`boxShort` → `VDTFrame.overflowPx`), content or none, and climbs the ladder
and warns. Proposals skip empty frames for `lowerMin`, and give an empty frame its own `lowerMin`
instead of `raiseMax`/`continueNextPage`.
- **Elastic rows (#927).** `rowH` is the tallest height the cards write (fixed, else `minHeight`);
the content's only when none writes one. Widths are negotiated there, then the row grows.
- **Ruled frames (#914).** `composed/rules.ts`: rules on the box's `designOverlay` (laid-out
coordinates, lengths over the text scale); an empty frame keeps them on `VDTFrame.rules` and the
decoration step adds them (mapped through the flow, turned on vertical pages).
- **Drop caps in frames (#922).** A story box (`FRAME_ATTR`) is not a box for `placesOf`; the box
typesetter stamps `VDTBlock.dropCap` and `offsetBlock` moves it.
### 5.15 Findings round G (#882)
- **Drop caps in frame chains (#947).** `splitCalloutFragment` ranks a cut before a drop cap's
sink line (`VDTBlock.dropCap.sink` on the piece that holds the initial) last, after the soft cuts
of wrapped code: the paragraph moves on whole as in the flow, and is cut there only when no other
cut fits. `dropCapWarnings` reports such a piece (`split`) from the laid-out document, since a
box's trial cuts leave no notes. `wrapComposedPages` stamps the flow story's box with
`__capHeading` (the heading's `sourceStart`) when its first block is a paragraph that followed,
in the Markdown, a heading a `takes` frame took; `headingBefore` (`dropCap.ts`) reads it, so that
paragraph takes the level's drop cap.
- **`useTemplate` regroups (#937).** `fitsWithTemplate` lays the page with the other template as
the build would: `regroupStories` keeps a story whose head names a frame of it, sends one a
`takes` frame took to its frame that takes the same level, and the rest to its flow frame after
its own content (merged boxes appended past the pass's content blocks, a derived
`FrameLayoutPass` whose `measureCtxFor` reads the first story's index), then runs
`layoutComposedPage` with its ladder. Proposed only when no frame overflows, every story is laid
and nothing continues; never on a page continuation added.
- **Trial passes and the measurement memo (#953).** `trialPass` (`composedLayout.ts`) builds the
pass a trial lays: appended story blocks copy the page's (`originOf`), and everything keyed by
content index follows the copy: `paragraphContainers.byBlock` (`:::paragraphs` groups), heading
prefixes and numbers, list metrics, the `contentBlocks` the drop cap and indent rules read, comic
strips and heading designs; each story's box takes the measure context of the story it replaces
and the trial keeps its own `dropCapNotes` / `joiningLetterSpacing`. `layoutComposedPage` gives
every run a `MeasureMemo` (`pipeline/measureMemo.ts`) on its pass: `layoutCallout` asks it before
`measureContentBlock` for a child at a width (key: context, style `body`/`lists`, block, width,
paragraph-group style, writing mode and language state), replays the drop cap note and the
joining-script mark of the first measure, and never keeps a figure or table. Dropped with the
run, since `leftFlow` and the numbers are fixed only for it.
- **Box styles based on another (#905).** `CalloutStyleConfig.extends` (`defaults/calloutStyles.ts`:
`inheritCalloutStyles` before resolving, objects merged key by key, colours and lengths whole,
`id`/`name` never; `calloutStyleChain`; warnings `calloutStyleExtendsUnknown` /
`calloutStyleExtendsCycle`, WARNING_SINCE 1.39.0). The frame-style library (§2.4) merging on the
look keeps a style whose `:::callout` boxes stand elsewhere as a thin style (`extends` + its
position fields, `placesToKeep`), so a book ends with one style per kind of box and no box moves.
- **Jump targets in the editor (#943).** The Sandbox template editor lists the book's page fences
with an `id` as `page:<id>` targets and takes a typed `page:` value. Jumps across chapter files
are not laid out (SP followup 943-rest: forward ids, carried story Markdown, chapter-qualified
source map, numbering, book-level marks).
- **Sheet points on flow pages (#929).** A composed page's frames are laid in the page's flow
frame (mirrored on a right-to-left page, turned on a vertical one); its decoration stands on the
sheet. `sourceAt` takes sheet points and maps them with `pageToFlow` for the frames (the spread
clip `sheetClip` read in the flow, the decoration's with `composedSheetClipOnSheet`). Every
element whose `rect` is in the flow frame carries `flow: true` (`sourceAt`, `elementsForSource`,
`findElement`; flow text of a vertical or right-to-left page too); `flowRectToPage` puts it on
the sheet. Sandbox: `frameHit.ts` `sheetToFlow` / `flowBoxOnSheet`; the frame tools, the Design
mode marks, the picked mark (flow matrix after the frame's turn), the selection's relocation
and the template editor (pointer read in the flow, marks drawn through the flow's matrix) use
them. The Cookbook's C40 probe clicks flagged boxes on the sheet.
- **Start and end edges (#936).** `AnchorEdge` gains `top-start`, `start`, `bottom-start`,
`top-end`, `end`, `bottom-end`, `align-start`, `align-end` (`design/logicalEdges.ts`:
`physicalEdge`, `physicalPlacement`). `LayoutContext.startRight` (set for slots laid on the
sheet of a right-to-left or vertical page: running heads and a template's decoration, through
`sheetExtras`) reads the start on the right and reverses the offset's `x`; frames (`anchorOn`),
fixed boxes (`anchorBox`) and heading designs are laid in the flow, where the start is the left.
So one decoration element follows the frames to the right of an Arabic page. Sandbox: the slot
editor's Alignment list has Start / End and its edge lists the start and end sides.
- **Cover in vertical flows (#946).** `layoutImageElement` crops an upright picture (`DesignFrames.upright`)
in its own upright frame (the box `boxH` × `boxW`) and maps the visible part back into the flow; the
primitive keeps `upright` and `crop`. Canvas (`drawUprightInBox` + clip), HTML (the turned `<img>` at its
uncropped size in an `overflow:hidden` box) and PDF (clip inside the quarter turn) paint it; preflight
reads an upright picture's printed width as its box's height. Sandbox: the image editor's Focus point
(`focusPoint.ts`: a click or the arrow keys; the window the crop keeps outlined, `comicArtCrop`).
- **Frames in their own writing mode and direction (#944).** `direction`: `wrapComposedPages` stamps the
chain head's direction on the story box and on every block of the story that no `{dir}` of its own (or of a
box round it) sets, so the box typesetter sets it against the page as a `{dir}` box. `writingMode` other
than the page's flow (`composedLayout.ts` `ownMode`): the frame's box as placed is
turned a quarter turn about its centre (`quarterTurnedRect`) and the story laid out in it under
`withMeasureWritingMode`; `VDTFrame.writingMode`, `rotate` ± 90 on top of the frame's own
(`frameModeTurn`), `rect` the turned box (`framePageRect` turns it back: decoration anchors, the template
editor). An `'auto'` height is the least whose lines fit the box's width across (`turnedNaturalOf`,
bisection to a px). Pushing frames do not wrap its text; empty frames' rules turn with it.
`stampCentralBaselines` stamps `centralBaselines` / `dashAdvances` on a vertical frame. Canvas
(`setVerticalPaint` per frame), PDF (`ctx.vertical` per frame, fonts collected with the frame's mode) and
HTML (`vertical` paint options per frame) paint its text as a page of that mode does; the source map and
the Sandbox's clicks read it through `frameMatrix`. (Round H, #960: see below.)
- **Comic text panels (#909).** On a vertical or right-to-left page a text panel is set in its frame
(`comicTextPanelsSupported` is true for every document): `layoutComicTextFrames` maps the text area
into the page's flow frame (`pageRectToFlow`), where the story is laid (set vertically on a vertical
page); the comic's frame record stays on the sheet, and `comicFramePaintOrder` hands the backends the
frame mapped into the flow (rect, polygon), painted inside the flow transform (canvas, PDF; the HTML
writes it in a `pt-flow` box over its panel). `sourceAt` and the Sandbox's comic tools read its words
through `pageToFlow`. A frameless text set from the top in a slanted cell takes the whole cell, each
line set short by `slantExclusions` (contour exclusions per 2 px band, `edge` off the outline,
`ComposedPageInput.frameExclusions`); with a box or `valign` middle/bottom it keeps `innerBox`.
`::panel{text shrink}` lowers the frame's `fit.minScale` (`shrinkScale`: 0.5, or the value given,
0.25–1). Tagged PDF: `comicStructure` makes a `Div` in the comic's `Div` at the text panel's place
(`ComicTags.texts`); `StructureFlow.readInto` moves the frame's elements into it.
### 5.16 Findings round H (#882)
- **Comic text panels on strips and spreads, valign in slanted cells (#959).** Strips: the stories are done
for the main loop from the start (`composedLaid`), and `placeStripTexts` lays them at the end of the pass
(after `centrePageText`), where each strip block stands: `layoutStripTextFrames` moves the block's comic
onto the sheet (`flowRectToPage` + `translateComicPage`, the panel sources copied with
`copyComicPanelSources`), runs `layoutComicTextFrames` there, and writes the frames' results (`blockIds`,
`overflowPx`, `scale`, warnings) back on the block's comic, whose `frames` array is replaced
(`comicBlockOnSheet`'s memo checks it). A page-wide strip set where it stands is boxed by
`wrapPageSpanEmbeds`, so `planComicTextPanels` hops the box's closing marker. Spreads: each half lays
the text panels that have most of their width on it (`only`), off the 8 mm about the spine
(`comicSpreadGutter` → `avoid`, a rectangular exclusion). Painting: `comicFramePaintOrder(page, comic)`
and `composedFrameBlockIds` read the strips too; canvas and PDF paint a strip's panels, the flow
frame's text frames, then its lettering in the strip loop after the flow (`comicHoldsText`); the HTML
keys the over-panel markup by comic. `comicTextAncestors` and `sourceAt` answer for a strip's words
before the strip block. Slanted cells: text set to the middle or the foot is laid with the slant moved
by the room the alignment leaves above it (bisection on that shift, at most ten layouts, a layout kept
only when every line lies inside the outline and none is empty); not settled → `innerBox`. Frames
of a half are found by the panel's `index` (`comicPanelOfFrame`), not its place in `panels`.
- **Vertical frame on a right-to-left page (#960).** `ownMode` no longer excludes a mirrored page. The frame is
laid out as on a left-to-right page (`mirroredIn(frame)` is false for its lines) and `VDTFrame.unmirrorX`
holds the flow's mirror origin: `frameMatrix` then returns `mirror · matrix-of-the-box-mirrored-to-the-sheet ·
move`, which, inside the page's mirrored flow, is a plain placement of the frame at the box the page shows
(its first line at that box's right edge, running down). The backends paint such a frame without the flow's
counter-flip (canvas `withoutMirroredPaint`, PDF `ctx.mirror` off for the frame, HTML class `pt-unmirrored` whose
rules cancel `.pt-flow-mirrored`'s `scaleX(-1)` on runs and pictures); `orientLinesToFrames` leaves its blocks
alone. Hit tests and the source map go through `frameMatrix` and need no change.
- **Pictures in a vertical frame of a horizontal page (#960).** The callout layouter takes `uprightMaxLength` (the
frame's extent across its lines less padding, `uprightIn` in `composedLayout.ts`) and lays each resource child
out upright (`ResourceLayoutInput.upright`, as a vertical page's flow does): counter-rotated against the frame's
quarter turn, at most what is left of the box along the stacking axis (`uprightMaxLength` less the cursor and the
gaps); `offsetResourceBlock` moves a rotated block's frame origin. A horizontal card on a vertical page already
stands upright (the frame's −90° cancels the flow's turn); a frame in the page's own mode is unchanged. Pictures
in the frames of a vertical page's own mode still turn with the flow (not touched).
- **Reflowable EPUB.** A frame with its own writing mode is a `div.pt-frame-vertical` / `pt-frame-horizontal` with
`writing-mode` in the stylesheet and the length its lines run along (the page box's height or width) in em of
the body text as `height` / `width` (`CalloutNode.frameMode`).
- **Chains of frames of different modes** work as they are: each piece is cut and laid out in its frame's mode
(`frameModesChain.test.ts`). `framePageRect` is exported; the Cookbook probe reports the page's box of such a
frame.
### 5.17 A story that jumps into a page of a later chapter file (#943)
A magazine kept as one chapter file per article jumps with `overflow.continueIn: 'page:<id>'` into the page
fence of a later file. One `buildDocument` call is one chapter, so the rest of the story crosses the call.
- **Forward ids.** `PostextContent.laterPageIds` (`id → chapter position`) comes from the host (`pageFenceIds`,
`laterPageIdsOf`: the ids of the page fences whose template exists, of the chapters after each chapter). `route`
(`build.ts`) sends a `page:<id>` that no fence of the chapter answers but `laterPageIds` has to
`VDTDocument.pendingJumps` instead of `continueTargetMissing` and adds no `'next-page'` pages; the sending
frame gets `continuesToChapter` and a `continued` event with `toChapter`. A host that passes nothing keeps
every `page:<id>` inside the chapter, as before.
- **Carrying.** A `StoryJump` (`composed/carry.ts`) holds the story's box and children as the sender had them ready
to lay (post-transform `ContentBlock`s, offsets of the sender's body), the `CalloutCut`, the continuation's
fields, the heading numbers by position, the sender's ordered-list indents, the notes the story cites with their
numbers and definitions, and `outline` when it refers to anchors. The host sets `chapter` (whose file the text is
in; kept when a chapter hands a story on again) and `fromChapter` (who sent it) and gives it to the chapter with
the page as `LayoutContinuation.jumps` (`buildBundle`: `incoming`; the Sandbox: the chapter layout record
`ChapterLayout.jumps`, the planner, the chain and the canvas build from the documents).
- **Receiving.** `importJumps` copies the blocks past the chapter's own (`contentBlocks`), moves every source offset
so that `offset + bodyOffset` is the offset in the sender's file, gives the containers ids of their own
(`1_900_000_000+`), renames the notes (`~k~id`) and registers them in the chapter's note maps. `carriedPass`
answers the lookups the box typesetter keys by content index for them: heading prefixes and numbers from the
sender, ordered-list metrics and paragraph groups worked out again from the carried blocks alone, the
context of the text (never a styled section's or a part's). The pre-passes of the chapter (numbering, floats,
warnings, the main loop) never see them. The continuations wait in `jumps` for their fence exactly as an
in-chapter jump's do (`assignJumpReceivers`, the page never memoised); the receiving chain goes on as `'next-page'`.
A story whose page the chapter does not have raises `continueTargetMissing` with `fromChapter` and is not set.
- **Marks.** `resolveJumpMarks(docs)` (after the book is numbered) gives the sending frame `continuesTo`, fills the
`continued` event's `toPage` / `toFrame` and writes the printed label of the page in the other chapter into the
`{page}` marks (`pageMark`) of both frames, measuring the lines again as `fillPageMarks` does; idempotent. A
chapter laid out on its own keeps the guessed number. `buildBundle` runs it; so do the Sandbox's chains.
- **Source.** `VDTBlock.sourceChapter` (and `VDTFrame.sourceChapter`) says whose file the offsets are in; the source
map's `SourceRef.chapter` / `SourceElement.chapter` and the `chapterFile` / `chapter` / `ownChapter` options
follow it. `VDTFrame.readIndex` makes a tagged PDF read a carried frame at its page. The reflowable EPUB and the
HTML reading order read it in the chapter that has the page (its container ids are that chapter's own).
- **Book layout.** `buildBundle` keys a chapter on its `laterPageIds` (only for a chapter with a composed page,
in a book whose templates name a `page:` target) and on the jumps it receives (they are in its continuation);
`ChapterRecord.jumps` keeps what a chapter sent past a dropped document. The Sandbox planner adds `|j<hash>` /
`|l<hash>` to the continuation key under the same conditions, so keys of other books are what they were.
- **Left out.** The sender's styled section or part typography does not reach the rest. (Index marks, anchors
and headings written in the rest, and the editor selection over it, were left out here and are built in
§5.18.)
### 5.18 Findings round I (#882)
- **Frame names by role (#979).** A drafted page names its frames by kind and order, a different box in the next
template. `template_dedupe.py` renames them after the merge (`name_frames`): the callout style id for a box or a
tab (the tab a style draws is already in the id, `box-light-blue-tab-white`), `picture`, `figure`,
`caption-of-<picture>` for a text or box set `below` one, `<role>-in-<parent>` for a frame laid inside another,
`rotated-text`, the flow `text` and the heading `title`. Content (a tab's `label` words) never enters a name. Names
are the same in every template that has the role, a second one is numbered from 2, a child keeps the names of
the template it extends, and a name that is not the positional `kind-N` form is left alone, so the pass is
idempotent. The anchors, groups and the `slot="…"` of every page fence under `chapters/` follow. The Sandbox
frame rename (`renameFrame`) now reaches the same family (`frameFamily`: the template that defines the frame and
everything that extends it, siblings included) and rewrites the fences of every chapter on screen
(`renameSlotChanges`) and of the other editions of a local project (`renameFrameInEdition`,
`ProjectActions.renameFrameInEditions`, one `updateProject` transaction). A sample's bundle keeps its other
editions read-only: only the edition open is renamed.
- **Image budget of a painted book (#985).** C20 stays at 0.9 MB for nine pages (+0.1 MB a page), failing at 1.4 MB.
A recipe whose pages are paintings states the weight it means to publish in `capture.expect.imageMb` (above 0.9,
at most 2): silent up to it, a failure past it, an info when it is not needed or 15 % too high; the card files take
`capture.expect.cardKb` the same way. A budget that counted the painted area was rejected: the bytes of the
rendered page, not the area, say what it weighs.
- **The contents in a frame (#983, #987).** `FrameConfig.takes` accepts `'toc'` besides `h1`…`h6`: the frame receives the
rows of the page's `:::toc` that name no slot (`ParsedComposedPage.tocRows`, the directive before the contents are
expanded, every row and the breaks between parts after; `takenBlocks` answers for both kinds of `takes` in `wrap.ts` and
`slotChecks.ts`). A `:::toc` inside a `:::frame` needs no `takes`. The rows are children of the frame's story box, so the
box typesetter lays them with `measureTocBlock` and stamps what the flow stamps (`tocBlockStamps`: `tocEntry` with the
page it links to, `tocPart`), which was the whole of the defect: `buildHeadersAndFooters` drew the part rows of
`page.columns` only, so a frame's part rows were empty lines, and the repeats of a block's head row (`toc.groups.continuation`),
which the flow sets only at the head of a column that a cut block goes on in, were all set, one before every entry. Now
(a) `placeChild` skips a continuation row unless it is the first row of the fragment, (b) `overlayTocPart` runs for the
part rows of `page.floats` and of a spread's `facingBlocks` with the frame's page frames, (c) a cut between the rows of one
block of the contents (`:::toc{keep}`) is offered only after its head (or the repeat of it) and `minEntriesAfterHead`
entries, and ranks `soft` (taken when no other cut fits) when the whole block is no taller than the room, so a block that a
frame holds goes whole to the added page, as the flow moves it to the next column; an entry is never cut inside its lines.
The title of the contents is not listed because its heading style says `toc: false`, as in the flow. A page break between
parts (`toc.parts.breakBefore`) goes with the rows; a box ignores the `:::pagebreak` row as every directive, and the
part row carries the break itself since §5.19 (#996). Memoisation: a page with
contents rows is never memoised (`pageMemo` already says so for any `toc` block).
- **A ground under the contents (#987).** The ground is the template's decoration, which paints under the frames of the
page and on every page the frame adds; no heading design is asked to paint across the sheet. A design element anchored to
the bleed that runs down to the trim claims the column (the reserved height of a heading design counts every element
without `reserve: false`); a document with such a design says so only in the docs: a warning would also fire on every
cover whose design claims its page on purpose (`headingDesignCuts.ts` makes the same call), so the answer is the note in
the reserved-height section and the `reserve` rows of the design tables. #997 later told the two apart: an in-column
heading is named (§5.19), a page-span opener never.
- **Frames held at the foot of a card (#980).** No new key. A stack group with a box of the card's height, `grow: 1` (and
`shrink`) on the frames above the held ones and a `minHeight` on the held ones is the held foot: spare space goes to the
frames that grow, so a shorter text moves nothing below it; a longer one is squeezed by the ladder (`redistribute`, then
`shrinkText`) with the held frames at their `minHeight`, and `frameOverflow` and the reflow log (`textScaled`,
`overflow`) say what did not fit. A frame anchored to the container's foot instead cannot do this: nothing relates it to
the frames above it, so the ladder never squeezes them for it and an overlap is not reported. `footHold.test.ts` pins both
directions.
- **Label styles in the Sandbox (#969).** A Design section, `labelStyles` in the Figures & tables group
(`LabelStylesSection`, registry id `labelStyles`, counted by `sectionHasOverrides` when the list is not
empty), modelled on Mark styles: id (renamed on blur, never to a colliding one), name, then three
sub-sections: type (borrowed paragraph style, face, size, weight, slant, colour, letter case, tracking), halo
and plate, fit and layout (`minScale`, `writingMode`, `overlap`). No label style is built in, so the list
starts empty and `Add label style` writes `{ id, name }`. A field left unset shows what it inherits (the
borrowed paragraph style, else the body text); `fontStyle` and `textTransform` are three-way selects whose
*Inherited* is "not written", which keeps `resolveLabelLook`'s rule (#955: `'normal'` / `'none'` written turn
a borrowed style's slant or capitals off). Renaming an id does not rewrite the anchors and labels that name
it, as for chip and mark styles: the Checks panel flags them (`labelStyleUnknown`) until corrected. The
config tools needed no change: `labelStyles` was already a section of `sandbox_get_config` /
`sandbox_set_config`, items named by `[id=…]`, and the "every setting" tests walk it.
- **`overlap: 'allow'` (#977).** `ResourceAnchor.overlap` and `LabelStyle.overlap` (`'check' | 'allow'`,
default `'check'`). `figureLabelWarnings` passes `overlappingLabels` a flag per label (anchor's value over
its style's, where the style is the label's own `style` else its anchor's); a pair is skipped when either of
its labels is allowed, since the overlap is as much the other's as that label's. The label style's value is
checked (`unknownConfigValue`) and `stripLabelStylesDefaults` drops `'check'`. The anchor editor has a
Check / Allow control (one anchor and the ticked ones), and the WebMCP marks schema takes the field. What the
issue asks of the extraction scripts (merge labels of the same text within 0.5 mm into one anchor) is the
porting skill's: its notes say so.
- **Words per figure (#986): a warning, not a second model.** A resource is the unit of numbering, of `:ref`
and of the list of figures, so two figures cannot carry different words for one resource without becoming
two figures. The deliberate answer is the existing one (a second resource for the same file) plus
`figureWordsShared`, raised by `collectContentWarnings` from `figureWordConflicts` (figureWords.ts): for a
resource already worded by a figure of the chapter, a later figure whose `alt`, `caption` or `note` differs
from the first's, or that the first left unsaid (and so is not shown either). A figure that repeats the
first's words, or gives none, is quiet. The range is the first of the dropped words; the Sandbox files it
under Figures.
- **Pictures in the frames of a vertical page (#971).** `uprightIn` (`composedLayout.ts`) hands the box
typesetter `uprightMaxLength` for every frame whose effective mode is vertical: its own `writingMode`, else
the page's. A frame in the page's own mode on a vertical page lies in the flow frame, which is turned the
same quarter turn as a vertical frame on a horizontal page, so the same counter-rotation applies with the
frame's extent across its lines (`laidH`) less padding. `naturalOf` (the height a frame needs, for placement
and proposals) passes the flow's whole height as the room, since the frame's height is what it settles. A
horizontal frame on a vertical page is turned against the flow and already stood upright; horizontal pages
are unchanged. The VDT of a vertical composed page that holds a figure in a frame changes (the picture and
caption no longer turn with the flow); no public preset or bundle chapter does, parity 0 diffs.
- **The reflow ladder reuses a frame's whole layout (#972).** Each step of the ladder composes the page again,
and most frames come out of it as they went in. `pass.layoutMemo` (`pipeline/layoutMemo.ts`, made with the
measurement memo in `layoutComposedPage` and dropped with it) answers `makeCalloutLayouter`'s `layoutRange`
for a range laid out before: key = pass, measure context, story and its end, the box style by content (the
ladder resolves the style again every composition: two equal styles are one key), the cut, width, frame id,
continuation, mirroring, the frame's options (`boxHeight`, exclusions, picture scale, marks, upright length)
and the measuring state (`measureStateKey`, shared with the measurement memo). A caller that moves the
result (`fill`: `commitFrame` shifts it to the page) gets a private deep copy; the memo keeps a copy made
before the first caller moved it. A caller that only reads (`peekRange`: the split probes, the natural height
of a frame) gets the stored layout, which nobody changes. The copy preserves shared structure (units and
children point at the same blocks) and what the layout keeps by identity, a block's end indent and a
designed heading (`copyBlockEndIndent`, `copyDesignedBoxHeading`; a new layout-time registry keyed by VDT
object must add a copier); a layout holding a comic page or an object that is not plain data is never kept.
The writes a layout makes to the measure context (drop cap notes, joining-script marks) are recorded with the
entry and replayed on a hit. `layoutMemoSwitch` turns it off for the test that lays a page both ways. Parity
0 diffs; the heaviest composed chapter of the Cookbook (a magazine of threaded frames) lays out about 45 %
faster.
- **Marks, anchors and headings written in the rest of a story that went to another chapter (#988).** The
receiving chapter locates them (`locateCarriedOutline`, called from `withIndexMarks` once the pages are
final): per carried story, the lines it laid for that story (offsets in the sender's file) answer, an index
mark, an inline anchor or a heading is its own when a line holds the character or it lies inside the span the
lines cover (what lies before is the sender's, laid by it). They go on `VDTDocument.carriedOutline`
(`chapter`, `kind`, `sourceStart` in the sender's body, page), the anchors also as destinations on
`doc.anchors` (`VDTAnchor.chapter`). The sending chapter's own location leaves out the lines of carried
blocks (`sourceChapter`, they are another file's offsets), its anchors skip the marks of a sent story that
none of its lines hold, and `outlineFromDoc` takes the heading blocks of a carried story out of the
order its entries are matched with (the sent headings are matched by their source). `mergeCarriedOutline`
puts the pages into the sender's entries by `(chapter, kind, sourceStart)`: `buildBundle` keeps
`ChapterRecord.carried` (`carriedPlaces`) and merges before it assembles the book outline; the Sandbox
planner keeps `ChapterLayout.carriedOutline` (and writes it in `layouts.json`) and merges in its
`bookOutline`. Index, contents and references then read the page of the words. The Sandbox canvas paints the
selection and the caret of the chapter being edited over the carried words on the later chapter's page
(`drawOverlay` with `CarriedSelection`).
- **Tracking in a box's body (#974).** `CalloutBodyStyleConfig.letterSpacing` (em of the body size, negative
tightens; absent when zero) rides on the derived `bodyText` of a box (`deriveCalloutResolvedConfig`), so
`resolveBodyStyle` gives the body style a `letterSpacingPx` and paragraphs and list items are measured, stamped
(`VDTBlock.letterSpacing`) and painted as a paragraph style's tracking is (#907). A paragraph in a
`:::paragraphs` fence keeps its own style's tracking, code listings none. A frame takes it through its callout
style; a frame's own override of it is the `look` of #976.
- **Removing an inherited decoration element (#975).** `decoration.elements` of a patch may hold
`{ id, remove: true }` (`DecorationRemoval`): `mergePatch` drops the element of that `id` merged so far, parents
first and a variant after the chain, as `remove: true` does for frames and groups. A removal naming nothing is
silent (no warning kind); a new element of the same `id` may follow it. The Sandbox lists the inherited elements
with a switch each and writes the removal in the scope being edited.
- **`width: 'auto'` on a frame (#982).** `placeBox` takes a `naturalWidth(room)` next to `natural(width)`; an `'auto'`
width asks it (the grid cell's width, the area's, or the room from the anchor to the area's edge, less
`maxWidth`) and clamps to `minWidth`/`maxWidth`. `naturalWidthOf` (`composedLayout.ts`) lays the frame's story
out flush left at eight times the room, so that nothing wraps, and takes the right edge of the longest line
(a picture by its box) plus what the box keeps to its right (`callout.innerRect`: padding, a right stripe), a
quarter pixel over, capped at the room: text that does not fit wraps at the cap. The box starts at the edge its
anchor names (`pinX`), so frames anchored to it follow, and a group member is placed the same way. Not for a
frame in its own writing mode, nor for a frame with nothing to lay (width 0 up to `minWidth`). The Sandbox
already offered *Its content* for width.
- **A decoration text over a frame, and the heading's number (#984).** `sourceAt` keeps the paint order (frames of
layer 0 and up over the decoration) but a frame hit that has nothing of its own at the point (the chain is the
frame alone: its box, its padding, an empty frame) is held back: a *text* element of the decoration under it
answers instead (whether its words come from the Markdown, a page attribute or the configuration), and the
held frame answers when none does. Rules, boxes and pictures of the decoration do not win over a frame. A
`layer` on decoration elements, to paint them over the frames, was not added: it would make the decoration two
paint items in every backend (canvas, HTML, PDF, tagged PDF, preflight, contrast, the source map); a frame
with a negative `layer` already goes under the decoration. `{headingNumber}` and `{headingNumber.<frame>}`
(`headingNumberPlaceholder`, `design/placeholders.ts`; allowed in the `'composed'` context only, so the running
heads of other pages do not list them) print the `headingNumber` counter of the first heading block each
frame holds, in the document's digits; `headerFooter.ts` gathers them while it names the page's frames and
hands them to the placeholder context. Empty when no frame holds a numbered heading.
- **A picture inside a label tab (#981).** `CalloutLabelConfig.icon.placement` (`'beside'` | `'inside'`): inside, the
tab is `paddingX + picture + gap + words + paddingX` wide, the picture (fitted into `icon.width` × the tab's
height) at the tab's start, the words after it, a tab at the right corner growing leftwards; with no picture the
tab is the plain one. The overlay is the same box, image and text blocks, so no backend changes. The picture
keeps its colours; a tint from the tab's text colour would need a colour on `VDTDesignImageBlock` and the same
in canvas, HTML and PDF, and is left (SP followup).
- **A frame's own colours, the page's palette, and the kinds of box (#976).** Decision: one callout style per *shape*,
the tint elsewhere. Three levers, from the narrowest:
(1) `FrameConfig.look` (`FrameLook`): colours named and nested as in the callout style (fill, border, stripe,
icon, marker and its rule, title, body text, the tab's colours and rule) and `body.letterSpacing` (#974's
frame override). `normalizeFrame` keeps what the engine can use (`LOOK_TREE`: a colour has a `hex`, tracking
is a length that may be negative), a patch merges it key by key at every level (`mergeLook`), and
`frameStyleOf` writes it over the resolved style (`styleWithLook`, memoised per style and look, so the `body`
and `lists` objects a measurement is keyed by keep their identity). A fill colour turns the fill on; tab colours
apply only to a style that has a tab. `validatePageTemplates` reports unknown keys at any depth. A `look` colour
with a `paletteId` follows the palette like every colour of the config.
(2) The template's `palette` and the page fence's `palette="id=#hex"`, which the engine read and ignored:
`buildHeadersAndFooters` stacks them on the part's and section's overrides for a composed page, so the
palette-linked colours of its frames (boxes recoloured by value, `partPalette.ts`) and decoration take the
page's value. One style with a fill linked to a palette entry then serves every tint, each page fence naming
its own.
(3) Sandbox library: `styleKinds` groups a style with those that extend it or differ from it only in colours
(`onlyColours`: every differing field a colour on both sides), and `mergeSuggestions` no longer proposes such
a pair; a pair that also differs in a length, a switch or a word still is. The Frame styles section lists the
kinds. What stays: a book whose boxes differ in colours *and* shape still has one style per shape.
**Sandbox, editions and bundles (#968, #970, #978).**
- **A licensed family names an open one (#978).** `CustomFontFamily.fallback` and the manifest's `fonts[].fallback` name a
family of about the same width for the faces the book has no file of. The configuration keeps naming the licensed
family (the book is still set "in" it; `fontFamily` fields are never rewritten): hosts load the fallback's cuts and
register them *under the licensed name*, so measuring, painting and the PDF need no other change. The rule is one
sentence, `hasOwnStyle`: a family with no file at all is set wholly in the fallback; a family with files keeps them
and takes from the fallback only a slant it has none of (an italic). Weights between its own files stay with CSS
matching, as for any family. It is not a general font-substitution map: `fontFallbackOf` reads only
`config.customFonts`, so a Google or system family cannot be aliased this way (a family nobody has is still
`missingFont`). Where it is done: the bundle reader keeps a family with a fallback and no file and warns once
instead of once per file; the bundle writer writes a family it may not hand on as `{ name, variants: [], fallback,
redistributable: false }`; `bundleFontProvider` and `loadBundleFonts` take the fallback's faces from the bundle or from
the host; the CLI's `resolveFonts` looks the fallback up like any family (and asks no catalogue for the licensed
name); the Sandbox's font loader declares the fallback's faces under the licensed name for the page, the layout
worker and the PDF provider, and the Fonts panel has a Stand-in family field. A fallback that is itself a custom
family (bundled open face) works too. The limits this round named (a fallback served only by Google's style sheet;
EPUB and HTML leaving the licensed family to the reader) are lifted in §5.19.
No new warning kind: the stand-in is announced by the import warning and shown in the Fonts panel; the PDF embeds
the open face, which the docs say.
- **Editions (#968).** *Title:* `ProjectRecord.editionTitles` keeps `localized.<lang>.name` and `description` of every
edition; the record's name follows the edition on screen (`withActiveEdition`) until the reader renames the
project (`renamePatch` sets `titleEdited` on a book with editions when the title changed); then that title stands in
every edition and an export gives no edition a title of its own. A copy's "… copy" name is the reader's. *Pagination:*
the Sandbox writes `layouts.<locale>.json` for every edition (the records kept under its chapter ids, current for
the edition's text, configuration and resources, keyed by the chapter files `BundlePlan.localeChapterFiles` reports);
the reader already read it before `layouts.json`. *Fonts:* the families an edition laid out behind the reader declares
and the book on screen lacks are served as the book's (`declareEditionFonts`, a second registry the book's own
`setCustomFonts` overrides and `newBook` empties); two editions that give one name different files are not told apart.
The Activity-bar badge counts the edition on screen (#903), by decision (§5.19).
- **The label editor of a surface (#970).** The hit test, hover, drag with its ghost, drop, click and double click are
`createLabelEditor`, which knows nothing of the page: a `LabelSurface` paints its marks (`paint(page, shapes | null)`)
and says how big a CSS pixel is there. The Canvas and the HTML preview adapt it per page element
(`attachLabelAnchorEditor`, same capture-phase listeners as before); the Folio gives `attachPageInteraction` a
surface that paints into the page texture (`viewer.redecorate`) in place of the page's own marks while a label is
hovered or dragged, and enables it in select mode only (a drag never starts a page flip: flipping is hand mode's).
The drop is `dropLabel` (anchor on the resource, or the `at` percentages in the Markdown), one history step, through
the template editor host, which the Folio now lends too. The anchors dialog takes two fingers (move and pinch).
### 5.19 Findings round J (#846)
- **A page break between the parts of a contents in a frame (#996).** `TocBlockInfo.breakBefore` marks the part row
that `toc.parts.breakBefore` puts after a `:::pagebreak` (every part but the first; the repeat of a head row drops it).
`partBreakCut(pass, L, from)` in `frameLayout.ts` answers the cut at the first such row after something real in the
fragment. Three places use it. (a) `splitCalloutFragment` takes a `partBreaks` argument (only the frame typesetter
passes it, so a `:::toc` in a flow callout is cut as before): a top-level cut before a flagged part row is a candidate
that skips `splitMinLines` and the list rules (the break is the author's), and no cut runs past the first such row;
the deepest cut that fits wins as always, so a part taller than the room is cut by the room and the next part still
opens a page. (b) `fill` in `composedLayout.ts` cuts even when the whole box would fit, when the frame can go on:
chained (`next`) or `fit.goOn`. (c) A frame that could go on but is not in `fit.goOn` is reported by the composition
(`Composition.wantsOn`); the ladder takes it as its first step (`goingOn`), before the pictures and text shrink, so
nothing is scaled for a break. A frame with no `overflow.continueIn` and no chain ignores the break (documented, no
warning: the option is the contents' and the template is the author's). `naturalOf` measures an auto-height frame up
to the cut it will take. Rejected: handling the break in `layoutCallout` (it has no way to end a fragment); a new
`FrameConfig` key (the option already says it).
- **A heading design that fills its column (#997).** `headingClaimsColumn` (`level`, `element?`; WARNING_SINCE 1.43.0),
a content warning of the layout beside `tocGroupSplit`. `measureHeadingDesign` returns what sets the reserved height
(`claimedBy`: the id of the element that reaches lowest, or `byMinHeight`); the build keeps it for an in-column heading
(`span` not `'page'`) whose design is taller than its lines (`headingClaims`), and at the end of the pass reports the
heading when it is the last block of its column, the column has no room for a line, and the next block of the flow is in
another column. The two cases the issue wanted told apart: a page-span opener is never noted, and a heading followed by
an explicit break is silent (the page is in `forcedBreakPages`, or the column in `forcedBreak`). On a page of one text column the column is the page, so a design that fills it is a cover and is silent too: one public recipe (a vertical title page) set exactly so, which the first version of the check named by mistake. Elements that follow
the band (a box filling it) do not set the height, so they are never named. It is not part of `headingDesignCut`
(`collectHeadingDesignCuts` stays a pure check on the finished layout, for design text lost past the foot): the claim
needs what only the build knows (which element reserved the height). The Sandbox lists it in the design group of the
Checks panel and the docs say what to do (`reserve: false` on the element, a lower `minHeight`, or `span: 'page'`).
**Sandbox editions and the template editor (#989, #991, #999).**
- **Add a language (#989).** `ProjectActions.addEdition(id, locale)` (Books panel row menu, `sandbox_save_book`
`add_language`): `withNewEdition` (storage/projectEditions.ts) copies the edition the record opens with into
`editions[locale]` (chapters under new ids, configuration cloned, resources cloned but pointing at the same files, which
the sweep already counts across editions), sets `config.locale`, follows built-in figure/table names to the new language
(`relocalizedResourceTypes`) and drops an explicit hyphenation language (it was the old language's). The record's own
`locale` is filled when absent (configuration, then the interface language) because `withActiveEdition` keys on it;
the action then opens the new edition with it. A language the record has (tag compared without case) is refused.
An Arabic, Chinese or Japanese edition gets the defaults of its language (`withLanguageDefaults`: the Design panel's
`arabicDefaults`, `chineseDefaults`, `japaneseDefaults` on the new edition's configuration, as a new book in that
language gets them). Those lists already tell a setting the book chose for itself (`customised`) from one it left at
the engine's or the other script's default: with no `include` they apply the rest and the rows that are part of the
language (an explicit left-to-right direction goes back to Auto for Arabic), so the book's own typefaces, margins and
the like are not overwritten. The writing mode the book has is kept (a vertical book gives a vertical Japanese
edition, a horizontal one a horizontal edition). The notice says how many settings moved. Going from such a
language to another one undoes nothing: direction, binding and writing mode stay as the first edition has them.
- **Copy names (#989).** Duplicating a project and "Copy of the open book" already added "copy"; the copy of a *sample*
kept the sample's name. It now takes the same suffix (`projectCopySuffix`).
- **A title per language (#999).** The rule of #968 stays the default (a rename is the book's in every edition,
`titleEdited`). `renameEditionPatch` is the other choice, offered by a box in the rename fields of a book in several
languages (`sandbox_save_book` `rename` with `thisLanguageOnly`): the edition on screen gets its own entry in
`editionTitles`, every other edition is given the title it shows now (so nothing moves under the reader's eyes, also
when `titleEdited` was on) and `titleEdited` goes off, which is what an export reads to write `localized.<lang>.name`.
- **The badge (#999).** Decision: the Checks badge keeps counting the chapter and edition on screen. Other editions are
laid out behind the reader only while the Checks panel is open (`useBookEditions`), so a badge reading their entries
with the panel shut would show numbers that an edit has outdated, and keeping them current means laying every edition
out after every edit. What changes is the accessible name: for a book in several languages it says which language the
count is of (`navWarningsEdition`); the panel is where every language is listed.
- **Template editor (#991).** `newFrameFor`: a frame added to a template whose groups have members is sized like the last
member of the last such group; with a stack and no member it is `fill` x `auto`; otherwise the old 60 x 40 mm box.
Group members are an ordered list (`GroupsEditor`): a ticked frame joins at the end, `moveMember` and two arrow buttons
reorder, and the engine already honours the order of `group.frames`; the old behaviour (always the template's order)
was the UI's, not the engine's. "New" picks its first frame.
- **A label near the spine (#990).** On a spread the figure across the spine is one block painted twice, each page
clipped to its sheet, so a label whose box reached into the gutter band was cut by the fold and read on neither page.
`keepLabelsOffGutter(block, band)` (`composed/labels.ts`) runs in `layoutComposedSpread` on every block of the laid
sheet, before the blocks are cut and the facing copies made: a label whose box (`labelRectInBlock`, the same box
`stampFigureLabelRects` stamps, through a quarter-turned block's turn) meets the band `[spine - gutter / 2, spine +
gutter / 2]` slides along the sheet's axis to the band's edge, on the side of its anchor point (ties, the point on the
spine itself: the first page, as a frame is), and keeps its other coordinate. The design answers the issue's first
option (draw the label on the side where its anchor lies) rather than the second (warn in the anchor dialog): the
fold is a fact of the sheet, not an authoring mistake, so nothing is raised and the Sandbox needs no warning. A label
set across both pages on purpose (a title over a map) keeps its place with `ResourceAnchor.gutter: 'cross'`
(`'keep'` is the default and is not written); the anchor editor has a Keep off / Cross control, one anchor or the
ticked ones, and the WebMCP marks schema takes the field. The move replaces the label object (the labels of a
measured block may be shared with a cache), works in the flow frame (so a right-to-left sheet moves the same way
before the page is mirrored) and on the transposed axis of a vertical sheet. The gutter band is the template's
`gutter`; with a gutter of 0 the band is the spine line itself. The outer edges
of the sheet are left alone, and documented so: a label past the trim is cut as the picture is.
- **Label styles (#994).** Renaming a label style in the Design panel now rewrites what names it, as a frame style
rename does: `labelStyles/labelRefs.ts` reads the `style` of each `::label` line (outside code fences, quoted or
bare) and of each anchor; `applyLabelStyleRename` writes one history step per chapter changed (the chapter in the
editor through its editor), one per picture changed, then the design, so Undo takes them back one at a time; the card
says how many anchors and lines it rewrote. Each card counts the anchors and label lines that name its style
(`useLabelStyleUsage`, a chapter read again only when its text changed). The Avoid zones mode of the picture marks
is worded for maps as well as comics (symbols a label keeps clear of). The EPUB limits stay deliberate and are
documented: the reflowable book lists the labels its SVG layer cannot draw in a hidden list, and tracking is not drawn
on Arabic words (a joining script is not tracked in print either).
- **An SVG's own shapes as symbols (#994).** Decision: measure once, store as `avoid` zones, read nothing at layout. The
engine never holds a picture's markup (the file lives in the Sandbox's blob store or the bundle), and zones already
mean "symbol" to `labelOverlap`, so no format and no engine path changed. `panels/resources/svgSymbols.ts`
measures an SVG in a browser (`measureSvgNodes`: the drawing laid at its view box with `preserveAspectRatio` none, one
`getBoundingClientRect` per element, so transforms, `use` and nested groups are exact) and `symbolZones` picks the
elements by id prefix or class: the outermost match of a branch (a group named as a symbol is one zone), never one in a
definition (`defs`, `symbol`, `clipPath`, ...), a point widened to the smallest zone the marks allow (0.6 % of the
picture). `addZones` adds only zones that do not cover an old one by nine tenths, so reading the drawing again adds just
what is new and never touches a hand-drawn zone. The Avoid zones mode of the marks dialog has the row (SVG pictures
only); `sandbox_save_resource` takes `marks.svgSymbols { idPrefix, className }` and merges the zones into the call's
or the picture's own. Known limit, documented: the zones are a snapshot, so a changed drawing has to be read again; a
stroke's width is not part of an element's box.
**Outputs (#993, #995).**
- **A stand-in travels where the licensed face may not (#993).** A family marked `redistributable: false` that names a
`fallback` is set, in a file that leaves the app, in the fallback's faces of the same weight and slant under the
licensed name. Decision: the fallback is open by definition (it is what the licensed family's bundle ships in its
place), so embedding it under the licensed name is a naming matter only, and the family's own files never go. The rule
is `exportStandInOf(customFonts, family)`: only a withheld family has an export stand-in (a family the book may hand
on keeps its files; `hasOwnStyle` is the layout's rule, this is the export's). Text fonts: the host gives the stand-in
faces as ordinary `EpubFontFile`s under the licensed name (Sandbox `collectEpubFonts`, which asks the loader for the
fallback's faces of every weight and slant the book uses and returns `stoodIn`; CLI `FontSet.exportStandIn`, which
looks the fallback up like any family, the book's own files of it first). SVG pictures: `inlineSvgFonts*` takes
`standIn(family)`; for a family `withhold` names, the provider is asked for the *stand-in's* face and the file is
embedded under the withheld name, so the licensed files can never be answered by a provider that has them; a family
whose stand-in lacks any face the SVG asks is left out whole (`fontWithheld` as before). `standIn` is passed by the
host (`ResolvedConfig` carries no `customFonts`): `EpubSvgFontOptions.standIn`, `renderToHtml`'s
`inlineSvgFonts.standIn`. No new engine warning kind: the Sandbox's EPUB tab gets a notice (`fontStoodIn`) and the CLI an
info line. A stand-in with several licensed families in one fallback is embedded once per family (buffers copied).
Third bullet of the issue: a fallback served only by Google's style sheet was already declared and sent to the layout
worker (tests added); what failed was the PDF and SVG provider, which looked for Fontsource files only; it now ends with
`sheetFaceFiles` (the sheet's faces for the cut CSS font matching settles on). The port kit's pack step is the skill's
(skill-notes/993.md). Limits: a family with some own files and a fallback for one slant is, in an export, set wholly
in the fallback (its own files are withheld, so no weight can be a mix); the lines were measured with the real files
in the owner's Sandbox, with the stand-in in a plain bundle's.
- **A picture painted in the label's colour (#995).** `label.icon.tint` (boolean, default off) paints the picture's
*silhouette* in `label.color`. Decision: one semantic for every backend, "the colour fills what the picture covers, in
proportion to its opacity" (canvas `source-in`, HTML `feFlood` + `SourceAlpha`, PDF recoloured paths / alpha stencil),
not SVG `currentColor` (it would work for some pictures and not for bitmaps or for SVGs with fixed colours). The block
carries `VDTDesignImageBlock.tint` (`#rrggbb`), so any design image can use it later; only the label sets it. Tint wins
over `diagramStyle.singleInk`. A picture with no transparency becomes a rectangle. PDF: a vector drawing is recoloured
into its own Form XObject per colour (`tintedDrawing`); a bitmap (or an SVG that took the raster fallback) is painted
through a 1-bit `ImageMask` of its alpha (cut at half transparency; no transparency, so PDF/X-1a stays valid, and the
colour goes through the render's colour space); a print master (an embedded PDF page) cannot be recoloured, so a tinted
SVG drops `pdfFileId`. HTML: the page defines one filter per colour (`pt-tint-<hex>-<page>`) beside the single-ink
filter; a reader without CSS filters shows the picture in its own colours (the fixed-layout EPUB goes through it). The
contrast check and preflight need nothing: the picture's colour is the label's, which they read for the words over
the same tab. The reflowable EPUB does not draw label tabs, so it has no picture to tint.
- **A finger on a label (#992).** The label editor of the Canvas and the HTML viewer took its presses in capture
listeners and its marks were an SVG that takes no pointer, so a touch on a label reached the browser as a plain
page touch: after a few px it became a scroll, `pointercancel` ended the drag and nothing was written. The browser
reads `touch-action` at the first touch, from what is under the finger, so the fix is something under it: a
`touchPads` list (one box per label outline, a 44 px square round every point), laid over the page as `div`s with
`touch-action: none`, only where the screen is `(hover: none) and (pointer: coarse)` (a screen that also has a mouse
would have them intercept the presses the editor leaves alone). A finger that lands on bare paper still scrolls the
canvas. The editor takes a finger or a pen within 22 CSS px of a point (a mouse 9) and lets it wander 8 px before a
press is a drag (a mouse 3). Decision: not `touch-action: none` on the whole page in Design mode, which would stop
the canvas scrolling wherever a page is under the finger. The Folio needed nothing (`.is-select` is
`touch-action: none` already and a press on a mark never turns a page); the anchors dialog's stage is
`touch-none`, and the browser's own page pinch does not start there. Checked with the browser's touch emulation
and real touch events (Chrome DevTools protocol) on a tablet and a phone screen, not on a physical device.
`drawLabelMarksOverlay`, unused since the HTML viewer takes the editor's marks, is gone.
- **Frame names in a draft that dedupe never sees (#998).** `pdf_regions.py extract` names frames by position
(`box-4`) because `template_dedupe.py` compares the frames of two templates by name and the positions line up
across the pages of a kind. A run of one page has nothing to merge, so `extract --name-frames auto` runs
`name_frames` itself: templates, page Markdown, `figures.json`, `regions.json`, overlays and `report.json` follow.
`always` does it for several pages, `never` keeps the positions. The callout styles are still `box-1` when
`extract` runs, so a box says `box` and its order; dedupe, run later on such a draft, names it after its merged
style (the role names match its positional pattern and are derived again). The Sandbox rename of a frame of a
sample reaching only the open edition is a documented limit: the other editions of a bundle are read each time and
have no store to write to.
## 6. Source map contract
Every painted thing knows its origin:
- Body text laid into frames: the usual `sourceStart`/`sourceEnd`/`sourceMap` per block and line
(absolute offsets in the chapter file).
- Frames: `configPath` of the template frame and `sourceStart/End` of the slot's content.
- Labels: source range of the `::label` text and `resourceId#anchorId`.
- Decoration and header/footer elements: their config path (`/pageTemplates/3/decoration/elements/1`).
- Text produced by `{attr.x}`: the attribute's source range in the page fence (`attrSources`).
- Contents entries: the heading they come from.
Stable ids: a frame is `cp<fenceIndex>:<slot>` (plus `~<n>` on the n-th page added by continuation, §5.12); a
label is `cp<fenceIndex>:<slot>:label:<anchor>`; same id in every locale and across relayouts.
Relayout scope: the composed page's layout is memoised by (resolved template, slot content,
fonts, locale, page parity); an edit inside one page re-sets that page (and its continuations).
Budget (P16): < 100 ms for a property change on one page, < 300 ms with reflow.
### 6.1 What P16 built (#857)
- **Query API** (`composed/sourceMap.ts`): `sourceAt(doc, page, x, y, { file })` returns the
elements at a point innermost first (`text` → `line` → `block` → `box` → `frame` → `group` →
`template`; `design` → `slot` → `template` for decoration and running heads), each with a
stable `id`, a `source` (`markdown` | `config` | `attr` | `resource`), `content` (the Markdown a
frame or page holds) and `rect`. Hit order follows paint order: running heads, then frames and
decoration from the top layer down; a turned frame is hit in its upright box. Reverse:
`elementsForSource(doc, start, end)`; `findElement(doc, id)`. Nothing is laid out: the API reads
stamps.
- **Stamps added.** `VDTChip.sourceOffsets` (relative to the chip's `:`, so the rich cache stays
valid), `containerAttrSources` on container fences carried to the story box so a frame's tab and
title map to `label` / `title`; every design block gets `elementId`, and `configPath` where the
slot's pointer is known (decoration through `decorationPaths`, template header/footer through
the new `headerPath` / `footerPath`, `/headingStyles/<i>/header`, `/header`); `attr` on a text or
image block that shows `{attr.x}`; `{attr.x}` in a composed page's running heads maps to the page
fence; `OutlineEntry.headingSource` → `TocBlockInfo` → `tocEntry.headingSource` (body offsets,
kept out of `outlineKey`); `FigureLabel.anchorRef` (`resourceId#anchorId`) and `FigureLabel.id`
(`cp<n>:<slot>:label:<anchor>`, `@<k>` for a percent label) for P6.
- **Ids** (`composed/ids.ts`): `cp<n>`, `cp<n>:<frame>` (`~<k>` reserved for continuation pieces
on later pages, #859), blocks `cp<n>:<frame>-c<k>`, `cp<n>:group:<name>`,
`cp<n>:decoration|header|footer:<elementId>`. A chained piece on the same page keeps P4's
`-cont-<k>` block id.
- **Memo** (`composed/pageMemo.ts`): `layoutComposedPageMemo` keys a page by a 64-bit digest of the
resolved config (once per pass), the measurement generation, the resolved template, the fence
index, the page's Markdown slice and blocks (offsets relative, container ids by order), the
geometry rounded to 1e-6 px, the side's `mirrored`, and a pass context from `build.ts` (heading
numbers, part, section, paragraph containers, boxes out of flow, the pictures' resources, types,
numbers and floats). Pages with footnotes, refs, citations, contents, index or strips are not
kept, nor pages whose layout left drop-cap or joining-script notes. A hit parses the stored JSON
and moves source offsets, `contentIndex`, container ids (`calloutPath` too) and `pageIndex`. The
digest is `VDTComposedPage.layoutKey`; `composedPagePaintKey(page)` adds what is laid round the
frames, and the Sandbox canvas keeps the bitmap of a page whose paint key is unchanged.
- **After P7, P9, P14.** `VDTFigureLabel` carries `id`, `anchorRef` and an absolute `sourceMap`
(and its `sourceStart`/`sourceEnd` are now absolute: `figureLabels(block, bodyOffset)`); a
label with a `rect` (P6) answers `sourceAt` at level `label`. The half of a spread frame on the
facing page (`facingBlocks`) answers with the read blocks' ids and `facing: true`; points outside
`sheetClip` miss frames and decoration. Spreads are laid out afresh every time (no memo, no
`layoutKey`): the sheet's two pages depend on each other. Groups come from the resolved
template; the negotiation reads only the template and the page's content, both in the key.
- **Cost.** A warm composed page sets in about 1 ms (stub measure), so the memo saves about half
of the composed-page work on a rebuild; the whole chapter build is dominated by the rest of the
pipeline. Bench: `__bench__/composedPage.bench.test.ts`.
## 7. Comics
`:::page{split}`, `::panel` and lettering keep their parser and layout unchanged in this
programme. P12 (later) re-implements them on templates: each split cell becomes a generated frame
holding the panel art, lettering stays positioned by the comics code. Every comics fixture and
Cookbook comics recipe must render unchanged before and after.
### 7.1 What P12 built (#885)
- **The split generates frames** (`comics/frames.ts`, `comicFrameSpecs`). The cells of the split
tree (`comicGeometry`, unchanged) in reading order, each with its panel's `pad` taken off, and
each `inset` panel over the frame laid before it (layer 2; cells are layer 1), are the page's
frames; `layoutComicFrame` builds each panel from its frame. Names: the panel's `id` when no
frame before took it, else `p<k>` (k = place in panel order, from 1). Ids follow §6:
`cm<n>` for the comic (`n` = the comic fence's ordinal in its chapter, `:::page` and `:::strip`
counted together, stamped by the block parser as `ComicPageSource.fenceIndex`),
`cm<n>:<name>` for a frame (also `VDTComicPanel.frameId`), `cm<n>:<name>:b<order>` for a balloon
(`VDTComicBalloon.elementId`; `id` keeps its layout-order value for the Sandbox).
- **Decision: polygon on the frame.** `VDTFrame` gains `polygon?` (set only when the outline is
not the box: a cell cut by a slanted line) and `panel?` (the index in `VDTComicPage.panels`,
which marks a generated frame; its `configPath` is `''`). Content is clipped to the polygon in
canvas, PDF and HTML; hit-testing reads it. The comic keeps `VDTComicPanel.polygon` for its own
painters, so frames are a record beside the panels, not a second painter: `VDTComicPage.frames`
(pages, spread halves and strips; `translateComicPage` moves them, a spread half rebuilds them
from its cut panels with `comicFramesFor`). The comics VDT and the HTML of every existing comic
are unchanged (harness: 125 comic chapters of the public presets and the Cookbook bundles, all
locales, full page JSON with only the new keys ignored, 0 diffs; preset parity 583 chapters, 0
diffs).
- **Text panels** (`comics/textPanels.ts`). `::panel{text}` makes the body Markdown
(`ComicPanelSource.text`, no script items). The block parser reads each body with the same
parser on a copy of the text blanked before the body (offsets stay absolute; `:::` lines in the
body are blanked, since a comic's body ends at its first bare `:::`) and emits its blocks right
after the comic directive, so outline, index marks, warnings and the flow fallback see ordinary
blocks. `wrapComicTextPanels` (beside `wrapComposedPages`) puts each body in a story box of
`FRAME_STYLE_ID` keyed `comic:<panel>`; the comic page branch of `build.ts` sets each story
with `layoutComposedPage` over a one-frame template whose area is the panel's text area (the
panel's box inside its border plus `comics.lettering.inset`, or the box itself with `box`; in a
slanted cell the widest straight-sided box inside the polygon, `innerBox`),
`valign` → `verticalAlign`; ids are renamed `cp<n>:` → `cm<n>:`; the blocks go to `page.floats`
and `doc.blocks`, the frame record gets `blockIds`, `overflowPx`, `scale`. A `::resource` in a
story does not float (the float plan treats the text plans as composed pages). Not wrapped, so
set in the flow after the comic: comics inside a container (strips and spreads since #959, vertical and
mirrored documents since #909; `comicTextPanelsSupported` is true for every document).
- **Paint order** (`comicPaintsUnderText`, `comicFramePaintOrder` in `composed/paint.ts`): on a
comic page holding text frames the panels are painted before the flow, the text frames after
the floats (their ids are in `composedFrameBlockIds`), the lettering after the flow; elsewhere
the comic paints whole as before. The HTML writes each text frame inside the comic's markup
after its panel's figure (`ComicHtmlPaint.overPanel`), `z-index:auto`. Tagged PDF reads a text
frame as one float after the comic's `Div`; the reflowable EPUB reads it after the panels.
- **Box styles.** `::panel{box=<calloutStyle>}` maps the style's background, border colour and
width and radius onto the panel (its own `border`/`bg` win); on a text panel the frame draws the
box (frame style = that callout style) and the panel draws none. `VDTFrame.styleId` names it.
- **Warning** `comicPanelFrame` (`reason`: `unknownBox`, `textOverflow` (layout); `strip`, `spread` and
`flow` were raised up to 1.39 and stay in the type for old reports; `panel` = source index).
- **Source map** (`comics/sourceMap.ts`, called from `sourceAt`, `elementsForSource`,
`findElement`): balloon glyph (`text`) → balloon (`box`) → frame → comic (`template`); picture
(`resource`) → frame → comic; a text panel's words answer through the flow path with the frame
and the comic appended. Sandbox: `comicTextFrameAt` (frameHit.ts), text frames in
`frameTools` (Content mode: click off words → caret at the text, double click → in-place
edit), `panelArtAt` ignores a text panel's words. The template editor never sees comic frames
(they are not in `page.composed`).
- **Not done:** the comic frames in the template editor's views. (Vertical and right-to-left pages, lines
following a slanted edge, `shrink` and the tagged PDF's reading order: round G, §5.15; strips, spreads and
text set to the middle or the foot of a slanted cell: #959.)
## 8. HTML and EPUB
- Reflowable HTML/EPUB: reading order is Markdown order. A composed page becomes a `<section>` in
which each frame is a block with its callout style, in source order (not template order).
Decoration is dropped (or kept as decorative images). A figure's labels become accessible text:
a visually hidden list after the image, in label order.
- Fixed-layout EPUB and the HTML viewer: the template is applied (they render the paged VDT).
- Tagged PDF: structure order follows the Markdown (`contentIndex`), not paint order.
What P9 (#862) built:
- **Reading order** is `composedReadingOrder(composedPaintOrder(page))`: the decoration first, then the
frames by the lowest content index of their blocks (a frame a chain continues into after the frame
before it). The HTML viewer (and so the fixed-layout EPUB) writes frames in that order inside one
stacking context (`pt-composed`, `z-index: 0`) and paints them by `z-index` = place in the paint
order; tagged PDF reads each frame in one `readFloat` call (a list stays one `L`).
- **Reflowable EPUB**: `ComposedNode` (`<section class="pt-composed pt-composed-<template>">`, the page
break first), each story a `CalloutNode` with `frame` (`<div class="pt-frame pt-frame-<name>">`, plus
`pt-callout pt-callout-<style>` when the frame has a style); frame CSS only in books that have
composed pages. Decoration dropped.
- **Labels** ride on the VDT as `ResolvedResourceBlock.labels` (`VDTFigureLabel`: text, at, style,
source range). Reflowable EPUB: a visually hidden `<ul class="pt-figure-labels pt-hidden">` after the
picture, no `aria-describedby` (read once). HTML `alt` and PDF `Alt`: `altWithLabels` (`Alt (A, B)`,
`(A、B)` for CJK, `،` for Arabic).
What #1045 changed (the screen viewer):
- **The leaf.** A host whose page is not a leaf sets `composedViewerLeaf` (the print page's size and
margins, resolved as `ResolvedConfig.composedViewerLeaf`; never saved with a document). `build.ts`
gives each page a composed page or spread side is laid on the leaf's geometry first (`toLeaf`:
size, content area, columns, flow frame), and `designFramesOn` and `headerFooter.ts` read the leaf's
trim and bleed for those pages (`composedLeafConfig`, `onComposedLeaf`). Frames, groups and
decoration therefore stand exactly where print puts them; the pages of the flow keep the host's
page. Before, the Sandbox's HTML viewer laid templates on its own page (a measure wide and 200,000 px
tall in the vertical scroll, a screen high in the columns view): frames anchored to the foot landed
a scroll away, frames ran past the page's width, and the columns view clipped what passed its foot.
- **The Sandbox.** The viewer asks for the leaf when the book has a `::::page` fence and shows each
composed page scaled to the viewer's width (never above the leaf at the viewer's dpi) with a
transform inside a box of the scaled size (`fitComposedPages`): in the vertical scroll the page is
the leaf's height (`fitPagesToContent` keeps it), in the columns view a page taller than the screen
scrolls down in a box of its own, and next/previous step by the page at the leading edge. CSS `zoom`
was tried first: Chrome clips the lines of a vertical page's turned flow under it.
- **Labels as text.** `labelPlainText` reads a label's forced breaks (`\\` ending a word) as spaces
in `altWithLabels` and the reflowable EPUB's hidden label list.
## 9. Literals allowed in the config
The `contentInConfig` whitelist (§2.8): book title, author and publisher (from metadata or
`checks.contentInConfig.allow`), and per element at most two words / 24 characters after removing
placeholders. Decoration text elements follow the same rule.
## 10. Answers to the open questions of #847
<table>
<tr><th>Question</th><th>Answer</th></tr>
<tr><td>Frame style: callout styles or new?</td><td>Callout styles (§2.4).</td></tr>
<tr><td>Position units</td><td>mm (any <code>Dimension</code>), grid cells, or anchored to a frame/group (§2.2).</td></tr>
<tr><td>Variants</td><td><code>variants.recto/verso</code> patches; <code>spread</code> templates (§2.1).</td></tr>
<tr><td>Header/footer</td><td>Per template, inherit by default, <code>false</code> hides.</td></tr>
<tr><td>Decoration with text?</td><td>Only placeholders and whitelisted literals.</td></tr>
<tr><td>Pushes other frames' text</td><td><code>exclude: 'push'</code>, rectangular, as <code>wrap</code> today; by the pictures' contour with <code>wrap: 'contour'</code> (P11, §5.12).</td></tr>
<tr><td>Flow frame and chaining</td><td><code>flow</code>, <code>next</code>, <code>overflow.continueIn</code>.</td></tr>
<tr><td>Fence syntax vs comics</td><td>Four-colon <code>::::page{template}</code>; <code>:::page{split}</code> unchanged (§3.1).</td></tr>
<tr><td>Geometry in Markdown?</td><td>Never; only <code>at="x% y%"</code> as a label fallback.</td></tr>
<tr><td>Determinism</td><td>Fixed ladder order and step sizes, template-order ties (§5.4).</td></tr>
<tr><td>Scope of a change</td><td>Its page or spread, plus continuation pages (§5.4, §6).</td></tr>
<tr><td>Rotation</td><td>Any angle per frame (content and box), drawn with a matrix in every backend as comic balloons are.</td></tr>
<tr><td>Comics</td><td>The split generates frames; text panels and box styles (P12, §7.1).</td></tr>
</table>
## 11. Issue map
P2 #853 config types/validation (§2) · P3 #854 parser (§3) · P4 #855 layout (§5.1–5.2, frames,
decoration, backends) · P14 #856 groups (§5.3, log) · P16 #857 source map and page-scoped relayout
(§6) · P5 #858 ladder and warnings (§5.4, §5.7) · P15 #859 continuation (§5.6) · P6 #860 anchors
and labels (§2.6, §3.3) · P7 #861 spreads (§2.1) · P9 #862 HTML/EPUB (§8) · P11 #883 contours (§5.12) ·
P12 #885 comics on frames (§7, §7.1).
## 12. Round 2 (#1051): lines, shapes, repeats, modules and overlap checks
Design of #1052 (C1). Status: approved. Draft types: `round2Types.ts` (no runtime effect; each issue
moved its part into `types.ts` / `composed/types.ts` and deleted it from the draft; C6, the last part
left, removed the file).
Each issue records what it built in a §12.x of its own, as §5.x did for round 1.
Why: a port of a 28-page children's textbook (checked privately) could only draw the lines of its
tree diagrams, its org chart, its timeline spread and its map notes inside a raster ground per page,
needed 27 one-off templates for 28 pages, drew its card and tabs into the same grounds, and passed
`check` with a clipped caption and a tab under a photo.
### 12.1 One vector primitive: `VDTDesignPathBlock`
Connectors (C5, C6), path elements (C9) and the shapes of C8 that a rounded rectangle cannot draw (a
card joined to its tab) all paint through one new design block, so each backend learns one thing:
```ts
interface VDTDesignPathBlock extends VDTDesignBlockOrigin {
kind: 'path'
bbox: BoundingBox // union of the ink, for hit tests and clips
subpaths: VDTPathCommand[][] // page px; M L C Q Z (absolute)
fill?: string // resolved colour; absent = no fill
fillRule?: 'nonzero' | 'evenodd'
stroke?: { color: string; widthPx: number; dash?: number[]; cap?: 'butt'|'round'|'square';
join?: 'miter'|'round'|'bevel' }
markers?: { kind: 'dot' | 'arrow'; x: number; y: number; angle: number; sizePx: number; color: string }[]
}
```
Canvas (`Path2D`-free, plain `moveTo/lineTo/bezierCurveTo`), PDF (`m l c h f/S/B`, dash `d`), HTML/EPUB
(inline `<svg>` positioned like a rule), tagged PDF (Layout artifact), preflight (ink colours),
contrast (ignored: not text), part palettes (`partPalette.ts` recolours `fill`/`stroke`/markers like a
rule's colour). It landed with C4 (#1055, §12.13) ahead of C5; C5, C8 and C9 reuse it.
`VDTDesignBoxStyle` gains `borderRadiiPx?: [tl, tr, br, bl]` and `borderSides?: { top, right, bottom,
left: { color, widthPx } | null }` (C8); a box with neither paints as today.
### 12.2 Anchoring to a point of a side (C4)
- `ElementSizeTo.edge` adds `'center'`: the middle of the target box on the axis being sized
(horizontal centre for a width, vertical centre for a height).
- `ElementAnchor.at?: 'start' | 'center' | 'end' | number`: on a side edge (`top`, `bottom`, `left`,
`right` and the `-start`/`-end` forms read as their side) of a `'#frame'`/`'#group'`, the point along
that side (a number is a fraction, 0 at the side's start in the page's line direction). Default: the
edge's own point (today's behaviour). Ignored on the container, page and bleed.
- Both are read by every design slot (composed decoration, header/footer, heading designs).
### 12.3 Connectors (C5)
A decoration element of composed templates (not of running heads: they have no frames):
```ts
interface DesignConnectorElement {
kind: 'connector'; id: string; parity?; pages?; pin?
from: ConnectorEnd // one end
to: ConnectorEnd | ConnectorEnd[] // one or more; '#card[*]' expands to every instance (§12.6)
routing?: 'straight' | 'elbow' | 'tree' // default 'elbow'
radius?: Dimension // corner radius of elbow/tree bends, default 0
trunk?: number // tree/elbow: where the bar runs between the ends, 0..1, default 0.5
color?: ColorValue // palette-linked like rules; default the main colour
width?: Dimension // default 0.75 pt
dash?: Dimension[]
startCap?: 'none' | 'dot' | 'arrow'; endCap?: 'none' | 'dot' | 'arrow' // default 'none'
capSize?: Dimension // default 3 × width
}
interface ConnectorEnd {
to: `#${string}` // frame, group, frame instance '#card[2]', module frame '#tree[1].left'
side?: 'top' | 'bottom' | 'left' | 'right' | 'start' | 'end' // default: the side facing the other end
at?: 'start' | 'center' | 'end' | number // default 'center'
gap?: Dimension // room between the frame's edge and the line, default 0
anchor?: string // C6: a figure anchor id of the picture the frame holds
}
```
- **Routing.** `straight`: one segment. `elbow`: horizontal–vertical–horizontal between left/right
sides, vertical–horizontal–vertical between top/bottom sides, an L when the sides are perpendicular;
the bar at `trunk` of the way. `tree`: one parent (`from`) and children (`to`): a trunk from the parent
to the bar at `trunk` of the gap between the parent's side and the nearest child, the bar across the
children's points (and the parent's, when it is outside them), one drop to each child. A child whose
side point is on the bar's line gets no drop.
- **When.** Laid with the decoration, after the frames' final boxes (§5.8), so it follows groups, the
ladder and continuation. `pin: true` reads declared boxes. An end on a frame that is collapsed, empty
and optional, missing on this page, or on the other page of a spread with no `crossGutter` path:
the end is dropped (a tree keeps its other children) and the reflow log says `connectorDropped`
(info). Every end dropped: nothing is drawn.
- **Paint.** Decoration layer (0), under the frames; a `VDTDesignPathBlock` with markers. Spreads:
laid on the sheet like the rest of the decoration (§5.10), split per page by `splitSpreadDecoration`
(a path is clipped by the page's `sheetClip`). Right-to-left pages: `start`/`end` follow the flow;
the geometry comes from mirrored frame boxes, nothing else to mirror.
- **Validation.** `frameAnchorUnknown` for an unknown `to` (pointer into `from`/`to`), `unknownConfigValue`
for routing, caps, side, `at` outside 0..1.
### 12.4 Leaders to picture anchors (C6)
- A `ConnectorEnd` with `anchor` ends at that anchor of the picture its frame holds (the first
`:::figure`/`::resource` of the frame's story that has the anchor), through crop, safe area and fit
scale as labels are mapped (`pictureToBody`, `composed/labels.ts`). Unknown anchor:
`labelAnchorUnknown` with `connector: id`.
- Labels: `ResourceAnchor.labelAt?: { x: number; y: number }` (fractions of the picture) sets the label
away from its point, and `leader?: boolean | LeaderStyle` draws a line from the label's box (nearest
side, centre) to the point; `LabelStyle.leader?: LeaderStyle` gives the look:
`{ color?, width?, dash?, startCap?, endCap? }` (default end cap `'dot'`). In Markdown,
`::label[…]{at="legend" leader}` turns it on for one label; `leader="none"` off.
- Painted with the figure's labels (after the picture), as path blocks in the figure's label layer;
HTML/EPUB as SVG in the figure; the alt text is unchanged.
### 12.5 Legends and inline symbols in labels (C7)
- `:icon[alt]{resource="id"}` (a bitmap or SVG resource) and `:icon[alt]{glyph="⊙" font="…"}` and
`:icon[alt]{line="solid|dashed|dotted" color="…"}` (a line sample), inline in label text: sized to the
line's cap height by default (`size=` em), tinted with `color=`. Allowed in labels and, since the
label line setter is the caption setter, in captions; not in body text in this round.
- Labels keep authored line breaks (a hard break in the label's Markdown, two trailing spaces or `\`
at the end of a line inside a `::label` container form), besides `\\`.
- `LabelStyle` gains `align?: 'start' | 'center' | 'end'` (the lines inside the label box), `border?:
{ color, width }`, `radius?`, and `kind?: 'legend'`: a legend is a label whose lines are rows of symbol
+ text aligned on the symbol column (the first `:icon` of each line hangs in a column as wide as the
widest symbol).
- Scale bars stay artwork; their numbers are labels on anchors along the bar.
### 12.6 Repeating frames (C10)
```ts
interface FrameGroupConfig {
kind?: 'stack' | 'row' | 'grid'
columns?: number; columnGap?: Dimension; rowGap?: Dimension // grid
rowAlign?: 'start' | 'center' | 'end' | 'stretch' // grid: cells in a row, default 'stretch'
repeat?: { frames: string[]; max?: number } // grid: the frame set laid per cell
}
```
- The first frame of `repeat.frames` is the cell's **main** frame: the set is laid in each cell with the
main frame taking the cell's width and the other frames anchored as the template draws them (to the
main frame or to each other; anchors to frames outside the set are kept).
- **Items.** Each `:::frame{slot="<main>"}` piece of the page is one item (pieces no longer
concatenate for a repeated slot). Inside an item, a block with `slot="<member>"` (a member of the set)
goes to that member's instance; the rest goes to the main frame. One `::resource{slot="<main>"}` (or
`slot="<member>"`) outside a container is also an item of its own.
- **Instances** are named `<frame>[n]` (1-based) for the main frame and every member of the set:
`#card[2]`, `#caption[2]`; `[*]` in a connector's `to` expands to all. Warnings, reflow log, source map, `VDTFrame.id`
(`cp<fence>:<frame>[n]`) and the Sandbox use the instance name.
- **Layout.** Cells fill rows start to end; a row is as tall as its tallest cell (`rowAlign` places
shorter ones); `max` caps the items, the rest is `slotUnknown`-like (`repeatOverflow`). The grid is one
group step (§5.9); `grow`/`shrink` apply to rows. Ladder: a grid that does not fit its box first runs
the ladder per row (text, then pictures, per instance), then continues **by whole rows** when the
main frame has `overflow.continueIn` (the instances that did not fit go to the receiving page's grid),
else warns `frameOverflow` on the last instance.
- Resolution: `resolvePageTemplate` stays per template; the instances are made in `composePage` from the
page's item count (`expandRepeats`, before geometry), so the template cache is unchanged.
### 12.7 Modules (C11)
- A template with `role: 'module'` is a **module**: frames, groups, decoration, connectors; no `area`,
header, footer, variants or spread; never used by a page fence (`moduleAsPage`).
- A page template asks for modules in a group: `groups: [{ name: 'body', kind: 'stack', modules: true }]`
takes the page's modules in Markdown order; `:::module{template="arbol-3" name="?"}` (three colons,
inside a page fence) holds that module's content, its blocks addressed by the module's slot names.
- **Instances** are scoped: `<name>.<frame>` with `name` from the fence or `<template>[n]`; anchors and
connectors inside a module are rewritten to the instance; a module frame anchored to `'container'` reads
its module box. A module is a box of the group: as wide as the group, as tall as its content (its
frames' union), with the group's `gap` between modules.
- **Expansion.** Like repeats, modules are expanded per page in `composePage` into ordinary frames, groups
and decoration (`expandModules`) before geometry: everything after (groups, ladder, continuation, source
map, paint) sees plain frames. A module that does not fit moves whole to a continuation page when the
page's module group continues (`overflow.continueIn` on the group, new), else the ladder runs on its
frames.
- `extends` works between modules; a module may hold grids (§12.6); a module inside a module is not
supported in this round (`moduleNested`).
### 12.8 Shapes (C8, C9)
- Callout/frame styles and `ElementBoxStyle`: `borderRadius` takes a `Dimension` or `{ topLeft?,
topRight?, bottomRight?, bottomLeft? }`; `border` (callouts) / `borderSides` (decoration boxes) take
per-side `{ enabled?, color?, width? }` over the whole border's values.
- **Joined tab** (`CalloutLabelConfig.joined?: boolean`, `fillet?: Dimension`): the tab and the box are
one outline: the tab's side corners meet the box with concave fillets (default the box radius), the
box's border runs round the tab, one fill. Works for every `position`; with `offset` 0 only (a joined
tab sits on the edge).
- **Path element** (`DesignPathElement`, every design slot): `{ kind: 'path', id, placement, d, viewBox?:
[w, h], fill?, stroke?, width?, dash?, preserveAspectRatio?: 'none' | 'meet' }`: SVG path data in the
`viewBox` (default `[1, 1]`: fractions of the box), stretched to the element's box (`'none'`, default)
or fitted. Colours palette-linked. Bad path data: `unknownConfigValue` and nothing drawn.
### 12.9 Glyph tab icon (C3)
`CalloutLabelConfig.icon` gains `kind?: 'resource' | 'glyph'` (default `'resource'` when
`resourceId`), `glyph`, `fontFamily`, `fontWeight`, `scale` (of the tab's text size, default 1) and
`color` (default the label `color`). `placement` and `gap` as for pictures. Tagged PDF: the glyph is an
artifact; HTML `aria-hidden`.
### 12.10 Overlap checks (C2)
- `frameCovered` (warning): a line of a frame (its ink box) or a picture block lies under the box of a
frame painted later (higher `layer`, or the same layer later in template order) that has a fill, a
picture or text over that point; or under a decoration path/box painted later (none today, decoration
is below frames). Data: page, template, `frame`, `coveredBy`, overlap mm², the first covered words.
Proposals: `{ kind: 'layer', path: …/coveredBy/layer, value }`, `{ kind: 'exclude', path:
…/coveredBy/exclude, value: 'push' }`, `{ kind: 'move' }` (data only).
- `frameOutsideArea` (warning): a frame's final box (ink, for an auto frame) runs past the trim (bleed
for `area: 'bleed'` templates) or past the box of the frame or group it is anchored *inside*
(a nine-point anchor on `#x` with an offset into it). Proposals: `maxHeight`, `minHeight` of siblings,
`continueIn`.
- `FrameConfig.overlap?: 'check' | 'allow'` (default `'check'`): `'allow'` on the covering frame (art
meant to sit over text) or on the covered one silences the pair.
- Both are content warnings of the layout with `WARNING_SINCE` the release that ships C2, listed by
`postext check`, the Sandbox Checks panel (U13) and `sandbox_get_warnings`.
### 12.11 Order, versions, outputs
- `CONFIG_VERSION` 15 → 16 with C5 (the first issue that adds a decoration kind): no migration; older
readers drop unknown decoration elements with `unknownConfigValue`.
- Every engine issue paints on canvas, PDF, fixed HTML/EPUB and tagged PDF, keeps reflowable EPUB/HTML
unchanged (decoration is not reflowed), updates `preflight.ts`, `partPalette.ts`, the source map
(`sourceAt` hits paths like rules: never over a frame), the JSON validation and the warnings docs list.
- Issue map: C2 #1053 §12.10 · C3 #1054 §12.9 · C4 #1055 §12.2 · C5 #1056 §12.1, §12.3 · C6 #1057 §12.4 ·
C7 #1058 §12.5 · C8 #1059 §12.1, §12.8 · C9 #1060 §12.8 · C10 #1061 §12.6 · C11 #1062 §12.7.
### 12.12 What C3 built (#1054)
Recorded where the implementation settles a point §12.9 leaves open, or departs from it.
- **Types.** `CalloutLabelConfig.icon` is `CalloutLabelIconConfig` (exported, with
`CalloutLabelIconKind`); the draft's icon part left `round2Types.ts` (its `joined` / `fillet` stay
for C8). The resolved icon always carries `kind`, `glyph`, `fontFamily`, `fontWeight` and `scale`;
`color` is absent unless set.
- **Kind.** `kind` as written when it is `'resource'` or `'glyph'`; unset (or another word), a glyph
when the icon sets `glyph` and no `resourceId`, else a picture. So a picture icon with a stray
`glyph` stays a picture; `kind: 'glyph'` wins over a `resourceId`.
- **Defaults.** Face and weight: the label's (`label.fontFamily`, else the headings face;
`label.fontWeight`, else 700). `scale` of the tab's text size, 1; a value that is not a positive
finite number reads as 1. `color` is read when the tab is laid out: unset, the label's `color`, so a
frame's `look.label.color` recolours the glyph with the words.
- **Geometry.** The glyph's box is its advance at `fontSize × scale`. `inside`: at the tab's start
(`paddingX` in, the right end in a right-to-left box), the words `gap` after it; the tab grows by
advance + gap. `beside`: `gap` past the tab's edge on the side away from the corner; the label rule
stops at the glyph's outer edge. Picture `width` and `tint` do not apply. The baseline is the tab's
middle plus 0.36 of the glyph size, the formula of the words: at scale 1 the glyph shares their
baseline, at other scales it stays centred on the tab. `gap` is measured as the picture's (the box's
em), and `keepChromeAt` (a frame at `scale`) does not scale it, as it does not for pictures.
- **Outputs.** The glyph is a design text block of the box overlay with `artifact: true`; no new VDT
field. Canvas paints it as any design text; the PDF draws it as text in the glyph's face, a layout
artifact in a tagged PDF (the tab's tagged content is its words); HTML and fixed EPUB set it in a
`<div aria-hidden="true">`; the reflowable EPUB leaves it out of the box's title (it reads only
non-artifact overlay text). Text extraction of the PDF (pdftotext, copy) still sees the glyph, as it
sees a repeated callout title: no empty `/ActualText` over it.
- **Checks.** `unknownConfigValue` for `label.icon.kind`, `placement` and `scale` (with what is read);
`unknownConfigKey` for a key `label.icon` does not have. A glyph the face lacks: the PDF's existing
`missingGlyph` warning (the glyph goes through the same text primitives); canvas and HTML fall back
as the browser does. The glyph's family joins `configFontFamilies`; `label.icon.color` is a text
role for the part palette.
- **Not done.** Sandbox controls (U13) and docs (D3). A frame `look` has no glyph colour of its own
(it recolours the glyph through `label.color` only).
### 12.13 What C7 built (#1058)
- **Inline symbols.** `:icon[alt]{…}` is read by `extractInlineSwatches(…, { icons: true })` and
rides the swatch's placeholder (`SWATCH_PLACEHOLDER`, `InlineSpan.swatch.icon`, `VDTLineSegment
.swatch.icon`): every pass that keeps a swatch whole (bidi, tracking, Knuth–Plass, the CJK composer,
the measure cache, which now keys on the icon) keeps a symbol whole, and a painter that does not
know icons paints a swatch's outline. Kind by the first attribute written of `resource`, `glyph`,
`line` (an unknown `line` value reads `solid`); none of them: the directive stays text. `color` is a
hex or a palette id (`resolveSwatchSpans`, which also fits a resource icon's picture: bitmap or SVG,
its aspect, square for an SVG with no size; any other resource or an unknown id paints an outline).
- **Where it is read.** A figure label (`::label[…]`, `:::label`), and every resource snippet:
captions, notes and table cells (`parseInlineSnippetSpans` reads them always, so the Sandbox's
snippet map agrees with the measurer). Not the body text, headings or design texts in this round.
- **Size** (`measure/inlineIcon.ts`). The box stands on the baseline; height `size` ems, default
`ICON_CAP_EM` 0.75 (a swatch's side). A picture keeps its proportions; a glyph is set at `height /
0.75` px (its capitals as high as the box) in `font` (else the text's face with its size changed) and
takes its advance; a line sample is twice as long as high, stroke 0.09 em (1 px at least), dashed
3:2 stroke widths, dotted as round dots two widths apart. `size` is the height for every kind (the
design said "em" without saying of what).
- **Kept lines.** `:::label{at=… style=…}` … `:::` inside a `:::figure`: every non-blank source line is a
line of the label (departure: the design asked for a hard-break mark at each line end; in a label
each line is meant as one, so the mark is optional and dropped when present). Lines are read one by
one and joined with `BREAK_PLACEHOLDER`, mapped to the end of the earlier line; `\\` still breaks
inside a line. The first `:::` closes the label, the next the figure. `::label[…]` stays one line.
- **`LabelStyle`.** `align` lines the lines up inside the box (in the label's direction); left out, as
the anchor places the box (the old behaviour), `start` in a legend. `border` and `radius` make the
plate (`background` in the VDT, its `color` now optional, `border`, `radiusPx`), painted as a
design box (outline inside the plate). `kind: 'legend'` (`layout.legend`): the symbol that opens a
row (its first non-space segment) has its segment widened to the widest such symbol and is centred
in it (`VDTInlineIcon.insetPx`, `hangLegendSymbols`), so the words of the rows start on one edge;
a row without one is left as it is. Not in vertical labels. Direction of a label is read past the
placeholders, so an Arabic row that opens with a symbol is right to left.
- **Scale bars.** Nothing new: the bar is artwork, its numbers are labels at anchors along it.
- **Reading.** `VDTFigureLabel.icons` (placeholder index + alt): `labelPlainText` / `altWithLabels`
(undrawn labels, reflowable EPUB list) read `[alt]`, nothing for an empty alt; `figureLabelReading`
joins a legend's rows with `; `. Tagged PDF: a legend is `L` › `LI` › `LBody` per row (`ListNumbering
None`) in the figure's label `Div`; a symbol with alt is a `Span` with `/ActualText [alt]`, its glyph
under an empty `/ActualText`; one without is a layout artifact. HTML: `role="img" aria-label`.
- **Outputs.** Canvas `paintInlineIcon` (pictures through the image registry, glyphs through
`fillContextText`); PDF `paintSwatchSegmentPdf` (`PageCtx.images` for the pictures, which
`preloadResourceImages` embeds from `resourceBlockIcons`, tinted ones with their stencil; glyph faces
join the font collection); fixed HTML/EPUB an inline SVG per glyph or stroke, an `<img>` (a mask over
the tint) per picture; reflowable EPUB glyphs and line samples inline (styled in place, the book's
stylesheet unchanged), a picture as `[alt]`. Preflight counts the plate's outline and the symbols'
inks; the part palette recolours the outline (`label:border.color`). The glyph faces a text names join
`configFontFamilies` / `collectFontUsage` (`iconFontFamilies`).
- **Checks.** `unknownConfigValue` for `align` and `kind`, `unknownConfigKey` inside `border`.
- **Fixture.** `__tests__/composed/fixtures/legendMap.ts`: a political map's key (country ■ in a
palette colour, capital ⊙, town picture, dashed border), the capital's name with its ⊙ and the
scale's numbers, in English, Spanish and Arabic (the Arabic key at an anchor whose box grows the
other way). Parity: VDT hashes of six presets unchanged.
- **Not done.** Symbols in body text; a picture symbol in the reflowable EPUB (read as its name); a
content warning for an icon naming an unknown resource; single ink on the canvas for SVG symbols (the
PDF recolours them, as every SVG it embeds); turning back a picture symbol in the HTML of a mirrored page is unchecked. Sandbox (U11),
docs (D3) and the skill have their own issues.
### 12.14 What C4 built (#1055)
- **The vector block.** `VDTDesignPathBlock`, `VDTPathCommand`, `VDTPathStroke`, `VDTPathMarker` in
`vdt.ts` as drafted (§12.1), a fifth member of `VDTDesignBlock`. Shared helpers in
`design/pathBlock.ts` (exported): `pathMarkerOutline` (a dot is a circle `sizePx` across; an arrow a
triangle whose tip is the point, `sizePx` long along `angle`, its base 0.7 × `sizePx` wide),
`pathSvgData` / `markerSvgData`, `quadToCubic`, `pathInkBox` (points and control points grown by half
the stroke, plus markers: for hit tests and clips, not a tight bound), `offsetPathBlock` /
`translatedPathBlock`, `pathBlockColors`, `recolorPathBlock`, `pathHit`.
- **Painters.** Canvas `renderPathBlock` (moveTo/lineTo/bezierCurveTo/quadraticCurveTo, `fill(rule)`,
then `stroke` with width, `setLineDash`, cap, join; markers filled on top). HTML: an inline
`<svg aria-hidden="true">` placed on `bbox` with `overflow:visible` (a mitre past the box still
shows), the path in the box's own coordinates; fixed-layout EPUB takes the same markup. PDF
(`pdf-backend/pathBlock.ts`): `m l c h`, a quadratic as its cubic, `f`/`f*`, `S`, `B`/`B*`, `w`, `J`,
`j`, `d` (scaled to points), alpha through the shared ExtGState; a dot is four cubics, an arrow a
triangle. Tagged PDF: the slot's artifact like a rule (a Layout artifact on decoration).
- **Other consumers.** `offsetDesignBlock` moves the outline and markers with the box (so spreads'
`splitSpreadDecoration`, callout overlays and `movedCopies` follow); so does the Sandbox's float shift
(`HtmlPreview/pageGeometry.ts`, the one other hand-written mover). Preflight: the stroke is a rule
(`thinRule`, coverage), the fill and the markers are ink (coverage, `nearTrim` for a fill). Part
palettes (`recolorFrame`, a box frame's overlay): the fill as the frame's fill; stroke and markers as
its border when the path is filled (a shape), as its rules otherwise (a line). The source map answers
on the ink only (`pathHit`: fill by its rule, stroke within half its width or 2 px, markers), so the
empty box of an elbow never takes a click; under a frame it never wins, as rules. Contrast: ignored.
No `never`-checked switch over design kinds exists; the three painters' final `else` were the only
compile errors, every other consumer was found by search.
- **Side points.** `ElementAnchor.at` and `ElementSizeTo.edge: 'center'` in `types.ts`; `SidePosition`
moved out of the draft. Resolution in `layoutDesignSlot` (`alongSide`), so composed decoration,
running heads, heading and part designs and box designs read it alike. Decisions the design left open:
- Along the side the element is **centred on the point** (pin `middle`) for every `at`, `'start'` and
`'end'` included; across the side the edge keeps its pin (`below` hangs under the foot, `bottom`
stands inside on it). A vertical rule's thickness so straddles a frame's middle with no offset.
`'bottom'` + `at: 'start'` is therefore centred on the corner, not `bottom-left`.
- `at` applies to an anchor on another slot element as well as on a frame or group (`'#name'`);
ignored on `container`, `page`, `bleed`, `outer` and `center`.
- The element edges read as their side: `below`/`above`/`align-top`/`align-bottom` horizontal,
`right-of`/`left-of`/`align-left`/`align-right` vertical; corners and `-start`/`-end` forms as
theirs (logical edges are made physical first). A horizontal side starts on the right when the slot
starts there (`startRight`: right-to-left and vertical sheets); a vertical side always at its top.
- A number is held in 0…1; a word other than the three is ignored.
- **Validation.** `collectConfigWarnings` walks every design slot (`configDesignSlots`):
`unknownConfigValue` at `<slot>.elements[i].placement.anchor.at` (a bad word: `used: ''`, with a
suggestion; a number outside 0…1: `used` the held value) and at `…placement.size.<axis>.edge`
(`used: 'fill'`).
- **Not done.** Frames' own placements (`composed/geometry.ts` `anchorOn`) do not read `at`: the design
names design slots only. No path *element* yet: C5 (connectors) and C9 (path elements) produce the
block; `applyPaletteOverrides` and `primitiveToBlock` gain their path branch with them. Docs (D3) and
the Sandbox fields for `at` and `'center'` belong to their own issues.
### 12.15 What C10 built (#1061)
- **Config.** `FrameGroupConfig.kind: 'grid'` with `columns` (default 1), `columnGap` / `rowGap` (default:
the group's `gap`), `rowAlign` (default `'stretch'`) and `repeat: FrameRepeatConfig { frames, max? }`
(`composed/types.ts`). `normalizeGroup` adds the grid fields only to a grid, so every other group
resolves to the same object as before (page memo keys and `layoutKey`s of templates without grids do
not move). A repeating grid's `frames` are its set (`repeat.frames`); written `frames` are not read.
A grid that repeats nothing lays the frames it lists, one per cell.
- **Validation.** Repeating grids claim their frames before the other groups: a repeated frame listed
in another group is `groupFrameTwice` there (the repeat keeps it), a frame the template lacks
`groupFrameUnknown` at `…/repeat/frames/<k>`; `columns < 1`, an unknown `rowAlign` and `repeat.max < 1`
are `unknownConfigValue`. Anchors (frames and decoration) to `#<frame>[n]` of a repeated frame are
valid. No new config warning kinds.
- **Items** (`composed/repeat.ts`, `pageRepeats`). `composedPages` records every slotted piece of a
page in Markdown order with the slotted pieces one level down inside a `:::frame`
(`ParsedComposedPage.pieces`). Each piece sent to a frame of a set is one item (a top-level
`::resource{slot="<member>"}` too, as §12.6 says); inside it a block or container naming another
frame of the set goes to that instance (a nested `:::frame{slot="<member>"}` without its markers); a
block naming a frame outside the set stays with the piece, as before. Items past `max` go to the flow
frame (else after the page) with the new content warning `repeatOverflow` (`template`, `group`,
`frame`, `item`, `max`, `flowFrame?`, at the item's `slot="…"`), one per item over. A repeated frame
is never the flow frame and takes no heading (`takes` is ignored on it). `slotEmpty` for a frame of
a set only when no item fills it.
- **Stories** (`wrap.ts`). One story per instance, `FRAME_ATTR` = `<frame>[n]`, dressed by its own
item's `:::frame` fence (`label`, `title`), its figure labels' ids rewritten to
`cp<n>:<frame>[n]:label:<anchor>`. A chain (`next`, `continueIn: '#x'`) among the frames of a set
threads the item's text through its own instances; a link out of the set is not followed.
- **Expansion** (`expandRepeats`). In `layoutComposedPage` (the entry of single pages, spreads,
continuation pages and `useTemplate` trials), from the items its stories hold (`repeatItemsOf`), not
in `composePage`: the ladder then works on instances too. Instances replace the set in template
order, item after item, main first; they copy the frame (`configPath`, `fieldPaths` unchanged) with
anchors, `next` and `continueIn: '#x'` inside the set rewritten to the same item (links out of the set
dropped). Frames and decoration outside the set anchored to a frame *of* the set read the grid's box
(`#<group>`). The grid gets `frames` = its instances and `items` (cells). On a continuation page the
items are those it receives, numbered as on the fence's page.
- **Geometry** (`placeGrid` in `composePage`, one group step). The grid's box is its own
`placement`/`grid`; without one it starts where the template draws the main frame (its own
placement, kept on the instances for that) and runs to the area's right edge and foot. Columns are
equal; cells fill rows left to right in flow coordinates, which on a right-to-left page (mirrored
flow) is right to left. The main frame takes the cell's width and its own height rule (`'auto'`:
the content's, min/max applied). A member anchored in the set is placed with the cell's column as its
area (`'fill'` and percentages are the cell's); one anchored to nothing is moved with the cell (the
set as drawn, translated); one anchored outside the set stays where that anchor puts it and does
not count for the cell. A row's height is the most any cell reaches above its main frame (a tab) plus
the most any reaches below its top (a caption pill hanging under it); mains of a row share a top.
`rowAlign`: `start`/`center`/`end` move shorter cells, `stretch` grows an `'auto'`/`'fill'` main frame
to the row (up to `maxHeight`). Grid cells give no reflow events (declared = laid). The grid's box for
`#<group>` anchors is its own box, else the union of the laid cells.
- **Overflow and ladder.** A row past the grid's foot is cut there (every rect clipped to the foot); the
ladder runs per instance (free, push, pictures of the instance and its own item's frames, text);
`redistribute` does not apply to grids. Clipped rows warn once: `frameOverflow` on the last instance
cut, in template order. With the main frame's `overflow.continueIn` off the page, the continue step
(taken for any instance, by the main frame's `continueIn` and `fit.preferContinue`) adds the grid to
`FitState.gridsOn`: the first row that does not fit and every row after it are not laid; each of
their instances that holds a story spills whole (`Spill.whole`), and becomes a
`ComposedContinuation` with `whole: true`, the main frame's rule, and the item's empty instances as
`trailing`. `assignReceivers` sends an instance continuation to the instance of its own name on any
template that repeats that frame. On a received page the step is reverted when no row fits
(`advanced` = a row was laid), so a cell taller than the box warns instead of going on for ever.
`link` does not set `continuesTo` for whole items (nothing of them is on the sending page) and the
receiving instance has no `continuedFrom`; the sending page's log gets a `continued` event per
instance (`toPage`, `toFrame`).
- **VDT, ids, source map.** `VDTFrame.name` is the instance name, `VDTFrame.repeat = { frame, item }`,
ids `cp<fence>:<frame>[n]` (`~<k>` on continuation pages). `sourceAt`/`findElement` treat an instance
as a frame of its grid (`frame.repeat.frame` in the group's frames). Spread sides keep the laid
record's order. The page memo keeps grid pages (keys unchanged: the expansion is a function of the
template and the page's content).
- **Decoration.** `#card[2]` resolves through `DesignFrames.named` (instance names); an element
anchored to an instance the page lacks is not drawn (as for a collapsed frame).
- **Parity.** Templates without grids lay out byte for byte as before: bench-layout hashes of five
presets equal, composed-page recipes unchanged (see the report of #1061).
- **Not done.** `grow`/`shrink` on rows and a per-row ladder (scaling a whole row's pictures or text
together); `fitBox` (spread gutter) on grid cells; a chain from a frame outside the set into an
instance (`next: 'card[2]'` is still `overflowTargetUnknown`); a chain inside a cell whose later link
has an `'auto'` height measures empty when the grid is solved (as for groups, §5.9); connectors'
`[*]` (C5); Sandbox (U12), docs (D3) and the warnings docs list.
### 12.16 What C2 built (#1053)
- **Where it runs.** `composed/overlap.ts` (`overlapChecks`, pure), called once at the end of
`layoutComposedPage` on the composition the ladder settled on (step 8, after `frameOverflow`), with the
groups' final boxes (`Composition.groupRects`, new). A spread is checked on the whole sheet before
`layoutComposedSpread` cuts it, so the facing copies never take part; `spread.ts` moves each warning to
the page that holds its frame, as for `frameOverflow`. The page memo keeps the warnings with the layout.
Every box goes to the sheet by its frame's `frameMatrix` (scale, turn, own writing mode) and is compared
as the axis-aligned box holding it; a frame whose content overflows is clipped to its box first.
- **What covers, what is covered.** A frame covers with its overlay boxes that have an opaque background
(the style's or `look`'s fill, the tab, a stripe, a code highlight; alpha under 50 % does not count; an
outline alone does not cover), its overlay pictures (icons), its pictures (`bodyRect` of a bitmap, SVG or
video) and its text lines. What is covered: body lines (`VDTLine.bbox`), caption and note lines, overlay
text (tab, title, marks: a box from the font size round the baseline), pictures, tables. Departure:
**words cover words, not pictures** (a line over a photo hides a hairline of it; whether the words read is
the contrast check's), so text set on art is not reported and lowering a picture under text is a fix.
A frame whose pictures push with `wrap: 'contour'` does not cover lines with its pictures' box (the
contour is what keeps them clear). Thresholds: a cut thinner than 0.3 mm on either side is a touch; a
pair under 1 mm² is not reported.
- **`frameCovered`** (one per pair, the covered frame's source range and page): `coveredBy`,
`coveredByPath`, `overlapMm2`, `text` (the first covered words, 40 characters) or `picture`, `locale`,
`proposals`: `layer` (the covering frame's `layer` to the covered one's − 1, proposed only when the
covered frame has nothing opaque under the covering one: else the swap hides the other), `exclude:
'push'` (only when body lines are hit: a caption or a tab does not run round a pushing frame), `move`
(`frame`, `dxMm`, `dyMm`: the shortest shift along one axis that takes the covering ink off the covered
ink; data only; since #1075 only one that lands clear of the page's other frames, §12.22).
- **`frameOutsideArea`.** Departure: instead of one limit by `area`, **text is checked against the trim
and fills and pictures against the bleed** (a band anchored to `'bleed'` on a `'content'` template bleeds
on purpose; text past the trim is cut whatever the area; with no bleed the two are one box). And, for a
nine-point anchor on `'#frame'` / `'#group'` whose anchor point (offset added) lies inside that box
(0.5 px slack), all the frame's ink against that box (`past: 'frame' | 'group'`, `target`). Element edges
(`below`, `right-of`…) and anchor points outside the box are never "inside". Data: `excessMm`, `sides`
(each side's overrun from 0.3 mm), `proposals`: `maxHeight` (the frame's height less the overrun at the
foot, whole mm down; twice the overrun for a frame held by its middle, none for one held by its foot,
which a smaller height does not bring in), `continueNextPage` (with it, for a frame of text with no
`next` / `continueIn`), `minHeight` of the frame it is anchored inside (its height plus the overrun, whole
mm up), and, added to the design, `move` (`frame`, `dxMm`, `dyMm`: the shift that brings it in when it
runs past one side of an axis and is no larger than the box along it; data only), the one fix of a strap
held by its foot below the trim.
- **`FrameConfig.overlap: 'check' | 'allow'`** (resolved as `overlap?: 'allow'`), validated
(`unknownConfigValue`, used `'check'`). Decision: `'allow'` also leaves the frame out of
`frameOutsideArea` (a badge hung off its card on purpose is the same kind of art), and an `'allow'` host
is not checked as the box a frame is anchored inside.
- **Reflow log.** `ReflowEventKind` gains `covered` (`cause` the covering frame, `areaMm2`) and `outside`
(`toMm` the largest overrun, `past`: `'trim' | 'bleed' | '#name'`), merged in template order;
`describeReflowEvent` says «card lies under cutout (25.3 mm²)», «note runs 26 mm past card».
- **Outputs.** `formatWarning` words both with their fixes (`describeFrameCoverProposal`,
`describeFrameOutsideProposal`, exported); `postext check --json` carries the warning's fields as `data`;
`WARNING_SINCE` 1.47.0 for both. Not done here: the Sandbox payloads, labels and `sandbox_get_warnings`
(U13, #1066, which owns the WebMCP coverage of the new kinds); decoration covering a frame of a negative
layer (decoration is laid after the frames); the cookbook's C37 does not fail on the new kinds (a
policy for the coordinator).
- **Fixture.** `__tests__/composed/fixtures/overlap/` (config, Markdown, three placeholder pictures): a
caption under a picture frame on a higher layer, a tab under a cut-out photo, a note anchored inside a
card that runs past its foot, a strap of text below the trim; `overlap.test.ts` and the CLI test read it.
The page-8 stand-in (`fixtures/page8.ts`) now reports its photos hanging 1.3–1.8 mm past the foot of
their boxes; `fit.test.ts` and `groups.test.ts` leave the overlap events out of their reflow
assertions.
### 12.17 What C9 built (#1060)
- **The element.** `DesignPathElement` moved out of the draft into `types.ts` as drafted (§12.8), a fifth
member of `DesignElement`, with `ResolvedDesignPathElement` (`resolvePathElement`: id `path-<n>`,
placement as a box's). Every design slot takes it: composed decoration, running heads (a page
template's too), heading and part designs, contents part rows, box designs. `applyPaletteOverrides`
recolours a palette-linked `fill` and `stroke`; on a composed frame's overlay the block is recoloured by
`partPalette.ts` as C4 left it.
- **Path data.** `design/svgPath.ts` (`parseSvgPath`, `arcToCubics`, `mapPath`, `pathViewBox`; the first
two exported): `M L H V C S Q T A Z`, absolute and relative, implicit repeats (a repeated move draws
lines), compact numbers (`M.5.5`, `-1-1`, exponents), arc flags without separators. `H`/`V` become `L`,
`S`/`T` their full curve with the reflected control point, an arc cubics of at most a quarter turn (SVG
1.1 F.6: radii scaled up when too small, a zero radius a line, the same point nothing). One pass over
the characters, no regular expressions; at most 64 KiB of data. Errors: no initial move, a letter that is
no command, a command short of its numbers, numbers after a close, a flag other than 0/1, empty data.
Parses are kept per data string (256, cleared whole), so a running head's chevrons are read once.
- **Layout** (`layoutPathElement`): the box is sized and pinned as a box element's (a side left out or
`'fill'` fills to the container's edge; `{ to, edge }` sizes and `anchor.at` as for every element), the
outline mapped from `viewBox` units onto it: `'none'` scales each axis, `'meet'` the smaller scale on
both, centred. The primitive keeps the element's box (what other elements anchor to, what a heading
reserves); the block's `bbox` is `pathInkBox` (the ink), so a spread cuts, and the source map answers,
by the ink.
- **Decisions the design left open.**
- Neither `fill` nor `stroke`: filled black, as an SVG path is. `stroke` alone: no fill.
- `width` (default 0.5 pt, a rule's) and `dash` are page lengths, not scaled with the box (a stretched
outline keeps an even line). Caps and joins stay the block's defaults (butt, miter); no `fillRule`
key (non-zero): neither is in the design.
- Bad path data or a `viewBox` that is no pair of positive numbers draws nothing, but the element's box
still stands for the elements anchored to it (a label beside a broken shape stays put).
- Warnings (`collectConfigWarnings`, every design slot): `unknownConfigValue` at `…elements[i].d`
(`value` the data's first 60 characters, `used: ''`), at `…viewBox` (`used: ''`), at
`…preserveAspectRatio` (`used: 'none'`, with a suggestion); `unknownConfigKey` for a key the element
does not have (the only element kind checked so; the others keep their silence). No new warning kind.
- **Outputs.** No backend change: the block C4 landed paints on canvas, PDF, HTML and fixed EPUB, is an
artifact in tagged PDF, and is left out of the reflowable EPUB with the rest of the page furniture. Tests
drive each from a laid-out element (`design/pathElement.test.ts`, the PDF and EPUB packages'
`pathElement.test.ts`).
- **Sandbox.** `PlacementFields`' `Sibling.kind` is `DesignElement['kind']` (the one compile error); a
path element shows in the slot editor with the box's title and no editor of its own: U10 (#1063) adds it.
- **Not done.** No `CONFIG_VERSION` bump (C5 owns it, §12.11). No `fillRule`, caps or joins on the element.
Paths are not rotated, and a path in a vertical page's flow is laid in the flow frame like a box.
### 12.18 What C5 built (#1056)
- **Types.** `DesignConnectorElement`, `ConnectorEnd` and `LineCap` moved from the draft into `types.ts`
(exported); the connector is a member of `DesignElement` (`ResolvedDesignConnectorElement` of
`ResolvedDesignElement`). It has no `placement`, so `PlacedDesignElement` /
`ResolvedPlacedDesignElement` name every other kind; the code that reads `placement` (topological
sort, logical edges, `withoutAnchoredTo`, the repeat expansion, the page-design migrators) skips
connectors. `LeaderStyle` (C6) stays in the draft and imports `LineCap`. `CONFIG_VERSION` 16
(`CONNECTOR_RULES`, no pin: one stored under 15 is read as it is).
- **Where it is laid.** In `layoutDesignSlot`, after every placed element (an end may name another
element of the decoration as well as a frame, group or instance), and only when the context has frames
(`frames.named`): the composed decoration. Painted in array order with the rest (layer 0, under the
frames). Geometry in `design/connector.ts` (`layoutConnector`, pure); the primitive is C9's
`ResolvedPathPrimitive` with `markers` added (→ `VDTDesignPathBlock`). `applyPaletteOverrides`
recolours `color`, so part palettes and `:::part palette` follow; the default colour is the main
colour, palette-linked.
- **Decisions where §12.3 was open.**
- Default side: the side of the box facing the other box, by the wider clear gap between the two (a
tie goes vertical), else by the larger distance of the centres. A tree's parent faces the union of
its children; each child faces the parent.
- `at` and `start`/`end` read as C4's side points: a horizontal side starts on the right of a
right-to-left page, a vertical side at its top.
- `elbow` between perpendicular sides is an L; `trunk` places the middle run (0 at `from`). `radius`
rounds each bend with a quarter-circle cubic, held to half a segment between two bends and the whole
of an end segment; a rounded connector joins round.
- `tree`: the bar stands `trunk` of the way from the parent's point to the nearest child (on the
trunk's axis); the two outermost points (children, or the parent) hang from the bar's ends as one
polyline (bends rounded), the others drop straight. **`trunk: 0`** (added): the bar would lie on the
parent's own edge, under the frame, so none is drawn: a drop from that edge to each child, the start
cap at the head of each drop (a timeline's ticks).
- Caps: `startCap` at `from` (a tree's parent; with `trunk: 0` each drop's head), `endCap` at every
`to`; marks that coincide (a fan leaving one point the same way) are drawn once. An arrow's line stops
`0.6 × capSize` short of the tip so its end never shows beside the point. Defaults: width 0.75 pt,
`capSize` 3 × width.
- `[*]` expands at layout over the instances the page has (`named` keys `frame[n]`, item order); an end
`#card` on a repeated frame reads the grid's box (`expandRepeats`), as decoration anchors do.
- **Dropped ends.** An end whose box is not in `named` (missing, an instance past the page's items, an
element dropped with its anchor) or is in the new `DesignFrames.absent` (frames `collapsed`, and optional
frames with no content: `sourceStart` unset and not continued) is left out with a `connectorDropped`
layout issue; with no `from` or no `to` left nothing is drawn. `layoutSlotToVdt` hands the issues out
(`SlotLayoutExtras.connectorDropped`) and the decoration pass logs each as a `ReflowEvent`
`{ kind: 'connectorDropped', frame: <end>, cause: <connector id> }` on the page laid (a spread's first
page); a pass that lays the decoration again replaces them. In words: «connector c left out opcional
(not on the page)».
- **Spreads, right to left.** Nothing new: laid on the sheet with the spread's named boxes, cut by
`splitSpreadDecoration` (by the ink box) and clipped as the rest; mirrored frames give the geometry.
Departure: §12.3's «other page of a spread with no `crossGutter` path» is not a drop case: a connector
between frames of the two pages always crosses the gutter.
- **Validation.** `settleReferences`: `frameAnchorUnknown` at `…/from/to`, `…/to/to` or `…/to/<k>/to` for
an end on nothing (`[*]` valid only in `to` and on a repeated frame); that end is left out.
`collectConfigWarnings` (`collectConnectorWarnings`): `unknownConfigValue` for `routing` (used `elbow`),
`startCap` / `endCap` (`none`), an end's `side` and `at` (a number outside 0…1 is held), and for a
connector in a slot that is not a template's decoration (`<slot>.elements[i].kind`, used `''`: not drawn
there). No new warning kinds.
- **Outputs.** Every backend paints C4's path block (canvas, PDF vector paths, HTML/EPUB inline SVG,
tagged PDF artifact, Folio through the canvas); the source map answers on the ink with the element's
config path, and never over a frame (C4's `pathHit`).
- **Fixture and tests.** `__tests__/composed/fixtures/connectors.ts`: a three-level food-chain tree, a
school org chart with two trees, a school-year timeline with drops, in English, Spanish and Catalan (a
leaf of the Spanish and Catalan trees wraps to two lines). `connectors.test.ts`: routings, caps, gap,
`at`, pin, part palette, `#foto[*]` over a grid, a spread, right to left, dropped ends, source map,
validation.
- **Not done.** `ConnectorEnd.anchor` and leaders (C6); Sandbox (U10), docs (D3), skill (S6). Connectors
inside modules are C11's to rewrite.
### 12.19 What C11 built (#1062)
- **Config.** `PageTemplate.role: 'module'` (`MODULE_ROLE`, `isModuleTemplate`) makes a template a module;
`FrameGroupConfig.modules: boolean` and `overflow: { continueIn }` (moved out of the round 2 draft into
`composed/types.ts`). `normalizeGroup` adds `modules: true` (and `overflow` when it continues) only to a
modules group, which resolves as a `'stack'` with no frames; every other group resolves to the same
object as before. Validation (`unknownConfigValue`, each ignored): a module's `area`, `header`, `footer`,
`variants`, `spread`, `gutter`, `overflowTemplate`; a modules group inside a module (resolved as a plain
stack); a modules group of another `kind` or with `frames`; a group `overflow.continueIn` other than
`'next-page'` / `'template:<id>'`, or on a group that is no modules group. `extends` between modules is
the usual `extends` (`role` is inherited, so a template extending a module is a module); a cycle is
`templateExtendsCycle`, and the module it leaves unresolved is `moduleUnknown` on the page. No new config
warning kinds.
- **Markdown.** `:::module{template="…" name="…"}` (three colons) is a container (`ContainerName
'module'`) inside a page fence; outside one it is a plain box of its content. Decision: **the parser
scopes the slots** of the blocks inside a module (`slot.name` = `<name>.<slot>`, the source range still
the value as written) and names figure labels by the scoped slot, so `composedPages`, `wrap.ts`, the slot
checks, label ids and the Sandbox's `pageSites` read module slots as page slots with no change. The
instance name is `name` when it is free and has no space, `.`, `#`, `[` or `]`, else `<template>[n]`, `n`
the module's ordinal among the page's modules of that template (named ones counted), the next free one
on a clash. The fence records its modules (`ComposedPageFence.modules`, `ContentBlock.module`); a
module inside a module is `nested` (not scoped, its blocks the outer module's). `ParsedComposedPage
.modules` holds each module's blocks that name no slot and its first headings: they go to the module's
`takes` frame, else its flow frame, else the page's flow frame.
- **Expansion** (`composed/modules.ts`, `expandModules`). Departure from §12.7: not in `composePage` but in
`resolvePageTemplate(config, id, { modules })` (cached by the list): the stories (`wrap.ts`), the slot
checks, the build (`singleOn`, spreads, continuation pages), the decoration (`headerFooter.ts`), the
source map and the Sandbox all resolve the page's template with the page's modules
(`VDTComposedPage.modules`), so every reader sees the same frames. Each module's frames, groups and
decoration are appended to the page's, scoped (`<name>.<x>`); anchors, sizes `to`, `next` and
`continueIn: '#x'` to the module's own names are rewritten (links out of the module dropped); frames lose
`flow`, `takes` (kept on `ResolvedModule`) and `grid` (a module has no template grid: a cell reads as no
placement); decoration anchored or sized to `'container'` reads `#<name>`, the module's box. The modules
group lists every module frame in module order and `items` (`{ item, frames, module }`); the modules'
own groups follow the page's, so `groupOfFrames` gives a module frame its modules group and
`composedOrder` lays the group as one step. `expandRepeats` (C10) then replaces a module grid's set in
the modules group and its items by the instances (`fotos[1].foto[3]`). A use whose template is unknown or
no module is left out.
- **Layout** (`placeModules` in `composePage`, one group step). The group's box is its own placement, else
the area. Each module, in order, gets a box as wide as the group from the foot of the one before
(`gap` between): its steps (`composedOrder` of its frames and groups) run with that box as the area
(`'container'`, `'fill'`, percentages), first in the room left with nothing filled, to find its height
(the union of its frames, those held by the foot or middle or sized by the area's height left out), then
in a box that tall, filled. The page's own frames before and after see the modules as one group box
(`#<group>`). Declared boxes follow each other at their declared heights, so a module pushed down by the
one before logs `moved` like a stack. RTL needs nothing: the flow is mirrored (the tree's left branch is
on the start side). Module boxes are `VDTComposedPage.modules[].rect` (with `name`, `template`,
`configPath`), read by the decoration as `#<name>`.
- **Overflow and continuation.** A module past the group's foot is laid with its frames clipped there
(`clipped`), each runs the ladder (scaling pictures among its module's frames, `peersOf`), and clipped
frames warn once per modules group (`frameOverflow` on the last cut). With the group's
`overflow.continueIn` off the page, the ladder's continue step for any module frame (by that rule and
the frame's `fit.preferContinue`; not on the last continuation page) adds the group to
`FitState.gridsOn`: the first module that does not fit and every one after it are not laid; their frames
that hold a story spill whole, each continuation carrying `module` (name and template) and, on the first
of a module, the module's empty frames as `trailing`. `build.ts` resolves the receiving page's template
with those modules, so they are laid there under the same names (`cp0:arbol[6].raiz~1`), at the top of
the group (the page's other frames collapse as on any `'next-page'`). Without the group's rule a module
frame's own `overflow.continueIn` works as on any frame.
- **Warnings** (content, `WARNING_SINCE` 1.48.0): `moduleUnknown` (`reason`: `'unknown'` id, `'notModule'`,
`'noGroup'`: the page's template has no modules group; at `template="…"`; its content goes to the flow
frame and raises no `slotUnknown`), `moduleAsPage` (a fence names a module: set in the flow, as for an
unknown template; `planComposedPages`, `wrapComposedPages`, `pageFenceIds` and `useTemplate`
alternatives leave modules out), `moduleNested`, `moduleNameRejected` (`reason` `'taken'` /
`'invalid'`, `used` the name given instead). `slotEmpty` covers module frames at their `:::module` line.
- **Ids, source map, Sandbox.** `VDTFrame.name` / ids are scoped (`cp0:caja[1].texto`), `configPath` the
module template's entry; `sourceAt` / `findElement` work through `templateOf` with the page's modules.
Sandbox (selection only, no panels): the five viewport readers of the page's template pass
`composed.modules`; `pageSites` reads slots inside `:::module` blocks and lists the modules, and a click on
an empty module frame writes `:::frame{slot="<own slot>"}` inside its `:::module`.
- **Parity.** Templates without modules groups and pages without `:::module` lay out as before (see the
report of #1062 for the bench and recipe hashes).
- **Not done.** Modules side by side (a modules group is always a stack; a grid of modules would be a
follow-up); a module inside a module; template grids (`grid` cells) inside a module; connectors and leaders
across modules (C5/C6 not landed here); decoration of a module that went on whole is drawn only on the page
that lays it, and a module element anchored to the page (`'page'`, `'bleed'`) is drawn once per instance;
Sandbox module library and Design panel entries (U12), docs (D3), skill (S7).
- **Fixture.** `__tests__/composed/fixtures/modules.ts`: a page shell (`tema`: a title and the modules
group) and three modules (`caja`: a box with a tab and a photo beside it; `arbol`: a tree of three on a
tinted ground; `fotos`: a heading and a 2 × 2 photo grid), words in Spanish and English;
`modules.test.ts`.
### 12.20 What C8 built (#1059)
- **Types.** `CornerRadius`, `BorderSide`, `BorderSides` and `BoxSideName` in `types.ts` (out of the
draft). `CalloutStyleConfig.borderRadius` and `ElementBoxStyle.borderRadius` take a `CornerRadius` (a
corner left out of the object is square); `CalloutBorderConfig.sides` and `ElementBoxStyle.borderSides`
give each side `{ enabled?, color?, width? }` over the whole border (a design box's whole border is on
when it has a colour and a width). `CalloutLabelConfig.joined` / `fillet`. `VDTDesignBoxStyle` gains
`borderRadiiPx` and `borderSides` as drafted; `borderRadiusPx`, `borderColor` and `borderWidthPx` stay
filled (the largest radius, the first side's colour, the widest side) for the readers that want one
value. A style whose corners or sides are all alike is folded back to the plain fields
(`normalizeBoxStyle`), and a style with one radius and a whole border builds the very object it built
before, so existing pages keep their VDT and their paint.
- **Geometry** (`design/boxShape.ts`, exported). An outline is a rectilinear polygon walked clockwise,
each vertex a corner (convex, or concave for a fillet) with a radius, each edge on one side. The
border's inner edge is the outline with each edge moved in by its side's width: a convex corner's radii
shrink by the widths beside it (an ellipse when they differ: CSS's inner border edge), a fillet's grow.
Corners are cubic quarter ellipses. Radii are clamped per edge (two corners never take more than their
edge, each by its share). A border is one even-odd ring when all four sides have one colour and a
width; otherwise each side is a closed piece, cut at the corners where the side changes in the ratio of
the two widths (the whole corner to a side whose neighbour has none, as CSS draws a top border on a
rounded box), the pieces of one colour filled together. Departure found on the way: a ring whose inner
edge lies on the outer one (a side without a border) is drawn by PDF rasterisers as a hairline, so it
is used only for four sides. A border wider than the box fills it, as before.
- **Painters.** Canvas `drawBoxBackground` and PDF `drawRoundedBox` send a box that has
`borderRadiiPx` or `borderSides` through `boxStylePathBlocks` and the C4 path painter; every other box
takes its old code. HTML writes `border-radius` with four values and `border-top…` per side (fixed
EPUB alike). Reflowable EPUB writes the same CSS on the callout rule, the corners and sides of the
mirrored flow in a right-to-left book as its padding.
- **Joined tab.** `label.joined` lays the box and its tab as one outline (`boxOutline` with a
`ShapeTab`) and paints it as path blocks marked `boxPart: 'fill'` / `'border'` (with
`borderWidthPx`), in place of the box block and the tab's own box. Decisions the design left open:
- The tab stands on the edge, out of the box by its height: `offset` is not read; a non-zero offset
is `unknownConfigValue` at `label.offset` (used `0`).
- One fill: the box's `background` (none when `backgroundEnabled: false`); the tab's own
`background` is not painted. Its words keep the label `color`, so a joined tab on a white card sets a
dark label colour.
- The tab's outer corners take the radius of the box's corner it hugs; the fillets `fillet`, default
that radius. Beside an `inset` smaller than corner plus fillet, the fillet is held to half the room
and the box corner to the rest; an inset under 0.5 px is flush: the side runs straight up into the
tab (no corner, no fillet there).
- "Every position": `CalloutLabelPosition` gains `bottom-right`, `bottom-left`, `bottom-start`,
`bottom-end`, for plain tabs too (a `bottom-` tab is the top one turned over: its foot `offset` below
the box's, the block taller by what hangs below, the `rule` along the foot). No left or right side
tabs: the words of a tab are horizontal.
- Per-side borders run round the tab with the side it stands on; a flush tab's outer side is the
box's side it continues.
- The stripe stays clipped to the box rectangle with the outline's corners: square where a flush tab
goes on up the side.
- The glyph and picture icons, the rule and the words are laid as before.
- **Readers.** Part palettes recolour each side's colour as the border, a joined fill as the fill and
its border pieces as the border; a frame's `look` border colour recolours every side; preflight checks
each side (and a joined border) as a rule; the contrast check reads a joined fill as the ground; the
overlap check (C2) counts a joined fill as fill and its border as an outline; design palettes
(`applyPaletteOverrides`) override side colours. Comic panels dressed by a box style take the largest
corner. `keepChromeAt` scales sides and `fillet`.
- **Validation** (`collectConfigWarnings`): `unknownConfigKey` for corner keys, side keys and side
fields of callout styles and of design boxes (`style` of a box element, `box` of a text element);
`unknownConfigValue` for a label `position` outside the eight and for a joined tab's offset. No new
warning kind.
- **Fixture.** `__tests__/composed/fixtures/cards/` (`cardShapes.test.ts`): a card with its title tab
grown out of it, a box with a top border only, a box rounded on two corners, a joined tab flush at the
top right and one hanging from the foot, a decoration band rounded on top and ruled at its foot.
- **Not done.** Sandbox fields for per-corner radii, per-side borders and the joined tab (the Callout
and box editors show a per-corner radius as 0 until U10, #1063); docs (D3); a split box's fragments do
not square the corners where it continues (as before); a joined tab in the reflowable EPUB is a plain
tab.
### 12.21 What U13 built (#1066)
The Sandbox side of C2 (§12.10, §12.16), C10's `repeatOverflow` (§12.15) and C3's glyph tab icon (§12.9,
§12.12).
- **Checks panel.** `frameCovered` and `frameOutsideArea` are layout warnings of the Sandbox
(`warnings/compute.ts`, by book page) and composed checks (`composedChecks.ts`): listed by page, frame
and edition, filed with the typesetting; `repeatOverflow` (read from the source, as the slot checks) is a
composed check filed with the markup. Titles and details in the panel's words in all ten site languages
(`warnings/frameOverlapText.ts`): the covered words or picture, the area hidden, what the frame runs past
and by how much, the fixes, and the `overlap: 'allow'` way out.
- **Canvas.** Selecting an overlap check (a jump of the Checks panel, a frame of the Reflow panel) sets an
overlap focus (`editions/overlapFocus.ts`) that the canvas draws in the overlay
(`viewport/composed/overlapMarks.ts`) until another check is selected or a fix is made:
for `frameCovered` the covered frame's box (blue, dashed), the covering frame's (red) and the rectangle
where the two meet; for `frameOutsideArea` the frame, the box it runs past (trim, bleed, host frame, or the
union of the group's frames as laid) dashed, and a band on each side it runs past as deep as the check's
`sides`. Decision: the boxes are the frames as laid (a turned frame by the upright box holding it), not
the ink boxes the engine measured, which the VDT does not keep; the meeting rectangle can so be larger than
`overlapMm2`. A spread's trim and bleed are not drawn (its sheet is cut at the spine).
- **Fixes.** Every proposal of the two checks is a one-click confirmed change in the Checks and Reflow
panels, one step of the undo history, as `frameOverflow`'s (#868): `layer`, `exclude`, `maxHeight`,
`minHeight` set the value at their pointer. Departures: **`move` is made**, as the moved frame's
`placement.offset` with the shift added (in mm; an offset in cm, in or pt is converted), and is only told
in words for a frame with no placement or an offset in ems or percent; a template with recto/verso
variants that mirror offsets is not special-cased. **`continueNextPage` of a `frameOutsideArea` also sets
the `maxHeight` it comes with** (alone it does not bring the frame in). On the fixture every proposal of
the four checks removes its check (`frameOverlap.e2e.test.ts`).
- **Reflow panel.** `covered` and `outside` events in words («figure lies under photo (25.3 mm²)», «note
runs 26 mm past card»), short marks `▨ 25.3 mm²` / `⇥ 26 mm`; the fixes listed under each frame the events
name (`frameFixChecks`). The p. 8 stand-in's test asserts the real lines (the photos' overruns) again.
- **WebMCP.** `sandbox_get_warnings` lists the new kinds with their data (`coveredBy`, `overlapMm2`, `text`,
`picture`, `past`, `target`, `excessMm`, `sides`, `locale`; `group`, `item`, `max`, `flowFrame`) and
numbered fixes in the engine's words; `sandbox_apply_proposal` takes any check of a frame with fixes
(`check`, `coveredBy` to choose) and answers `fixed` (and `fits` for an overflow). No new tool.
- **Glyph tab icon.** The box style editor's label group has a kind switch (picture / glyph); a glyph tab
shows its glyph field, a row of common symbols (◉ ● ○ ■ □ ▲ ★ ✓ ✎ ➜, toggle buttons named «Use ◉»), face,
weight, size (`scale`), colour (the words' when unset), placement and gap; switching to a glyph with none
set writes ◉. The resolved fields already passed `settings.test.ts`. Search index regenerated.
- **Not done.** Pointing at a frame in the Reflow panel does not draw the overlap (a click does); the
Folio does not draw it.
### 12.22 What #1075 changed (C2 finding)
- **A `move` lands clear.** Before #1075 the `move` of `frameCovered` was the shortest shift along one axis
that took the covering ink off the covered ink, whatever lay there: on the overlap fixture «move cutout
8.5 mm up» uncovered the card's tab and covered the figure's caption. Now each proposed shift is tested
on the page by the check's own rules (`tryMove` in `overlap.ts`): the moved frame, and every frame
anchored to it (transitively: they move with it), against every other frame painted before or after
them, with the same ink, paint order, thresholds and `'allow'`/contour rules. A move is offered only when
the pair is cleared, no pair that had no warning comes to cover (pairs already warned may stay), and no
moved frame runs further past the trim, the bleed or the box it is anchored inside than it did (0.5 px
slack). Lines of a frame that a pushing frame covers are left out of the test: they run round it where
it lands.
- **Direction.** All four directions are tried, each from the distance that takes the covering boxes off
the covered ones; when the moved frame would land on another frame's ink, the move goes on past that
ink and is tested again (up to 8 times); a direction stops when the pair is not cleared or a frame runs
past what holds it. The smallest clear move wins (ties: right, left, down, up). The value is rounded
away from zero to 0.1 mm, so the proposal never falls short of what was tested. On the fixture the
cut-out now moves 13.1 mm right.
- **No move.** None when no direction lands clear, and none when the covered frame is anchored to the
covering one (they move together). The warning then offers `layer` / `exclude` where they apply, and
`overlap: 'allow'` as before; on the fixture the photo anchored inside the figure has no fix left (every
way off the caption takes it out of the figure).
- **`frameOutsideArea`'s `move`** goes through the same test: it is dropped when the shift that brings a
frame in would make it cover another frame or be covered by one.
- **Data unchanged.** `FrameCoverProposal` / `FrameOutsideProposal` keep their shape (`frame`, `dxMm`,
`dyMm`, a shift added to the frame's placement offset), so `sandbox_apply_proposal` and the panels read
them as before. Proposals are data: no layout moves.
- **Not done.** The test does not reflow: text pushed round a moved frame (`exclude: 'push'`) is assumed to
run clear, and a group's box is not recomputed when a member moves.
- **Tests.** `overlap.test.ts`: every offered fix on the fixture (`layer`, `exclude`, `move`, `maxHeight`,
`minHeight`, `continueNextPage`) applied as the Sandbox applies it clears its warning and raises no new
pair; the move off the tab goes right, not up onto the caption; a move goes on past a frame it would land
on; no move when every way out is blocked, nor when the covered frame hangs from the covering one.
### 12.23 What C6 built (#1057)
- **Types.** `LeaderStyle` moved into `types.ts` (exported); `ResourceAnchor.labelAt` / `.leader` and
`LabelStyle.leader` are in the real types; the C6 part was the last of `round2Types.ts`: the draft file is
removed. `LeaderStyle` has two keys the draft lacked: `capSize` (default 3 × width, as a connector's) and
`routing: 'straight' | 'elbow'` (default `straight`). VDT: `VDTFigureLabel.leader` (the attribute) and
`VDTFigureLabelLayout.leader: VDTFigureLabelLeader` (point, routing, stroke, caps, cap size, gap). No
`CONFIG_VERSION` bump: only new optional keys.
- **Connector ends at a picture's anchor.** `DesignFrames.anchorPoint(frame, anchor)` (filled by the
decoration pass in `headerFooter.ts` from each frame's blocks) answers with the point of the first
bitmap or SVG block of the frame whose resource has the anchor, through `figureAnchorPointOnPage`
(`composed/labels.ts`): `pictureToBody` (crop, safe area) on the body's box *on the sheet*
(`flowRectToPage`), so the ladder's scale, mirrored pages (a picture is painted the right way round) and
vertical pages (upright pictures) need nothing more; a quarter-turned block goes through its turn. On a
spread the point takes the right page's shift with the frame's box. The end is a box of no size at the
point; `at` is not read, `gap` stops the line short of the point.
- **Decisions where §12.4 was open.**
- The side of an end at a point with no `side` written: the line arrives on the axis it leaves the other
end by (a note's side gives a horizontal last run), from the side the other end stands on; a tree's
point children arrive against the trunk. The connector's default (`facingSide`) turned the last run
vertical whenever the point was further down than across, which ran a note's pointer through the map.
- A point its frame cuts off (outside the frame's box on the sheet: a picture pushed past the frame's
foot) is not pointed at: the end is dropped as `connectorDropped` (reflow log), with no warning.
- Unknown anchor: `labelAnchorUnknown` with `connector` and `frame` added (no new kind, so no
`WARNING_SINCE`); `resourceId` is the frame's first picture, empty when the frame holds none on the
page; that end is dropped. A non-string `anchor` is `unknownConfigValue` (`collectConnectorWarnings`).
- Connectors stay in the decoration layer (0). To point *into* a picture its frame goes under the
decoration with `layer: -1` (the fixture does so). Raising a connector over the frames was not added,
for the reason given in §5.18 (two decoration paint items in every backend).
- **Labels away from their point.** `labelAt` (fractions of the whole picture, any finite numbers) moves the
label's frame origin; `align`, `verticalAlign` and `rotate` place the box about it. Which labels get a
leader: the label's attribute (`leader`, any value but `none` / `false` / `off`; `leader="none"` turns it
off), else the anchor's `leader` (`true`, an object, `false`), else a label set away from its point whose
style has a `leader` look. The look is the style's `leader` under the anchor's object, field by field;
colour by default the label's text colour (the part palette recolours it by `label:leader.color`, then by
the label's colour); width 0.75 pt; end cap `dot`, no start cap.
- **Leader geometry** (`figureLabelLeaderPath`, laid when painted, not stored as a path). In the label's own
frame: the box (the plate, else the lines' box) grown by a gap (0 with a plate, 0.25 em of the label's set
size without); the side with the wider clear gap to the point (as `facingSide`), at its middle; straight to
the point, or an elbow out of that side square with one turn onto the point. Leaving by the facing side
keeps the line off the label's words, a turned label included (the points go through the label's matrix).
A point under the grown box, or a line shorter than its cap, draws nothing. Because it is laid from the
layout, `keepLabelsOffGutter` and `mirrorLabelLayout` / `unmirrorLabelLayout` / `labelLayoutOnSheet`
(which reflect the point about the body as the rest) keep it on target. Caps and the polyline reuse the
connector's `capEnd` and `polylineCommands`.
- **Paint.** `figureLabelLeaderPaths(rb, bx, by)` gives C4's path blocks, painted before the figure's labels
(under every plate and halo): canvas `renderPathBlock` (Folio through it), PDF `renderPathBlock` under a
Layout artifact (the structure tree is unchanged), HTML and fixed EPUB the design path's hidden inline SVG
inside the figure's label markup, the reflowable EPUB paths in its label layer for the labels it draws.
Preflight checks the line as a rule (`thinRule`, coverage). `labelOverlap` ignores leaders (they are not
label boxes): a leader across another label is not reported.
- **Validation** (`validatePageTemplates`): `unknownConfigKey` / `unknownConfigValue` inside
`labelStyles[i].leader` (caps, routing) and `resources[i].anchors[j].leader` (also a value that is neither
a boolean nor an object), a `labelAt` that is not a point (`unknownConfigValue`) or has other keys. The
anchor checks run when an anchor has `labelAt` or `leader` too.
- **Fixture and tests.** `__tests__/composed/fixtures/mapParts.ts`: the parts of a map (a painted map, its
title, key and scale as labels, three notes joined to them, two names with leaders) in English, Spanish
(longer notes) and Arabic (mirrored), and a spread variant. `leaders.test.ts`: connectors on target in two
languages, under the ladder, right to left, across a spread, on grid instances, cut-off and unknown
anchors; `labelAt`, straight and elbow leaders, attributes, style looks, mirroring, canvas and HTML paint,
validation. `postext-pdf` `figureLeader.test.ts`; the reflowable EPUB's `labels.test.ts`.
- **Not done.** A connector over the frames without `layer: -1`; a connector that ends at a label's box (it
ends at the anchor's point); the leader of a vertical label on a mirrored page is drawn from the mirrored
layout (right on the sheet by construction, not covered by a test); frames painted unmirrored on a
mirrored page (#960) read their blocks' boxes through the page's flow like any other (unchecked). Sandbox
(U11), docs (D3) and the skill (S6) have their own issues.
### 12.24 What U12 built (#1065)
The Sandbox side of C10 (§12.6, §12.15) and C11 (§12.7, §12.19): grid groups and modules edited in the
Design panel, a module library, and two reshapes that move a port of one-off frames to them.
- **Grid groups.** The groups editor's kind is stack / row / grid; a grid shows columns, column and row
gaps (the group's `gap` when unset), `rowAlign` and «Repeat a set of frames»: the main frame (a select
of the frames no other group holds), the frames that go with it (checkboxes) and `max` (0: none). A
repeating grid hides the plain member list (its `frames` are not read). A group can be switched to «Takes
the page's modules» (`modules: true`, kind and members cleared) with «goes on to the next page»
(`overflow.continueIn: 'next-page'`).
- **Modules in the template editor.** A «Module» switch sets `role: 'module'` (and clears area, spread,
gutter, running heads, overflow template, variants); a module's editor hides those fields and the
template grid. The Page templates list tags modules. Renaming a module's id rewrites the `:::module`
blocks that name it (a page template's, the `::::page` fences, as before).
- **Module library** (Design › Page & columns › Modules, section `modules`): every module drawn by its
sheet, its id and «N blocks on M pages» (`moduleUsage`, read from the Markdown of every chapter: blocks,
pages, by chapter, the page templates holding it); «New module» (one `text` frame); Edit opens the template
editor in place on the module; Insert writes `:::module{template}` with an empty `:::frame{slot}` per
frame (none for a `takes` frame; a grid's set as one empty item with its members inside) before the
closing fence of a composed page of the chapter in the editor (a select, the page at the caret first),
puts the caret in its first slot, and adds a modules group to the page's template when it has none
(under its last frame, `width`/`height` `'fill'`, 6 mm gap). Decision: the library lists module
templates only; a template that is used as page and module is not distinguished.
- **Instances on the canvas and in the Markdown.** Design mode already names instances by their `[n]`.
`pageSites` now also lists the slotted pieces one level inside a `:::frame` (`within`), left out of
`sitesOf`; with the page template's repeat sets (`repeatSetsOf`) a site is read as its instance
(`instanceOfSite`: the n-th top-level piece of the set, as `pageRepeats` counts) and an instance as its
site (`siteOfInstance`). A click on `card[3]` puts the caret in the third item, on `caption[3]` in the
caption written inside it, or writes an empty `:::frame{slot="caption"}` inside the item; the caret in
an item marks its instance. Every viewer's pick reaches the template editor through `selectFrame`, which
now opens the template frame that draws the pick (`templateFrameOf`: `card[2]` → `card`, a module frame
`caja[1].nota` → frame `nota` of module `caja`). Departure: the canvas frame editor's drag handles are
not drawn on an instance or a module frame (a drag would move every copy; the panel edits them).
- **«Turn into a grid»** (`templates/framesToGrid.ts`, «Reshape frames» in the template editor, base scope,
not on spreads or modules). The frames picked are split in cards and what hangs from each by their names
without the number (`foto1`/`foto2` one kind, `pie1`/`pie2` another; the cards are the first kind none of
whose frames hangs from a frame of another kind; a member goes with the card of its number, else the card
it hangs from, else the nearest); the lattice is read off the first laid page of the document on screen
set with the template (tops within 0.6 mm make a row, starts make columns, equal widths, one column gap,
full rows but the last, one row gap measured as the engine measures rows: reach above the card plus reach
below its top). The grid repeats the first card's set renamed without the number when free, `rowAlign:
'start'` (so a shorter card keeps its height), its box the first row's start, as wide as the columns, to
the area's foot, anchored as the first card was when that hangs by its top-left (`below`, `right-of`,
`align-top`, `align-left`, or `top-left` of the area), else at the area's top-left. Anchors elsewhere to a
card or a member point at its instance (`#foto2` → `#foto[2]`); groups lose the cards (and go when empty).
Every page set with the template gets one item per card, in reading order (`:::frame{slot="foto"}` with
the member written inside it; a card written as a block is wrapped when it has members). Refusals are
worded in the panel (too few, inherited, not laid, flow or `takes`, already repeated, off the lattice,
widths, members differ).
- **«Extract a module»** (`planExtractModule`). The frames picked, a group (stack, row or grid) all of whose
frames are picked, and the decoration anchored to them (a connector when both ends are) move into a new
module after the page template; frames anchored outside the selection are placed at their laid offset in
the module's box (percent widths and `'fill'`/percent heights fixed to the laid size). The page template
gets a modules group where they stood: as wide as their union, anchored as the topmost frame was (same
rule as the grid) else at the area's top-left, `height: 'fill'`, or the union's height when something
else hangs from a moved frame; what hung from a moved frame hangs from the group, its offset shifted by
as much as that frame's edge stood from the group's (`edgeShift`). Every page set with the template gets
the pieces of the moved frames wrapped in one `:::module{template}` where the first stood (an empty block
when the page wrote none, so the module is still laid). Refused: a template with a modules group already
(stacking a second extraction is not done), a grid picked in part, flow / `takes` frames, a module.
- **Page looks the same.** `templates/reshape.e2e.test.ts` lays a page of five one-off photo cards (with
caption pills) on a three-column lattice and a box with a tab and a picture, turns the cards into a grid,
extracts the box into a module, and compares every frame box before and after (within 0.3 px) under its
new name (`foto2` → `foto[2]`, `nota` → `caja[1].nota`), alone and both on one page.
- **Undo.** Both reshapes, a module insert, rename and create are the chapters' text changes (one step per
chapter) then one design step (`applyTemplateRewrite`), as a style merge; «Reshape frames» offers Undo
while they are the latest steps.
- **Checks.** `moduleUnknown` (each reason worded), `moduleAsPage`, `moduleNested`, `moduleNameRejected`
are composed checks of the Checks panel (filed with the markup, as `repeatOverflow`), with titles and
details in the ten languages, the Cookbook warnings registry explains them, and `sandbox_get_warnings`
gives their data.
- **WebMCP.** `sandbox_modules` (modules with frames, groups and uses; page templates with their modules
group), `sandbox_create_module`, `sandbox_rename_module`, `sandbox_insert_module` (chapter, page fence
ordinal; `groupAdded`), `sandbox_frames_to_grid` and `sandbox_extract_module` (the boxes read off a laid
chapter; answers the old → new names), each answering its `undoSteps`. None is consequential.
- **Not done.** A frame hanging from a moved frame keeps the shift measured on the page read (the group then has a fixed height): on a page where the moved frames come out at other heights it follows the group's box (an anchor from a page frame to a module's frame, `#caja[1].nota`, is not resolved by the engine; worth an engine issue). A second module extracted into an existing modules group; reshaping inherited frames or a
variant's (variants are only retargeted); right-to-left and vertical pages are read in their flow frame
but not tested; the Folio and HTML viewers reach instances through the same pick path but have no test of
their own; a canvas multi-selection (the frames are picked in the panel).
### 12.25 What U10 built (#1063)
The Sandbox side of C4's side points (§12.14), C5's connectors (§12.18), C9's path element (§12.17) and
C8's box shapes (§12.20): all of them are edited in the Design panel and on the canvas without JSON.
- **Connectors.** The decoration of a page template (the slot editor given the template's frames and a
`connectorHost`) has «Add connector»; a new one runs from the template's first frame to its second as an
elbow. Its card (`ConnectorElementEditor`): the start in a list of the template's frames, groups and the
decoration's placed elements; the ends as a row of toggle buttons (pressed: an end), with every instance of
a repeated frame as `#card[*]` and an end on a name the list lacks (an instance `#card[2]`, a frame taken
out) still listed so it can be taken out; per end (start and each `to`) its side («Facing» when unset),
point on the side (start, middle, end or a fraction, shared with the placement fields) and gap; routing,
bend radius and bar position (not on a straight line), colour (palette-aware, default the main colour),
width, dash, start and end marks and the mark size (shown when a mark is set, default 3 × width), pin,
parity and pages. The edits are pure (`templates/connectorEdits.ts`): a list of one end is written as an
object, several as a list.
- **Picking on the page.** «Pick on the page» (a toggle button) sets the template editor's connector focus
(`editorStore.connector`, phase `from`) and turns the Design mode on. On a page of that template (or one
extending it) a click on a frame is an end, not a drag (`frameEditor.ts`): the first sets the start, and
the ends start afresh; each later click adds an end or takes it out again; Shift-click adds or takes out an
end in either phase; a click on the start itself does nothing; Escape (or the button) ends the picking. A
live line under the button says what the next click does. Decisions: re-picking the start clears the ends
(the issue's «pick a frame and then its targets»); a line never goes to its own start (setting the start
takes it out of the ends). The write is one step of the history.
- **Handles.** While a connector's card is open its ends are drawn in the Design mode marks: a filled dot at
the start, a ring at each end, and while picking the frames joined outlined (solid the start, dashed the
ends). Geometry in `templates/connectorHandles.ts`, the engine's rules (default side facing the other end, a
tree's parent facing the union of its children; `at`, `gap`), in the flow frame (`start` is its left: a
right-to-left page's flow is the sheet mirrored). `facingSide`, `CONNECTOR_ROUTINGS`, `CONNECTOR_CAPS`,
`CONNECTOR_SIDES` and `DEFAULT_CONNECTOR_WIDTH` are now exported by `postext`. The e2e test builds a tree of
three connectors and a joined card with these edits and checks every handle lies on the engine's line.
Departure: the handles show in Design mode only (the marks layer); they are not draggable (an end is moved
by picking or by its fields).
- **Shapes.** «Add shape» in every design slot writes a bar filled with the main colour. Its card
(`PathElementEditor`): presets bar, ribbon (a band with a pointed head and a notched tail), arrow, chevron
and wave, each a toggle button with its outline drawn, writing `d` in the default 1 × 1 view box (and
taking a `viewBox` off); the path field checks the data with `parseSvgPath` as it is typed and writes it only
when it parses, saying at which character it goes wrong (`role="alert"`, `aria-invalid`); view box width and
height, fit (stretch / keep proportions), fill and outline switches with their colours, outline width and
dash; size (lengths or `{ to, edge }`), placement, parity and pages as a box's.
- **Corners and sides.** One component pair (`shapeFields.tsx`) for callout and frame styles (the frame
styles section reuses the style card), decoration boxes and a text element's box: «Same radius on every
corner» (off: four corner lengths, each starting from what it showed; on again: the largest) and «Same
border on every side» (off: per side a switch, colour and width over the whole border's, each with its
reset; a side switched to the whole border's state takes its own `enabled` off). On a callout style the
sides are offered whether or not the whole border is on (a border on the top only is `enabled: false` plus
a side). The callout card shows a per-corner radius for what it is (the 0 that `uniformRadius` gave is
gone).
- **Tabs.** The label group has «Joined to the box» and, when joined, the fillet radius (default shown: the
corner radius); turning it on writes `offset: 0` (a joined tab is on the edge, C8 warns on another) and
the rise field is hidden. The position list has the four foot positions (left and right named by the flow,
as the top ones).
- **Placement.** Decoration elements can be anchored to the template's frames and groups (they were only
reachable through a size); a name the lists lack stays shown. On an anchor to another element or a frame
the «Point on the side» field writes `anchor.at` («the edge's own point» when unset, start, middle, end, a
fraction). The size-to edge offers the middle (`'center'`).
- **WebMCP.** No new tool: everything above is configuration `sandbox_set_config` writes. A gap found on the
way: a per-corner `borderRadius` was refused as «a length»; `coerceSetting` now takes an object of corner
keys, each coerced as a length. `connectorShapes.test.ts` draws the tree, the shape, the corners, the sides
and the joined tab through the tools and writes every new leaf back unchanged.
- **Words.** 103 new labels, one changed (the size-to edge tooltip names the middle), in the ten site
languages; the settings search index regenerated.
- **Not done.** Connector ends on a module's frames (`#tree[1].left`) are not listed (typed through the tools
only). The handles are not drawn outside the Design mode nor in the Folio. Dragging an end along its side
to set `at` is left for later.
### 12.26 What #1076 changed (C6 finding)
- **The key.** `DesignConnectorElement.over?: boolean`: the line is painted after every frame of the page.
Default (`connectorPaintsOver`, `design/connector.ts`): `true` when an end as written (`from` or any `to`)
has a non-empty `anchor`, else `false`; read from the element, not from the ends the page keeps, so a
dropped end does not move the line to another layer. `over: true` raises a plain frame-to-frame
connector, `over: false` keeps a pointer under the frames. A value that is not a boolean is
`unknownConfigValue` at `…/over` (`used` the default). No `CONFIG_VERSION` bump: one new optional key.
- **One slot, two paint items.** §5.18 rejected a `layer` on decoration elements because the decoration
would become two paint items in every backend. The decoration stays one slot in the VDT
(`VDTComposedPage.decoration`): the connector's path block carries `over: true`
(`VDTDesignPathBlock.over`, set by the layout through `ResolvedPathPrimitive.over`), and only the paint
order knows the rest. `composedPaintOrder` appends a `{ kind: 'over' }` item after the last frame when
the slot has such a block (never otherwise); `decorationUnder(slot)` (the slot itself when nothing is
over, so nothing is copied) and `decorationOver(slot)` (undefined when nothing is over) give what each
item paints, `paintsOverFrames(block)` the test (`connectorPaintsOver` is exported for editors). With `part` (`'below'` / `'above'`, a page painted
through a flow frame) neither decoration item is listed: the caller paints `decorationUnder` on the
sheet between the two calls and `decorationOver` on the sheet after the `'above'` frames. What reads the
slot as data (preflight's thin-rule and coverage checks, the part palette, the spread cut, the font and
image collectors, the Sandbox's page geometry) reads it unchanged.
- **Backends.** Canvas (`renderComposedFrames`, `renderComposedDecoration(…, 'over')`; Folio through
it), PDF (`paintComposedItems`; the flow-frame branch paints the over part on the sheet after the
frames, under the same Layout artifact mark as the rest of the decoration, so the structure tree is
unchanged and the artifact comes later in the content stream), HTML and fixed EPUB (the over part in
its own `pt-frame-decoration pt-frame-decoration-over` box with the last `z-index` of the paint order;
through a flow frame, on the sheet after the decoration; `decorationHtml` includes it for patchers).
`composedReadingOrder` reads the over item with the decoration, first: it holds no content. The
reflowable EPUB reads frames only. Contrast skips the over item (lines are not a ground; the slot is
walked once with the decoration item, as before).
- **Source map.** `sourceAt` reads the paint order backwards, so the over item is hit before every frame:
on its ink (C4's `pathHit`) the connector answers inside the picture's frame; the decoration item hits
only the blocks that are not over (indices stay the slot's, so element ids do not change).
- **Checks.** `frameCovered` reads frames only, so a connector over a frame's text or picture is not
reported (a test pins it); `labelOverlap` is unchanged.
- **Fixture.** `fixtures/mapParts.ts` lost its `layer: -1` (single page and spread): the map frame is at
the default layer and its pointers paint over it. With `layer: -1` patched back the lines are the same
paths and still painted last (`overConnectors.test.ts`).
- **Parity.** A book without anchored connectors and without `over: true` has no `over` mark and no over
item: its VDT and its output in every backend are unchanged (bench-layout hashes, the composed recipes'
vdtHash). On a page whose connectors end at anchors the paint order changes (they move from layer 0 to
last) and the VDT gains the mark; where the picture's frame was at `layer: -1` the look is the same.
- **Tests.** `overConnectors.test.ts` (default and both overrides, paint and reading order, canvas left to
right and right to left, HTML z-index and the flow-frame page, source map, `frameCovered`, validation);
`postext-pdf` `connectorOver.test.ts` (stroke after the picture left to right and through the mirrored
flow, before it with `over: false`, a Layout artifact when tagged).
- **Not done.** Other decoration elements over the frames (a rule or box that should sit over a photo
still needs the photo's frame at a negative layer); an over connector between frames of different
layers is painted over both, never between them. Sandbox (U10/U11), docs (D3), skill (S6).