# Shojiku, for AI agents You are reading the machine-readable companion to this site's pages. Shojiku renders business documents (invoices, receipts, forms, worksheets) from three files: `templates.yml` (all layout and styling), `definitions.yml` (the declared data catalog) and `params.json` (the data). Rendering is deterministic — the same inputs produce the same bytes on the CLI, in Docker, in every SDK and in browser WASM — and network-free. To work with Shojiku, register its MCP server and drive the validate → preview → inspect loop against the engine's own output: ```bash claude mcp add shojiku -- \ docker run --rm -i --entrypoint shojiku-mcp \ -v "$PWD:/work" -w /work ghcr.io/kengos/shojiku:edge ``` Then install the product skills (`npx skills add kengos/shojiku`) and follow `shojiku-template-author` — it carries the authoring loop, the wire gotchas, and the command table. The engine is the only source of rendering truth: validate until clean, preview every page, look at the pixels. Answer the reader in their own language; the material below is English. ## Bundled examples (25) - examples/business/invoice-ja/ — Invoice (ja): 22 line items paginate with a repeating table header, per-tax-rate totals, QR + link; `params-short.json` renders a 3-item single page from the same template. - examples/typography/novel-ja/ — Vertical short story, paperback style (ja): Run, Melos! (excerpt): vertical columns paginate with their ruby, strict kinsoku + hanging punctuation, tate-chu-yoko in the colophon, vertical page numbers. - examples/business/invoice-en/ — Invoice (en-US): Letter-size US invoice: USD with cents, plural-aware quantities (`1 item` / `24 items`), Net-30 terms, payment box + pay-online QR. - examples/forms/certificate-en/ — Certificate (en-US): Letter-landscape certificate: double-rule frame, wide letter spacing, real italics, verification QR. - examples/business/receipt-zh-tw/ — Receipt (zh-TW): The locale-pack story: the same receipt geometry as ja / zh-CN / 80mm en-US, with currency, dates, tax wording, and font fallback swapped by the pack. - examples/forms/application-form-ja/ — Application form, filled ↔ blank (ja): ONE template, two params files: form marks, 〒 entry cells, wareki with a blank-form `placeholder`; not a single pt shifts. - examples/business/restaurant-menu-us/ — Restaurant menu (en + vertical writing): An American Japanese restaurant's specials: English menu, USD prices, and the vertical 正直亭 brand column + per-dish vertical names carrying the Japanese feel. - examples/business/event-tickets-ja/ — Event tickets (ja): 2×4 n-up imposition with per-ticket QR, trim marks for the cutter, `placeholder` seat fallback; 14 attendees flow onto sheet 2 automatically. - examples/business/estimate-ja/ — Estimate: The invoice's sibling: single-rate one-pager, estimate-terms box, discount row. - examples/business/delivery-note-ja/ — Delivery note: Between estimate and invoice: quantity-bundling `headerGroups`, data-driven row styling that tints only rows with items remaining, a receipt-stamp field; partial ↔ complete delivery as two data files. - examples/business/pickup-slip-ja/ — Pickup slip: The Thinreports migration artifact — the migration walkthrough's result. - examples/business/catalog-ja/ — Product catalog: Variable-height `repeat_flow` cards with dynamic images. - examples/business/shipping-labels-ja/ — Shipping labels: 2×3 n-up labels with 〒 cells and an overflowing contents list. - examples/forms/rirekisho-ja/ — Rirekisho (JIS-style résumé): A3 spread, blank ↔ filled from one template. - examples/forms/certificate-ja/ — Certificate (ja): Landscape certificate with a double-rule frame and full bleed. - examples/typography/kokugo-print-ja/ — Kokugo reading worksheet: A framed vertical passage from Run, Melos! with ruby, then vertical questions read right to left with answer boxes and kanji cells — a grade-school reading-comprehension sheet. - examples/typography/genkoyoshi-ja/ — Genkoyoshi (vertical): 200-character manuscript paper with Aozora ruby. - examples/typography/genkoyoshi-yoko-ja/ — Genkoyoshi (horizontal): The 400-character horizontal sheet. - examples/lifestyle/recipe-booklet-en/ — Recipe booklet (en-US ↔ ja): A Japanese recipe video, reprinted for the kitchen counter: a photo-led shopping page whose third column carries substitutes, then `repeat_flow` step cards that paginate whole, with the source URL and its QR in the footer of every page. Not one reader-facing word lives in the template — even the table's column labels are bindings — so the English and Japanese sheets are one file and two params. - examples/business/receipt-ja/ — Receipt (ja): The quickstart document: containers with `%` widths, a boxed total, tax breakdown, issuer block and QR. - examples/business/receipt-us/ — Receipt (80mm thermal, en-US): A custom-size 80mm thermal-printer receipt. - examples/business/receipt-zh-cn/ — Receipt (zh-CN): The simplified-Chinese member of the locale set. - examples/business/receipt-hi-in/ — Receipt (hi-IN): Devanagari conjuncts + lakh/crore digit grouping. - examples/business/receipt-fil-ph/ — Receipt (fil-PH): Latin face + Philippine peso from the fil-PH pack. - examples/business/receipt-th-th/ — Receipt (th-TH): Thai wrapped at word boundaries, dated in the Buddhist era. --- ## docs/engine/README.md — The reference index: every authorable key, one page per feature. # Template reference The complete authorable surface of the Shojiku engine, one page per feature (MDN-style): what you can write in `templates.yml`, what each key means, its defaults, and the diagnostics it can produce. This is the human- and machine-readable source for "what syntax exists"; keep it accurate against the code (see the curation rules in [../README.md](../README.md)). These pages are **not** on the MCP wire. An agent talking to `shojiku-mcp` gets the initialize `instructions`, the bundled examples (`list_examples` / `get_example` / `resources`), `capabilities`, and `validate`'s diagnostics — working documents and machine-checkable answers, not this reference. Serving the reference itself is open work. Feature availability per engine build is machine-checkable: `shojiku capabilities` prints the key list, and each page notes its capability keys. Template authors targeting one engine build can ignore them — they exist so GUIs/SDKs can gate features across engine versions. The development-facing companion [features.md](../engine/features.md) carries the implemented-capability inventory and the decision log — *that* a feature exists and why it is shaped that way; the pages here carry only *how to author it*. It lives in the repository only and is not part of this rendered reference; the reader-facing tour of what the engine does is the site's own [Features](https://shojiku.pages.dev/features) page. ## A minimal template ```yaml page: { size: A4, margin: 25 } sections: body: type: flow items: - type: text text: "Hello {customer.name}" ``` Full file structure (bands, bodies, styles registry): [template.md](template.md). ## Rendering a template ```bash # PDF (the core command) shojiku render --templates templates.yml --params params.json \ --definitions definitions.yml --output out.pdf # --output - writes to stdout # Static + data checks, diagnostics as JSON (definitions/params optional) shojiku validate --templates templates.yml --params params.json # Per-page preview PNGs ({page} is replaced by the 1-based page number) shojiku preview --templates templates.yml --params params.json \ --output "page-{page}.png" --scale 2.0 # 2.0 px/pt ≈ 144 dpi # The resolved layout tree + box index as JSON (GUI/AI surface) shojiku inspect --templates templates.yml --params params.json # This build's feature keys (no inputs needed) shojiku capabilities ``` Useful defaults on `render` / `preview` / `inspect`: - `--lang ` selects the locale (default: the template `defaults.locale`, then `ja-JP`); `--locale-dir` / `--font-dir` (repeatable) locate the packs, adding to `$SHOJIKU_LOCALE_DIR` / `$SHOJIKU_FONT_DIR` then `./packs/{locale,fonts}`. See [fonts.md](fonts.md). - `--font-pack ` (repeatable) loads a font pack in addition to the locale's own `fonts.uses` — how a pack made by `shojiku font add` is used without rewriting the locale. See [fonts.md](fonts.md). - A font pack whose faces are **pinned** (`sha256` + `url:`) but absent is downloaded into `$SHOJIKU_CACHE_DIR` before rendering; `--offline` refuses instead, and `--font-fetch-allow ` trusts an extra source. Rendering itself never uses the network. See [fonts.md](fonts.md). - `--assets-dir` is what image `src:` paths resolve against (default: **the template file's directory**). Asset policy: `--asset-mode open|bundled-only`, `--allow/deny-dynamic-image `. - Diagnostics print to stderr; `validate` exits non-zero on errors. Try the bundled example: ```bash shojiku render --templates examples/business/receipt-ja/templates.yml \ --params examples/business/receipt-ja/params.json \ --definitions examples/business/receipt-ja/definitions.yml --output receipt.pdf # or via the Docker image (`make docker-build` builds the local # `shojiku-ci:local` tag; its default command renders exactly this example): docker run --rm shojiku-ci:local > receipt.pdf # your own files: mount them and pass normal CLI arguments docker run --rm -v "$PWD:/work" shojiku-ci:local render \ --templates /work/templates.yml --params /work/params.json \ --output /work/out.pdf ``` Getting the binaries in the first place (source build or Docker, plus MCP-server registration for AI agents) is covered in the [quickstart](../quickstart.md). ## Item types Every `items:` entry is a map with a `type:`. Where an item may appear depends on which part of the page holds it. A page ([template.md](template.md)) is made of optional **bands** — `header` and `footer`, repeated at the top/bottom of every page — and one **body** between them, which is either `type: flow` (items stack top-to-bottom and paginate onto new pages as they run out of room — the usual choice, [flow.md](flow.md)) or `type: absolute` (every item pinned at its own `box.x`/`box.y`, single page). The placement column below uses: - **F** — in a flow body - **A** — in an absolute body - **B** — in a band (header/footer) - **C** — inside a `container` item, wherever that container sits - **cell** — inside a `repeat` cell / `repeat_flow` card | `type:` | What it draws | Allowed placement | Page | | --- | --- | --- | --- | | `text` | static / interpolated / bound text; rich `spans:` | F A B C cell | [text.md](text.md) | | `rect` | rectangle (border/fill) | F A B C cell | [rect.md](rect.md) | | `line` | stroked segment; `Length` endpoints (`to: { x: "100%" }`) or an anchor to another item (`to: { item: total }`) | F A B C cell | [line.md](line.md) | | `image` | PNG/JPEG/GIF/WebP/SVG asset | F A B C cell | [image.md](image.md) | | `qr_code` | layout-time vector QR | F A B C cell | [qr_code.md](qr_code.md) | | `list` | one line per array entry + overflow clamp | F A B C cell | [list.md](list.md) | | `container` | nestable box: origin, size, style cascade | F A B C cell | [container.md](container.md) | | `table` | paginating data-driven rows; a column binds a value or hosts a `cell:` sub-template; `box:` narrows it in flow or places it as one bounded block elsewhere | F A B C (not cell) | [table.md](table.md) | | `repeat` | imposition / n-up grid of data-scoped cells | F only | [repeat.md](repeat.md) | | `repeat_flow` | flowing card list, one card per element | F only | [repeat_flow.md](repeat_flow.md) | | `page_break` | start a fresh page | F only | [page_break.md](page_break.md) | | `char_grid` | manuscript-paper / workbook character cells (+ruby) | F A B C cell | [char_grid.md](char_grid.md) | | `ellipse` | box-inscribed oval, or `anchor:` to circle another item's glyph band; circled-option mark or decoration | F A B C cell | [form_marks.md](form_marks.md) | | `checkbox` | always-drawn frame + params-driven check | F A B C cell | [form_marks.md](form_marks.md) | | `page_number` | `{page} / {pages}` | B only | [page_number.md](page_number.md) | Every type in this table also takes **`visible:`**, which binds whether the item is shown to a params field — reserving its box by default, or removing it from layout with `collapse: true`. See [visible.md](visible.md). Disallowed placements warn and skip (never a hard failure) — codes in [diagnostics.md](diagnostics.md). ## Concepts | Page | Covers | | --- | --- | | [template.md](template.md) | file structure: `page` / `styles` / `defaults` / `formats` / `sections`, bands, bodies, common item keys | | [defaults.md](defaults.md) | document presentation defaults: root style (rem root), per-type format defaults, the `formats:` registry | | [page.md](page.md) | page size, orientation, margin; the margin box as coordinate origin | | [document.md](document.md) | `document:` metadata: title / description / keywords / language / authors → the PDF's document properties (PDF only) | | [length.md](length.md) | units: pt, `%`, `mm`/`cm`/`in`, `em`/`rem`; round-trip; guards | | [box.md](box.md) | `box:` geometry — x/y/w/h, margin/padding, minWidth/maxWidth/minHeight/maxHeight | | [flex.md](flex.md) | default child placement: direction, gap, alignItems, justifyContent, auto margins | | [grid.md](grid.md) | `box.type: grid` — column/row tracks, fill order | | [flow.md](flow.md) | stacking, gap, pagination, what splits and what moves whole | | [style.md](style.md) | every style property, the cascade, named styles, box decoration | | [text.md](text.md) | wrapping, kinsoku, `textOverflow`, long-text pagination | | [vertical_text.md](vertical_text.md) | `writingMode: vertical_rl` / `textOrientation` — vertical text (plain, spans, list, table cells, page_number) | | [link.md](link.md) | `link: { url }` hyperlinks on text/image/spans → PDF annotations | | [data-binding.md](data-binding.md) | `data:` bindings, `{key:format}` interpolation, `bindings:` named declarations, params, format types | | [visible.md](visible.md) | `visible:` — show an item only for some data; the reserve-box default and the `collapse: true` opt-in | | [definitions.md](definitions.md) | `definitions.yml`: the OpenAPI-shaped schema (properties/items, `format` hints, constraints, display variants, params validation) | | [fonts.md](fonts.md) | locales, lang packs, valid `fontFamily` face ids | | [layout-model.md](layout-model.md) | the resolve invariant, box tree, caps, box index | | [diagnostics.md](diagnostics.md) | every diagnostic code by stage | ## The `box:` keys at a glance | Key | Meaning | Page | | --- | --- | --- | | `x` `y` | offset from the parent origin (authoring either opts a container child out of flex) | [box.md](box.md) | | `w` `h` | border-box size; omitted = fill width / auto height | [box.md](box.md) | | `minWidth` `maxWidth` `minHeight` `maxHeight` | CSS-order size clamps | [box.md](box.md) | | `margin` | outer spacing; per-side map; `auto` sides | [box.md](box.md) | | `padding` | inner spacing (non-negative) | [box.md](box.md) | | `type` | child layout mode: `flex` (default) \| `grid` | [flex.md](flex.md) / [grid.md](grid.md) | | `direction` `gap` `alignItems` `justifyContent` | flex keys (grid reuses some) | [flex.md](flex.md) | | `flexGrow` | child's weighted share of leftover row width | [flex.md](flex.md) | | `columns` `rows` `columnGap` `rowGap` | grid tracks & gaps | [grid.md](grid.md) | | `columnSpan` `rowSpan` | grid child's track span (≥ 1) | [grid.md](grid.md) | ## Style properties at a glance Inherited: `fontSize` `fontFamily` `fontWeight` `fontStyle` `letterSpacing` `lineHeight` `color` `textAlign` `lineBreak` `textSpacingTrim` `hangingPunctuation` `writingMode` `textOrientation` `textCombineUpright`. Not inherited: `verticalAlign` `backgroundColor` `borderWidth` `borderColor` `borderStyle` `borderRadius` `textOverflow` `overflow` `textDecoration` `opacity`. Full table with defaults and value sets: [style.md](style.md). ## Not supported yet The cross-cutting list, gathered from the per-feature pages. Where a diagnostic reports the restriction, the entry names its code, so the claim is checkable against [diagnostics.md](diagnostics.md); a structural limit that nothing reports carries a dash instead. A restriction stated here is stated on its own page's **Limitations** section too. | Not supported | Reported as | | --- | --- | | A `table` inside a `repeat` cell, a `repeat_flow` card, or a table column's `cell:` | `table_in_cell` | | Body-cell spanning (colspan). `headerGroups` spans the header row only | `header_group_span_clamped` | | `repeat` / `repeat_flow` / `page_break` outside a flow body | `repeat_in_band`, `repeat_flow_in_container`, `page_break_in_absolute_body`, … | | `page_number` outside a band | `page_number_in_body`, `page_number_in_container` | | A text `mark:` in vertical writing | `vertical_text_unsupported` | | `textOverflow: shrink` / `ellipsis` on a rich `spans` block | `span_overflow_unsupported` | | Per-corner border radii; any radius on a per-side or `double` border, a `table`, or a form mark | `border_radius_ignored` | | SVG constructs outside the subset parser | `svg_unsupported` | | Remote image sources — the render path has no network I/O, by design | `remote_asset_unsupported` | | Flex wrapping: a row is one line | `flex_row_overflow` | | Justified text and hyphenation (`textAlign` is `left`/`center`/`right`) | — | | Barcode symbologies other than QR | — | | Arithmetic in bindings: totals and tax are computed by the host | — | | Per-section page geometry: one `page` block per document | — | ## Reading order for new authors 1. [template.md](template.md) — the file skeleton 2. [box.md](box.md) + [length.md](length.md) — placing things 3. [style.md](style.md) + [fonts.md](fonts.md) — making them look right 4. [flow.md](flow.md) + [table.md](table.md) — variable-length content 5. [data-binding.md](data-binding.md) — wiring in params AI agents authoring a template end-to-end (three files → validate → preview loop) should also load the step-by-step playbook in [skills/shojiku-template-author/](../../skills/shojiku-template-author/SKILL.md) (AI-only — written as instructions to the agent). Runnable examples live in the **repository source** (`examples/` at the repo root) — a docs-only distribution (e.g. what an MCP consumer sees) does not include them; the snippets on each feature page are the self-contained fallback. Each example directory commits its **rendered output** (`output.pdf` + `preview-.png`) next to the sources, so you can see what a template produces without rendering anything; `make examples` regenerates them all. The set (gallery order and one-line pitches: README.md § Gallery): [`examples/business/invoice-ja`](../../examples/business/invoice-ja) (a multi-page A4 invoice: paginating table with repeating header, pre-computed totals, QR + link; `params-short.json` renders the single-page variant), [`examples/business/estimate-ja`](../../examples/business/estimate-ja) (the invoice's sibling: single-rate one-pager, estimate-terms box, a negative discount row), [`examples/business/delivery-note-ja`](../../examples/business/delivery-note-ja) (a delivery note: `headerGroups` spanning band, data-driven `row.conditionalStyles`, a receipt-stamp field — partial ↔ complete delivery as two params files), [`examples/business/pickup-slip-ja`](../../examples/business/pickup-slip-ja) (the Thinreports-migration worked example — the [migration walkthrough](../migration-thinreports.md)'s result, with the legacy `.tlf` + Ruby host beside it), [`examples/forms/application-form-ja`](../../examples/forms/application-form-ja) (an A4 application form: form marks, 〒 entry cells, wareki `placeholder` — a blank ↔ filled-sample params pair), [`examples/business/event-tickets-ja`](../../examples/business/event-tickets-ja) (2×4 n-up event tickets with per-element QR; `params-few.json` = one sheet), [`examples/business/catalog-ja`](../../examples/business/catalog-ja) (a product catalog: `repeat_flow` variable-height cards, dynamic images, `fit: cover`), [`examples/business/shipping-labels-ja`](../../examples/business/shipping-labels-ja) (2×3 n-up shipping labels: 〒 cells, `list` + an overflow-count line, per-order QR), [`examples/forms/certificate-ja`](../../examples/forms/certificate-ja) (an A4 landscape certificate: double border, mincho + letter spacing, seal/medal SVGs, wareki), [`examples/typography/kokugo-print-ja`](../../examples/typography/kokugo-print-ja) (a kokugo worksheet: kanji practice cells + ruby, a vertical copying grid, answer cells), [`examples/typography/novel-ja`](../../examples/typography/novel-ja) (a B5 vertical paperback booklet: ruby-paginating vertical body, strict kinsoku + hanging punctuation, a tate-chu-yoko colophon, vertical page numbers), [`examples/business/restaurant-menu-us`](../../examples/business/restaurant-menu-us) (a US Japanese-restaurant menu: English menu + USD prices with vertical Japanese accents — `writingMode: vertical_rl` brand column + per-dish dish names, double border, mincho), [`examples/business/invoice-en`](../../examples/business/invoice-en) (Letter US-style invoice: USD cents, plural-aware quantities, Net-30 terms block), [`examples/forms/certificate-en`](../../examples/forms/certificate-en) (Letter-landscape certificate: real italics, en-US dates), [`examples/business/receipt-ja`](../../examples/business/receipt-ja) (an A4 receipt: containers, `%`, named styles), [`examples/business/receipt-us`](../../examples/business/receipt-us) (80mm thermal, custom page size), [`examples/business/receipt-zh-tw`](../../examples/business/receipt-zh-tw) / [`examples/business/receipt-zh-cn`](../../examples/business/receipt-zh-cn) (the same receipt geometry under zh locale packs), [`examples/business/receipt-hi-in`](../../examples/business/receipt-hi-in) (Devanagari conjuncts + lakh/crore digit grouping) / [`examples/business/receipt-fil-ph`](../../examples/business/receipt-fil-ph) (Latin face + the Philippine peso) / [`examples/business/receipt-th-th`](../../examples/business/receipt-th-th) (Thai wrapped at word boundaries, dated in the Buddhist era), [`examples/typography/genkoyoshi-ja`](../../examples/typography/genkoyoshi-ja) (a B5 vertical 200-cell genkoyoshi: `char_grid` + aozora ruby) and its horizontal twin [`examples/typography/genkoyoshi-yoko-ja`](../../examples/typography/genkoyoshi-yoko-ja), [`examples/forms/rirekisho-ja`](../../examples/forms/rirekisho-ja) (an A3 landscape JIS-style rirekisho: custom page size, 2-column header, full-width tables), and [`examples/dev/layout-showcase`](../../examples/dev/layout-showcase) — the component-showcase document (rich spans, hyperlinks, flex/grid, overflow policies, zebra table, list, QR, SVG gradient, `repeat_flow`, `repeat` imposition starting in place with trim guides and a document-scoped cell value (`breakBefore: auto`, `cutMarks`, `scope: document`), `page_break`), one labeled section per feature family, each demo followed by a code panel showing the YAML that produces it. The showcase is the **visual index** of the engine, Bootstrap-docs style: its rendered pages show the look and the syntax side by side; open the feature's reference page for the full key table. It grows with the engine — every new authorable feature adds a showcase section (demo + code panel) in the cycle that ships it. --- ## docs/engine/box.md — Position, size, margin, padding and min/max bounds — the border-box every item is placed by. # `box` — position, size, spacing, bounds Every item (except `line` and `page_break`) takes a `box:` map that places and sizes it. Box sizing is **border-box**: `w`/`h` are the outer box, `padding` insets the content, `margin` spaces the box within its parent. Unknown keys are parse errors (`deny_unknown_fields`) — a typo like `alignItmes:` cannot silently mean "unset". ## Syntax ```yaml box: x: 10 # Length; omitted = 0 (flow items ignore y) y: 20 w: "50%" # omitted = fill the parent width (minus margins) h: 40 # omitted = auto height (content-sized) minWidth: 100 # min/max bounds, each a Length maxWidth: "80%" minHeight: 24 maxHeight: 200 margin: 8 # bare number = all sides # margin: { top: 10, left: auto } # per-side map; margin sides accept auto padding: { top: 4, bottom: 4 } # same forms, no auto, non-negative # layout-mode keys (containers / repeat cells / repeat_flow cards only): type: flex # flex | grid (unset behaves like flex) direction: row # flex main axis / grid fill order gap: 10 # Length between flex children alignItems: center justifyContent: space_between columns: ["1fr", "2fr"] # grid tracks: Length or `fr` weight (type: grid only) rows: 2 columnGap: 6 rowGap: 4 ``` ## Position & size | Key | Type | Omitted means | Description | | --- | --- | --- | --- | | `x`, `y` | [Length](length.md) | 0 | Offset from the parent box origin. In a **flow**, `y` is ignored (the cursor owns it) and `x` offsets within the flow region. Authoring `x` or `y` on a container child opts it out of flex placement (see [flex.md](flex.md)). | | `w` | Length | fill parent width minus horizontal margins | `%` resolves against the parent width. | | `h` | Length | **auto height** — grows to content (lowest child bottom for containers, wrapped text height for text) | `%` resolves against the parent height. A definite `h` activates `textOverflow` policies on text. | ## Min/max bounds `minWidth` / `maxWidth` / `minHeight` / `maxHeight` clamp the **border-box** size in **CSS order — min wins over max wins over the size** (`minWidth: 200, maxWidth: 100` resolves to 200). Width bounds resolve `%` against the parent width, height bounds against its height (a `%` height bound against an auto-height parent drops with `percent_of_auto`, like `h`). - An authored `w`/`h` is clamped at resolve; a filled (unset) width is clamped after the fill; an **auto height** is clamped after the content height is known. A `minHeight` taller than the content reserves the extra space, which `verticalAlign` then distributes — and which a flow text carries across a page split, leading the first fragment and trailing the last ([text](text.md) § Pagination of long text); a `maxHeight` shorter than the content behaves like a too-short explicit `h` — content overflows visually, without a warning (the author set the bound). - A fixed flex-row child's width is clamped in the row pre-pass; an *unsized* (flex-share) child's min/max is deferred (needs iterative flex resolution). - The clamped size is what the `inspect` box index reports. ## `margin` — outer spacing A bare number (all sides) or a per-side map (`{ top: 10, left: "5%" }`). **The map specifies all four sides: an unset side is 0**, not some inherited default — `margin: { top: 10 }` means top 10, everything else 0. Positional shorthand strings (`"10 20"`) are rejected with a pointer to the map form; unknown keys are rejected. Only authored keys serialize back. - **`%` resolves against the parent's *width* for all four sides** (the CSS margin/padding rule), so vertical edges stay definite inside auto-height containers. - Margin offsets the box within its parent (`x`/`y` shift by the left/top margin) and reserves space around it: flow siblings space **additively with `gap` — no margin collapse**; auto heights and pagination include margins. A missing `w` fills the parent minus the horizontal margins. - **Negative margins are allowed** (CSS-style overlap), but an atom's reserved height clamps at 0 so the flow cursor stays monotonic. - Margin sides also accept **`auto`** (`margin: { left: auto }`): under flex placement (and horizontally for flow items) auto margins absorb the free space — `left`+`right` auto centers, a single `auto` pushes to the opposite side; elsewhere they resolve to 0. The bare string `margin: auto` is rejected (the map form is the one spelling). ## `padding` — inner spacing Same forms as `margin`, minus `auto`; negative values are rejected at parse. Padding insets the content without growing the box (border-box): a container's child basis, a text item's wrap width and vertical-alignment area, an image's fit box, and `repeat` cells. `rect` has no content and ignores padding. Auto heights grow by the vertical padding; an explicit `h` does not (content overflowing the *content box* warns `container_overflow` / `text_overflow`). Content sizes clamp at 0 when padding exceeds the box. ## Layout-mode keys Only meaningful on boxes with children (`container`, `repeat` cells, `repeat_flow` cards): `type` (`flex` | `grid`; unset behaves like flex), `direction`, `gap`, `alignItems`, `justifyContent`, and the grid track keys `columns` / `rows` / `columnGap` / `rowGap`. See [flex.md](flex.md) and [grid.md](grid.md). On a leaf item's box these keys lay out nothing: `validate` warns `layout_key_on_leaf`; grid keys without `type: grid` warn `grid_key_ignored`. The child-side keys — `flexGrow` ([flex.md](flex.md)) and the grid spans `columnSpan` / `rowSpan` ([grid.md](grid.md)) — are valid on leaves; spans outside a grid parent warn `span_outside_grid` at layout. ## Limitations - `%` needs a definite basis. Against an auto-sized parent axis the value is dropped (`percent_of_auto`) rather than guessed. - Layout-mode keys are CONTAINER keys: on a leaf box they are inert (`layout_key_on_leaf`), and grid keys never imply the mode on their own (`grid_key_ignored`). - `margin` sides accept `auto`; `padding` does not, and padding is non-negative. - A resolved length past ±1e6 pt falls back to the key's default (`length_out_of_range`). - `overflow` is one key for the box, not one per axis. ## Diagnostics | Code | Meaning | | --- | --- | | `length_out_of_range` | resolved length exceeds ±1e6 pt; default used | | `percent_of_auto` | `%` against an auto-height parent; value dropped | | `layout_key_on_leaf` | flex/grid keys on a non-container box | | `grid_key_ignored` | grid keys without `box.type: grid` | | `container_overflow` | content taller than a definite-`h` container's content box | Horizontal overflow warns wherever a definite width can be compared against the box that holds it: `flex_row_overflow` for a fixed-width flex row exceeding its parent, `flow_item_overflow` for a definite-width flow item past the region edge, `child_overflow` for a column or `x`/`y`-positioned child past its parent's content box, and `sheet_overflow` for a band / absolute-body item past the edge of the **sheet** (see [diagnostics.md](diagnostics.md)). The sheet — not the margin box — is the bound for band and absolute-body items on purpose: reaching into the page margins is a deliberate escape hatch (a full-bleed background, a rule wider than the text column), so only ink that leaves the paper is a defect. Items that FILL (no authored `w`) are bounded by their basis and never warn, and a parent with `overflow: hidden` clips by intent and stays silent. Capability keys: `box.margin`, `box.padding`, `box.percent`, `box.minmax`, `margin.auto`. ## See also - [length.md](length.md) — the unit forms every key accepts - [flex.md](flex.md) / [grid.md](grid.md) — child placement modes - [style.md](style.md) — `overflow: hidden` clipping (a style property, not a box key) --- ## docs/engine/char_grid.md — One character per cell: manuscript paper, practice sheets, and form entry boxes. # `char_grid` — manuscript-paper / workbook / form character cells One character per cell in a fixed grid: manuscript paper (genkoyoshi), kanji practice sheets, and application-form boxes (postal-code entry cells). The engine assigns cells — including the school kinsoku hang-back — so the bound string stays verbatim in params. Vertical writing (`vertical_rl`) and Aozora-Bunko-style ruby readings are built in. Capability keys: `char_grid`, `char_grid.markup.aozora`, `char_grid.containers`, `char_grid.textAlign`, `char_grid.markup.aozora.page_break`, `char_grid.markup.aozora.large`, `char_grid.markup.aozora.placement`. ```yaml - type: char_grid data: { key: manuscript } # or text: "静的テキスト{key}" grid: { charsPerLine: 20, lines: 10, cellSize: 9mm, lineGap: 4.5mm } writingMode: vertical_rl # horizontal_tb (default) | vertical_rl markup: aozora # opt-in ruby notation; omit = verbatim style: { fontFamily: ipamj-mincho, borderColor: "#a8674f" } ``` ## Keys | Key | Values | Default | Meaning | | --- | --- | --- | --- | | `text` / `data` | like a text item | — | Content: static text with `{key}` interpolation, or one bound value. Neither set warns `empty_char_grid_item`. An empty string draws a blank sheet (printable manuscript paper). | | `bindings` | map of name → binding | unset | Named declarations for this item's `{name}` interpolations — the option set the bare `{key}` grammar cannot carry, incl. a key outside `[A-Za-z0-9_.]` ([data-binding.md](data-binding.md#named-binding-declarations)). | | `grid.charsPerLine` | integer ≥ 1 | required | Cells per line. | | `grid.lines` | integer ≥ 1 | required | Lines per sheet. `charsPerLine × lines` is clamped to 4096 cells (`char_grid_clamped`). | | `grid.cellSize` | [length](length.md) | derived | Cell side (cells are square). Omitted: the content width divided by `charsPerLine` (horizontal) or `lines` (vertical), gaps subtracted. Non-positive warns `invalid_cell_size` and skips the item. | | `grid.lineGap` | length ≥ 0 | `0` | Space between lines — the ruby band. Ruby for the *first* line draws above (horizontal) / right of (vertical) the grid: reserve room with `box.padding` or leftover box width. | | `grid.charGap` | length ≥ 0 | `0` | Space between cells along a line (workbook-style separated boxes). | | `writingMode` | `horizontal_tb` \| `vertical_rl` | `horizontal_tb` | `vertical_rl` runs lines top-to-bottom, columns right-to-left. | | `kinsoku` | `school` \| `none` | `school` | See below. | | `markup` | `aozora` | unset | Opt-in content markup: ruby (`《》`), the `[#改ページ]` sheet break, the large-writing span notes, and the line-placement notes — see the sections below. Unset, every character (including `《》` and `[#…]`) renders verbatim — bound user data is never interpreted by default. | | `rubySize` | length | 0.4 × cellSize | Ruby font size. Each reading is centered along its base run's extent (horizontal: above it; vertical: beside it) and shrinks to fit when longer — 4pt floor, past which `ruby_overflow` warns. A 2-char reading over a 1-cell base therefore shrinks to ~half a cell per char and stays centered on that cell. | | `box` / `style` / `styleNames` / `id` | common keys | — | `box.w` defaults to the full width; the grid is drawn from the content box's top-left. `id` lands in the box index per sheet page. | Style notes: authored `fontSize` sets the character size; unset it defaults to 0.7 × cellSize (inherited sizes are deliberately ignored — cells are cell-relative). `textAlign` (`left` default / `center` / `right`) is read the same way — from the item's own `style` / `styleNames`, never inherited — and fills a partly filled line toward its END: see [Alignment](#alignment-textalign). `borderWidth` is the **grid line** width (default 0.5pt; `0` turns the grid ruling off), `borderColor` its color. `backgroundColor` fills the grid area; `color` paints the characters. ## Placement and pagination Flow body: content beyond one sheet continues on **full sheets** on following pages (every sheet draws its complete grid, filled or not). Everywhere else — bands, absolute bodies, containers, `repeat` / `repeat_flow` cells — the item draws exactly one sheet; overflowing characters are dropped with `char_grid_overflow`. Inside a repeat cell the content binds element-scoped, so a card list can carry one grid per array element (a data-driven kanji drill: `repeat_flow` + `text: "{kanji}"`). **Side-by-side entry boxes** (postal-code boxes next to a `〒` label, a phone number split `3-4-4`) are a flex row: ```yaml - type: container box: { direction: row, gap: 2mm, alignItems: center } items: - { type: text, box: { w: 6mm }, text: "〒" } - type: char_grid box: { w: 26mm } text: "" # empty string = blank boxes grid: { charsPerLine: 3, lines: 1, cellSize: 8mm } - { type: text, box: { w: 4mm }, text: "-", style: { textAlign: center } } - type: char_grid box: { w: 34mm } text: "" grid: { charsPerLine: 4, lines: 1, cellSize: 8mm } ``` `\n` in the content starts a new line (`\n\n` leaves a blank line); `\r` is ignored. ASCII/half-width characters occupy one cell each. On a **vertical** grid, the inherited [`textCombineUpright`](vertical_text.md) style property groups runs of up to N consecutive ASCII digits into ONE cell (tate-chu-yoko — "12月" costs two cells instead of three); runs longer than N stay one digit per cell, and large-writing spans never combine. ## Alignment (`textAlign`) Cells fill from the line's start by default, which puts a name in a name field at the wrong end. `textAlign` fills toward the line's **END** instead: ```yaml - type: char_grid text: "{applicant.name}" grid: { charsPerLine: 8, lines: 1, cellSize: 8mm } style: { textAlign: right } # left (default) | center | right ``` - The shift is **per line** and runs after cell assignment, so a **full line never moves** — wrapped body text is unaffected and only a partly filled line (the entry-grid case) shifts. `center` floors an odd remainder toward the line's start. - In `vertical_rl` a line runs DOWN a column, so `right` is flush-bottom and `center` centers along the column — the physical keyword names the line's end in both modes (the same mapping vertical text blocks use). - Ruby follows the shifted cells: readings key off cell positions. - A hanging-punctuation cell occupies its line's last cell, which is exactly why that line is full and does not move. A line that line-end kinsoku shortened (an opening bracket pushed to the next line) *is* short, so it de-rags like any other — the prohibition survives, since the pushed bracket still starts a line. - The value is read from the item's own `style` / `styleNames` only. An inherited `textAlign` is ignored, exactly like `fontSize`. ## Sheet break (`[#改ページ]`) Under `markup: aozora`, the Aozora Bunko note `[#改ページ]` ends the current sheet — public-domain aozora texts carry it natively, so a long-form work paginates the way its source says: ```yaml - type: char_grid data: { key: manuscript } # "…序文[#改ページ]第一章…" grid: { charsPerLine: 20, lines: 10, cellSize: 9mm } markup: aozora ``` - **Flow body**: content after the break starts the next sheet (a new page when a sheet fills the region; sheets that fit stack). - **Everywhere else** (bands, absolute bodies, containers, cells): the item is one sheet, so content past the break is dropped with `char_grid_overflow`, like any other overflow. - Breaks **collapse** like `type: page_break` does on a fresh page: a leading break, consecutive breaks, and a trailing break add no sheet. - A `[#…]` note the engine does not act on — not `改ページ`, a large-writing note, or a line-placement note (the two sections below) — renders literally (each character takes a cell) and warns `aozora_note_ignored`, naming the note. The notes are fullwidth-only: `[#改ページ]` in halfwidth is ordinary text, and `[` not followed by `#` is too. A note that does not close within 64 characters stays literal and warns `ruby_markup_invalid`. - Without `markup: aozora` the whole notation renders verbatim, one cell per character — the standing posture that bound data is never interpreted. ## Large characters (`[#「…」は大書き]`) Under `markup: aozora`, a large-writing note draws a character (or short phrase) across an **n×n block** of cells — the dialogue/heading emphasis of manuscript paper: ```yaml - type: char_grid text: "会話《かいわ》[#「会話」は大書き]とは何か。" grid: { charsPerLine: 12, lines: 12, cellSize: 11mm } markup: aozora ``` - The note's `「…」` **target must be the text just before it** (the pending run, or the base of a `《…》` it immediately follows, as above); otherwise it renders literally and warns `ruby_markup_invalid`. - `[#「題」は大書き]` is **2×2**; `[#「題」はN倍の大書き]` is n×n (`N` fullwidth or ASCII, ≤ 2 digits). `N < 2` renders literally and warns; `N` past `min(columns, lines)` clamps with `char_grid_markup_clamped`. - **Block-level placement**: each span character starts a fresh line and fills its own n×n block, blocks filling along the line and wrapping at block granularity; the following content resumes on a fresh line below the block row. A block never straddles a sheet boundary (it is pushed whole to the next sheet). The character draws at `n ×` the cell font size, centered in its block; the grid ruling stays complete underneath. - **Ruby** rides the block run's outer side (above, horizontal; right, vertical) over the full block extent, with the usual shrink-to-fit. ## Line placement (`[#…字下げ]` / `[#地付き]` / `[#中央]`) Under `markup: aozora`, a placement note at a **line head** positions that source line within the grid, overriding the item's `textAlign` for the line — the essay title sheet (title + author) and dialogue indents: ```yaml - type: char_grid text: "[#中央]吾輩は猫である\n\n[#地付き]夏目漱石" grid: { charsPerLine: 12, lines: 12, cellSize: 11mm } writingMode: vertical_rl markup: aozora ``` | Note | Effect | | --- | --- | | `[#N字下げ]` | the line's first physical row starts `N` cells in; a wrapped continuation resumes at the line head (the manuscript-paper convention) | | `[#地付き]` | the line's characters sit at its END (flush-bottom in `vertical_rl`) | | `[#地からN字上げ]` | as 地付き, leaving `N` cells after the run | | `[#中央]` | the line's characters center along it — **a Shojiku extension** (青空文庫 has no centering notation), the others are real aozora notes | - Honored **only at a source line head** (stream start, after `\n`, after a sheet break) and **once per line**; a mid-line or second placement note renders literally and warns `ruby_markup_invalid`. - The placement overrides the item's `textAlign` for its line; a FULL line has no free cells and never moves. `字下げ`/`地から上げ` past the line width clamps with `char_grid_markup_clamped`; `[#0字下げ]` / `[#地から0字上げ]` render literally and warn (`地付き` is the way to write a plain end-flush). A placement + large-writing on the same line places the block row (`[#中央][#「題」は大書き]` centers the block row). - **Blank lines and vertical position** need no markup: a `\n` starts a new line and `\n\n` leaves a blank one, so "put the title a few lines down" is written with leading newlines, not a note. ## Kinsoku (`school`) The school-education rule set used on genkoyoshi: - **Hanging punctuation**: `、 。 , . ! ? 」 』 ) 〕 〉 》 }` never start a line — they share the previous line's last cell, drawn toward its trailing corner. - **Line-end kinsoku**: opening brackets `「 『 ( 〔 〈 《 【 {` never end a line — the cell stays empty and they open the next line. - Small kana (`っ ゃ …`) may start a line (the elementary-school convention). `kinsoku: none` fills strictly in order. The rule sets are closed; extending them is a deliberate decision, not a drive-by edit. ## Vertical writing (`vertical_rl`) Vertical cells shape with the **same GSUB `vert` arrangement** as the free-flowing [vertical text surfaces](vertical_text.md): - `ー` (the long-vowel mark), dashes, and brackets (`「」()—` …) rotate as the font's vertical alternates — no closed substitution table. - `、 。 , .` and small kana sit where the font's vert glyphs place them (the top-right of the cell in any CJK font); a hanging cell additionally drops half a cell so it reads in the trailing corner. - A font the shaper cannot parse degrades per character to the closed presentation-form/nudge tables, so a broken font still renders a readable grid. ## Ruby (`markup: aozora`) The Aozora Bunko notation, so public-domain texts paste straight in: - `《reading》` annotates the maximal run of kanji just before it: `吾輩《わがはい》は猫である`. - `|` (or `|`) scopes the base explicitly: `|昨日《きのう》` — needed when the base includes kana or crosses a kanji-run boundary. - A reading whose base run wraps across lines splits proportionally. Malformed markup (unclosed `《`, empty reading, no base, a reading over 64 chars, dangling `|`) renders literally and warns `ruby_markup_invalid`. Interpolation runs first, so a `{key}` value can carry readings — only when the template opted in with `markup: aozora`. ## Limitations - Outside a flow body — in a band, an absolute body, or a container — a `char_grid` is a SINGLE sheet, and content past it is dropped (`char_grid_overflow`). - 1..=4096 cells per sheet; a grid outside that range is clamped (`char_grid_clamped`). - Only three aozora notes are acted on (sheet break, large writing, placement). Anything else renders literally (`aozora_note_ignored`), and a malformed note is refused (`ruby_markup_invalid`). - A large-writing scale past `min(columns, lines)`, or an indent past the line, is clamped (`char_grid_markup_clamped`). - A cell size that is not positive and finite skips the item (`invalid_cell_size`). ## Diagnostics `char_grid_clamped`, `invalid_cell_size`, `empty_char_grid_item`, `char_grid_overflow`, `ruby_markup_invalid`, `aozora_note_ignored`, `ruby_overflow`, plus the shared content codes (`missing_data`, `missing_glyph`). Full list: [diagnostics.md](diagnostics.md). ## Example [`examples/typography/genkoyoshi-ja`](../../examples/typography/genkoyoshi-ja) — B5 縦書き 200字詰め原稿用紙 rendering the opening of 吾輩は猫である with ruby; [`examples/forms/rirekisho-ja`](../../examples/forms/rirekisho-ja) uses a one-line grid as 郵便番号 boxes. --- ## docs/engine/container.md — An origin and a resolved size: children position, resolve `%`, and inherit against it. # `type: container` A container establishes an origin and a resolved size: children position relative to it, resolve `%` against it, and receive its inherited style properties. Containers nest up to `MAX_CONTAINER_DEPTH` (32). ## Syntax ```yaml - type: container id: amount_box box: { x: "10%", w: "80%", h: 56, padding: 6 } style: { borderWidth: 1.2, fontSize: 11 } # decoration + cascade items: - type: text box: { x: 0, y: 14, w: "100%", h: 28 } text: "{amount.total_in_tax}−" ``` | Key | Type | Description | | --- | --- | --- | | `box` | map | `w` omitted defaults to the parent width minus the x offset; `h` omitted = **auto height** (the lowest child bottom edge). Layout-mode keys (`type`/`direction`/`gap`/`alignItems`/`justifyContent`, grid tracks) select how children place — see [flex.md](flex.md) / [grid.md](grid.md). | | `style` / `styleNames` | | Decoration paints the container's own border box; the **inherited** properties cascade to all descendants ([style.md](style.md)). | | `items` | array | Children. Each child with no authored `box.x`/`box.y` is a flex/grid item; one with either is absolutely placed within the container. | ## Behavior - **Auto height** grows to the lowest child bottom edge plus vertical padding. A `%` length that needs the height of an auto-height container cannot resolve — it drops with `percent_of_auto`. - In a **flow**, containers behave like any other stacked item: `box.y` is ignored, an explicit `h` reserves exactly `h`. Taller content warns `container_overflow` and overflows visually — unless the container opts into `overflow: hidden`, which clips children to the border box and suppresses the warning. - **Not allowed inside containers**: `page_number` (band-only), `repeat` / `repeat_flow` (flow constructs), and `page_break` — all warn and skip. A `table` child IS allowed: it renders as one **bounded** block (no pagination — the pagination keys warn `table_pagination_key_ignored`; see [table.md](table.md)), which is how two tables sit side by side in a `direction: row` container. - Containers keep **atom-unit page breaking** in a flow: a container that doesn't fit moves whole to the next page (no fragmentation). ## Limitations - Nesting is capped at 32; a deeper subtree is skipped (`container_depth_exceeded`). - The flow-only items do not work inside a container: `repeat` (`repeat_in_container`), `repeat_flow` (`repeat_flow_in_container`) and `page_break` (`page_break_in_container`) are skipped, and `page_number` is band-only (`page_number_in_container`). - Content taller than a definite `h` warns (`container_overflow`) unless `overflow: hidden` suppresses it — the content is not clipped by default. - `%` against an auto-height container is dropped (`percent_of_auto`). ## Diagnostics | Code | Meaning | | --- | --- | | `container_overflow` | content taller than a definite-`h` content box; suppressed by `overflow: hidden` | | `container_depth_exceeded` | nesting > 32; subtree skipped (error) | | `percent_of_auto` | `%` of an auto-height container's height | | `page_number_in_container` / `repeat_in_container` / `repeat_flow_in_container` / `page_break_in_container` | unsupported child; skipped (a `table` child IS supported — rendered as one bounded block) | Capability key: `container`. ## See also - [box.md](box.md) — the geometry keys - [flex.md](flex.md) / [grid.md](grid.md) — child placement - [repeat.md](repeat.md) / [repeat_flow.md](repeat_flow.md) — data-driven container reuse (cells/cards are containers) --- ## docs/engine/data-binding.md — How templates bind runtime params and how the locale pack formats them for display. # Data binding & formatting Templates bind runtime data (`params.json`/`params.yml`) into items three ways, and format values for display via the locale pack. ## The three binding forms **`data:` binding** — a single bound value on `text`, `qr_code`, `image`, and (as the array source) `table` / `repeat` / `repeat_flow` / `list`: ```yaml data: { key: order.code } # params key, dot-separated data: { key: amount.total, format: currency } ``` **Interpolation** — inside a static `text:` string on `text` and `qr_code` items: ```yaml text: "合計 {amount.total_in_tax:currency} です" ``` `{key}` inserts the value; `{key:format}` applies a format; `{{` escapes a literal `{`. Keys are `[A-Za-z0-9_.]`, format names `[A-Za-z0-9_-]`. Malformed expressions (unclosed braces, invalid characters, empty key) stay as literal text — templates degrade visibly, never fail. (`{{key}}` in this documentation names the general interpolation mechanism.) **Named binding declarations** — an item-local `bindings:` map giving one `{name}` the full `data:` option set (another key, a scope, a placeholder, a format): ```yaml - type: text text: "コード: {code} / 合計 {total}" bindings: code: { key: order.code, scope: document } total: { key: amount.total, format: currency, placeholder: "—" } ``` See [Named binding declarations](#named-binding-declarations) below. ## Blank-form placeholder A binding draws a `placeholder` when its value is **absent, `null`, or `""`**, instead of leaving the field empty and emitting `missing_data` / `format_error`. This is the "this field is intentionally blank" signal a fillable form needs — one template renders both a blank form and a filled one from two params files. ```yaml # on the placement (templates.yml): data: { key: birth_date, format: wareki, placeholder: " 年 月 日" } ``` ```yaml # on the field (definitions.yml) — also covers {key} interpolation: - key: birth_date type: date placeholder: " 年 月 日" ``` - **Effective placeholder**: the placement's `data.placeholder` wins; otherwise the field's `placeholder` from definitions. An inline `{key}` / `{key:format}` interpolation segment carries no placeholder of its own, so only the field-level one reaches it. - **Drawn verbatim**: the placeholder text is never interpolated and never formatted — a `{…}` inside it stays literal, and it bypasses the type formatter entirely. `placeholder: ""` suppresses the diagnostic and draws nothing (a clean blank). - **Blank ≠ invalid**: only an absent/`null`/`""` value triggers the placeholder. A value that is PRESENT but unusable (a `"abc"` date) still reports `format_error` — a data bug must not hide behind a blank-form placeholder. Whitespace (`" "`), `0`, and `false` are real values, not blanks. - Applies wherever a binding resolves: text `data:`, spans, `qr_code`, `char_grid`, table columns (per row), and `list` entry templates (a blank entry field draws its field-level placeholder). Images have no text placeholder — an absent image key stays `missing_data`. Capability key: `binding.placeholder`. ### Where `{…}` resolves — not always params Several other keys reuse the `{…}` spelling with a **different scope**: | Where | `{…}` resolves against | | --- | --- | | `text:` on `text` / `qr_code` | params (or the bound element inside a repeat cell/card) | | `list.text` | the **array entry** object (`{name}` = entry field) | | `list.overflowText` | the engine token `{count}` only | | `page_number.format` | the engine tokens `{page}` / `{pages}` only | Each surface knows only its own scope: a params key in `page_number.format` (or `{page}` in a `text:`) is not resolved there. ## Scopes Bindings resolve against **top-level params** by default. Three constructs re-scope them to an array element: - a **table column**'s `data.key` is relative to the row object; - a **`repeat` cell**'s bindings resolve against the bound element; - a **`repeat_flow` card**'s bindings likewise. `validate` checks scoped bindings against the array group in definitions. ### `scope:` — the escape back to the document A value that belongs to the whole document rather than the element (a store name on every ticket, one pickup date across a card list) takes the explicit escape: ```yaml - type: text data: { key: store_name, scope: document } # element (default) | document ``` - `element` (the default, and what every template authored before the key existed means) reads the **ambient** scope: the bound element inside one of the three constructs, top-level params everywhere else. - `document` reads **top-level params** even inside a construct. Outside one the two are identical, and `document` is deliberately **inert** there — a sub-template must compose the same way in and out of a `repeat`. The key rides every binding carrier: `text` / `spans` / `qr_code` / `char_grid` / `image` / a `list`'s array key / a table column's `data:`, and a form mark's presence binding (`ellipse` / `checkbox` `data:`). `validate` follows it — a document-scoped key is checked against the top-level scalars (declared field, format variant, params presence) instead of the array group, so the escape never opens an unchecked path. **A bare `{key}` carries no scope.** The `{key:format}` grammar stays two-part by design, so an UNDECLARED name inside a cell always reads the ambient scope. To mix scopes on one line, declare the name — that is what [`bindings:`](#named-binding-declarations) is for: ```yaml - type: text text: "{shop} / {code}" bindings: shop: { key: store_name, scope: document } # the document's value # `code` is undeclared, so it stays the element's own field ``` ([`spans`](text.md) with a `data:` per fragment also works and predates declarations, but `spans` exists for per-fragment *styling*; reach for it when the fragments differ in appearance, not merely in scope.) Capability key: `binding.scope` (older engines parse-reject it). ## Named binding declarations `bindings:` is an item-local map of interpolation **name** → the same options a `data:` binding carries. A declared `{name}` resolves through its declaration; an **undeclared** name keeps its original meaning — the name *is* the key, read at the ambient scope — so every template written before this key existed is unaffected. ```yaml - type: text text: "品名: {hinmei} / 合計 {total}" bindings: hinmei: { key: 品名 } # a key the {…} charset cannot spell total: { key: amount.total, format: currency } ``` It solves two things a bare `{key}` cannot express: - **Keys outside `[A-Za-z0-9_.]`.** `{品名}` is not a valid expression, so it prints its own braces on the page — silently, until the `interpolation_key_charset` warning. A declaration gives the key an ASCII name to be referenced by. - **Options on an interpolated value**: `scope:` (the escape out of a cell), `placeholder:`, `format:`. Rules: - **Which items carry it**: `text` (its `spans` included), `qr_code`, `char_grid`, `list` (its per-entry `text`), and `image` (its `link.url` only). A span has no map of its own — it resolves through the map of the item that owns it. The map also covers each item's `link.url`. - **A declared name and `data.key` are separate namespaces.** A `data:` binding already carries every option and never consults the map. - **An inline `:format` wins** over the declaration's `format` (most-specific, like the style cascade). Everything else — the key, the scope, the placeholder — comes from the declaration. - **A `list`'s declarations resolve per ENTRY** like the entry template itself, unless one authors `scope: document`. - Bounded by 256 declarations per item (`too_many_bindings`, advisory — every declaration keeps working). Diagnostics: `unused_binding` (declared, referenced by nothing), `binding_shadows_key` (the name already resolved and the declaration redirects it — attaching options to the *same* key is silent), `invalid_binding_name` (a name outside the reference charset, so nothing could ever reference it). A declaration whose `key:` does not exist rides the usual `unknown_data_key`, reported at the declaration. Capability key: `binding.declarations` (older engines parse-reject it). ## Params Runtime data owned by the calling application, close to ISO-normalized values: ISO/RFC 3339 datetimes, `yyyy-mm-dd` dates, plain numbers — display formatting is the engine's job. Params never supply geometry: all `x`/`y`/`w`/`h` are template-owned. ## Formats The `format` variant (or `{key:format}`) selects how a value renders: - **A field type name** overrides the type entirely: `string`, `number`, `currency`, `datetime`, `date`, `quantity`, `percentage`. Type names are reserved — a `formats:` registry entry cannot use one. - **A named variant**: a template `formats:` registry name, a lang-pack pattern name (`ja`, `long`, `wareki`), or a currency variant (`default` / `symbol` / `name`). Lookup order: registry → pack. An unknown variant renders the default form and warns (`unknown_format_variant`). - **`symbol`/`name` on a plain number** promote the value to the currency type with that variant (the code rides the `defaults.currency` chain), so a money display needs no definitions type — the picks work on an untyped params number. This coercion wins over a registry entry that happens to be named `symbol`/`name` (registry patterns are date patterns and never applied to numbers). Capability key: `format.currency.coerce`; older engines warn `unknown_format_variant` and render the bare number. - **`value` on a field with enum display labels** renders the machine value instead of the declared label ([definitions.md](definitions.md) § Enum display labels). The label is the default the moment the field declares one — on every carrier, a QR code's encoded content included (a QR has always encoded the FORMATTED value). On a field with no labels a `format` pick stays inert, as it always has (plain text has no variants of its own); on a labeled field an unknown pick warns `unknown_format_variant` and renders the label. ### Precedence (the effective format) Low → high; the highest layer that picks wins: 1. the locale pack's `default` pattern, 2. the template's `defaults.formats.` ([defaults.md](defaults.md)), 3. the field's `displayFormat:` in definitions (per-field default), 4. the placement's `format:` / `{key:format}`. Patterns themselves may be authored ONLY in the central places — the template's `defaults`/`formats:` registry and locale packs; placements and definitions always reference by name (the guard against per-item format sprawl). Per type (locale data = the builtin pack for the locale, with an optional `packs/locale/.yml` per-key overlay — see [fonts.md](fonts.md)): | Type | Rendering | | --- | --- | | `string` | as-is; a declared enum label replaces the value it labels (`format: value` escapes back) | | `number` | grouped digits (locale separators); no explicit precision trims trailing zeros (max 2 decimals). A `symbol`/`name` pick promotes the value to `currency` with that variant | | `currency` | three named variants: `default` = the bare grouped amount (composes with literals), `symbol` = `¥9,000`, `name` = `9,000円`. Precision: the field's `precision:` → the pack's per-code override → the CLDR fractions table (an unlisted code keeps its digits). Code chain: the field's `currency:` → the template `defaults.currency` → pack `currencyDefault` | | `datetime` | CLDR-subset pattern tokens (below); default `yyyy-MM-dd HH:mm` | | `date` | same tokens; default `yyyy-MM-dd` | | `quantity` | number + the unit word for the field's semantic `unit:` key (`item` when unset), plural-aware via the pack (`1 item` / `3 items`); layout from the pack's `unitFormat`. Unknown keys render verbatim + `unknown_unit` | | `percentage` | value × 100 through the locale separators + the pack's `percentFormat` layout | There are no business-document formats beyond these — e.g. no dedicated Japanese qualified-invoice helpers such as per-tax-rate subtotal rows or registration-number fields. Compose them from the primitives (a `table` + `currency`/`percentage` fields, plain `text` for the registration number); business/region-specific formatters are future `plugins/` territory, never engine features ([agents/plugins.md](../agents/plugins.md)). A fixed summary block (the subtotal/tax/total rows every invoice needs) is NOT a table: `table.data` binds an **array** property, while totals are scalar object fields — and the engine does no arithmetic, so the computed values arrive pre-summed in params. The idiom is a `container` of interpolated text lines (the amounts pick a currency variant in-place): ```yaml - type: container box: { w: 240, h: 60 } # or a right-aligned flow child items: - type: text box: { x: 0, y: 0, w: "100%", h: 14 } text: "小計 {totals.subtotal:symbol}" style: { textAlign: right } - type: text box: { x: 0, y: 15, w: "100%", h: 14 } text: "消費税(10%) {totals.tax:symbol}" style: { textAlign: right } - type: text box: { x: 0, y: 32, w: "100%", h: 18 } text: "合計(税込) {totals.total:symbol}" style: { textAlign: right, fontWeight: bold, fontSize: 13 } ``` (Label + value as separate columns instead: make each line a `direction: row` child with `justifyContent: space_between` — [flex.md](flex.md).) ### Pattern tokens (append-only inventory) `yyyy y MMMM MMM MM M dd d EEEE E HH H hh h mm ss a GG G` — a CLDR subset. `MMM`/`MMMM` = month names, `EEEE` = full weekday, `h`/`hh` + `a` = the 12-hour clock with the pack's `dayPeriods`, `G`/`GG`/`y` = the era set (below). Text between `'` quotes is literal (`'at'`), `''` is a literal apostrophe; unquoted non-token characters pass through, so Japanese pattern text needs no quoting. **The inventory is append-only**: existing tokens never change meaning, new ones only extend it. ### Era formatting (wareki) `G` renders the era name, `GG` its abbreviation (令和 → `R`, falling back to the name), and `y` the era year, from the locale's `eras` table (builtin ja-JP: Meiji through Reiwa). The builtin ja-JP pack ships `wareki` variants (`令和7年4月1日`) and a `wareki-compact` variant (`R7.4.1`). Year 1 renders as the pack's `eraYearOne` (ja: `元` → `令和元年`); a date before every era falls back — `G`/`GG` to empty, `y` to the Gregorian year. `yyyy` stays the Gregorian year in any pattern. Without definitions, the type is inferred from the JSON value (RFC 3339 strings become datetimes, `yyyy-mm-dd` strings dates, numbers numbers). ## Definitions (`definitions.yml`) The data dictionary for the Designer/AI/validation — **not required at render time**. An OpenAPI-style schema isomorphic to the params JSON (see [definitions.md](definitions.md)): nested `properties` with JSON-Schema `type`s, `format` as the data-semantic hint, `title`, `example`, `displayFormat` (the field's default variant), `displayFormats` (declared variants for the GUI picker), `currency`, `precision`, `unit` (a **semantic key** like `item` — display words live in the locale pack, so adding a locale never edits definitions), labeled `enum` members (`{ value, label }` — the CALLER's display words, which are business vocabulary and so live here, not in a locale pack); a `type: array` property marks a repeating source (tables, repeats). `validate` cross-checks every template binding against it — unknown keys, undeclared display variants, missing data — and, when params are supplied, the params tree against the schema (`required`, types, ranges, `enum`). ## Limitations - A `{…}` key outside `[A-Za-z0-9_.]` prints its own braces (`interpolation_key_charset`); a `bindings:` declaration is how such a key is reached. - A `bindings:` name outside the reference charset can never be referenced (`invalid_binding_name`), and the per-item registry is capped at 256 (`too_many_bindings`). - An inline `format: { pattern }` applies to `date`/`datetime` only; elsewhere the default form renders (`format_pattern_ignored`). - A currency with no display data falls back to the code itself (`unknown_currency`), and a unit key missing from the pack renders verbatim (`unknown_unit`). - Binding does no arithmetic. Totals, tax and rounding are computed by the host and bound as values — there is no expression syntax. ## Diagnostics | Code | Meaning | | --- | --- | | `unknown_data_key` | binding key not in definitions (validate) | | `unknown_format` | display variant not declared for the field (validate; registry names and the currency variants always pass) | | `missing_data` | params has no value for a bound key (validate + layout); suppressed when a `placeholder` covers the binding | | `not_an_array` | a table/repeat/list source key is not an array | | `format_error` | value cannot render as the requested type (e.g. not a number); a `placeholder` suppresses it for a blank value but NOT for a present-but-invalid one | | `unknown_format_variant` | a picked variant exists nowhere; the default form rendered (layout, deduped) | | `unknown_currency` | a code without display data used the code itself as symbol/name (precision stayed correct) | | `unknown_unit` | a semantic unit key missing from the pack rendered verbatim | | `format_pattern_ignored` | an inline pattern on a non-dated type; the default form rendered | | `interpolation_key_charset` | a `{…}` that looks like an intended key but uses characters outside `[A-Za-z0-9_.]`, so it prints its braces; declare a name for it | | `unused_binding` | a `bindings:` declaration no string in the item references | | `binding_shadows_key` | a declared name that also resolved at the ambient scope, redirected by the declaration (which wins) | | `invalid_binding_name` | a declaration name outside the reference charset, so `{name}` could never reach it | | `too_many_bindings` | more than 256 declarations on one item (advisory; all of them still work) | Capability keys: `format.wareki`, `format.patterns.cldr`, `format.currency.variants`, `format.units.semantic`, `binding.placeholder`, `binding.declarations`. ## See also - [text.md](text.md) / [qr_code.md](qr_code.md) — interpolation users - [table.md](table.md) / [repeat.md](repeat.md) / [repeat_flow.md](repeat_flow.md) / [list.md](list.md) — array scopes --- ## docs/engine/defaults.md — Document-wide presentation defaults and the named format registry — the CSS `:root` analog. # Template defaults & the format registry Document-wide presentation defaults — the CSS-`:root` analog. Elements just bind their key; how a date/currency LOOKS is decided once here, so changing the default changes every placement that didn't override it. ## `defaults:` ```yaml defaults: locale: ja-JP # selects the locale pack currency: JPY # document currency code style: { fontSize: 12, fontFamily: biz-udp-gothic } formats: date: wareki # variant name datetime: { pattern: "yyyy-MM-dd(E) HH:mm" } # inline pattern currency: symbol # ¥9,000 everywhere ``` | Key | Meaning | | --- | --- | | `locale` | Document locale id (BCP 47, e.g. `ja-JP`). The CLI's fallback for picking the locale pack when `--lang` is absent (`--lang` > `defaults.locale` > `ja-JP`). Drives dates, currency display, grouping separators, and units. | | `currency` | Document currency **code** (ISO 4217, e.g. `JPY`). The middle of the currency fallback chain: the field's `currency:` → `defaults.currency` → the pack's `currencyDefault` → `JPY`. Lets currency bindings stay a bare `{key}` with no per-field code. | | `style` | The cascade **root style**. Inherited properties ([style.md](style.md)) flow into every item; the `rem` root follows this style's computed `fontSize` (engine default 10pt when unset — see [length.md](length.md)). | | `formats` | Per-type format defaults: `date` / `datetime` / `number` / `currency` / `percentage` / `quantity`, each a variant-name string **or** an inline `{ pattern: … }` map. Unknown type keys are parse errors. | > `locale` and `currency` were top-level `definitions.yml` keys in > earlier engines; they now live here, the single home for document > presentation defaults. `definitions.yml` no longer carries them — a > stale copy left behind is a located parse error (the definitions wire > rejects unknown keys), so remove them when migrating. Inline patterns use the [pattern grammar](data-binding.md) and apply to `date`/`datetime` only — on other types validate warns (`format_pattern_ignored`) and the default form renders. ## `formats:` — the named registry Reusable named format definitions, parallel to `styles:`: ```yaml formats: stamp: { type: date, pattern: "yyyy.MM.dd" } ``` Placements (and `defaults.formats`) reference entries by name: `data: { key: issued_on, format: stamp }`. v1 entry kinds: `date` | `datetime`. Field-type names (`currency`, …) are reserved (`reserved_format_name`); the registry is capped at 256 entries (`too_many_formats`). ## Precedence Low → high: pack default ← `defaults.formats.` ← definitions the field's `displayFormat:` ← placement `format:`. Details and the diagnostics table: [data-binding.md](data-binding.md). ## Round-trip Everything here is `Option`+skip: an untouched template serializes without `defaults`/`formats`, names stay bare strings, inline maps stay maps. Capability keys: `template.defaults`, `template.defaults.document` (locale + currency), `template.formats`. ## Limitations - A `formats:` entry that shadows a builtin variant name is ignored (`reserved_format_name`), and the registry is capped at 256 entries (`too_many_formats`). - Inline `{ pattern }` defaults apply to `date`/`datetime` only; on another type the default form renders (`format_pattern_ignored`). - A variant name that exists in no pack renders the default form (`unknown_format_variant`). - Defaults are PRESENTATION only. They never supply data, and a missing bound value is still a missing value (`missing_data`). ## See also - [data-binding.md](data-binding.md) — formats, tokens, precedence - [style.md](style.md) — the cascade the root style heads - [length.md](length.md) — the rem root --- ## docs/engine/definitions.md — The data dictionary: the engineer-to-author seam that enriches validation and formatting. # `definitions.yml` — the data dictionary Definitions describe the data a template binds: the engineer↔author seam. They are **not required at render time** — they enrich `validate` (do bound keys exist? do the params match the declared shapes? is this display variant declared?) and tell the formatter a field's type without guessing from the JSON value. The wire is an **OpenAPI-style schema isomorphic to the params JSON**: the same nesting, so an AI can derive it directly from a DB schema or an existing OpenAPI spec — and the Designer can generate sample params from it. The engine advertises this shape as the `definitions.schema` capability key. ## Syntax ```yaml version: "0.2.0" # optional type: object # required — the root is always an object required: [receipt] # optional: required top-level keys properties: receipt: # a top-level OBJECT property = a field group type: object title: 領収書 # optional display name (Designer palette) description: … # optional required: [number] # required child keys properties: number: type: string title: 領収書番号 minLength: 1 # string constraints: length in CHARACTERS maxLength: 20 example: R-2026-0707-001 # Designer/AI sample value issued_at: type: string format: date-time # data-semantic hint (see `format` below) displayFormat: ja # optional field-default DISPLAY variant displayFormats: # optional declared variants (GUI picker) - id: default label: 標準 - id: ja label: 日本語表記 placeholder: " " # optional; drawn when a binding to this field # resolves to a blank value (absent/null/"") — # see data-binding.md § Blank-form placeholder status: type: string enum: # declared value set; a member is a - draft # bare value, or a { value, label } - { value: sent, label: 送付済み } # pair — the label is what a # text placement PRINTS for the value amount: type: object properties: total_in_tax: type: number format: currency # semantic types are `type` + `format` pairs currency: JPY # optional per-field currency code precision: 0 # optional decimal places minimum: 0 # numeric range constraints recommendedStyle: { textAlign: right } # optional, GUI hint items: # a `type: array` property = a table/repeat/ type: array # repeat_flow/list data source title: 明細 minItems: 1 # optional row-count constraints items: type: object title: 明細行 # row display name required: [name] properties: name: type: string quantity: type: integer format: quantity unit: item # SEMANTIC unit key; display words live in the # locale pack (adding a locale never edits this) ``` NOTE: the document `locale` and `currency` are NOT here — they live in the template's `defaults:` block (see [defaults.md](defaults.md)). definitions.yml is the engineer↔author schema seam; presentation defaults belong with the other presentation defaults. ## Types and `format` The base `type` vocabulary is JSON Schema's: `string` | `number` | `integer` | `boolean` | `object` | `array`. `format` is an **open vocabulary** of data-semantic hints. Known values refine how the engine formats the field; unknown values (e.g. `person-name`, `email`, `postal-code`) are generation hints for the Designer/AI sample-data tooling and leave the base type untouched — they never warn. | `type` | `format` | engine field type | | --- | --- | --- | | `string` | — / unknown | string (verbatim) | | `string` | `date-time` | datetime | | `string` | `date` | date | | `string` | `image` | image reference (bundled path, `data:` URI, or inline SVG — the Designer can offer an upload widget) | | `number` / `integer` | — / unknown | number | | `number` / `integer` | `currency` | currency (`currency:`/`precision:` refine it) | | `number` / `integer` | `percentage` | percentage (the value is the FRACTION: `0.1` → `10%`) | | `number` / `integer` | `quantity` | quantity (`unit:` refines it) | | `boolean` | — | boolean (`checkbox`/`ellipse` presence bindings) | A KNOWN semantic format on a base type it does not apply to (`format: currency` on a string) keeps the base type and warns `definitions_format_ignored` — a declared-schema mistake, unlike an unknown hint. The same names work as type overrides in `{key:type}` interpolation ([data-binding.md](data-binding.md)), spelled in the engine's field-type vocabulary (`datetime`, `currency`, …). ## Enum display labels An `enum` member may be authored as a `{ value, label }` pair beside the bare form (the two mix freely in one list): ```yaml status: type: string enum: - { value: shipped, label: 出荷済み } - { value: backorder, label: 入荷待ち } - hold # a bare member renders its value verbatim ``` Params keep carrying the machine value (`"backorder"`); every text placement of the field — a text item, a table column, a list entry, a QR code's content — prints the declared label instead, and `format: value` (or `{key:value}` in an interpolation) prints the machine value ([data-binding.md](data-binding.md)). This replaces host-side display ternaries: the value set and its display words live in ONE declaration, so they cannot drift apart, and a status word never enters the params contract. Labels apply to **plain text fields only** (a `string` with no semantic `format`): every other field type renders through its own formatter (a date pattern, a currency variant), so a labeled member there warns `definitions_enum_labels_ignored` and renders unlabeled. A member with no label falls back to its value silently — partial labeling is legitimate. An empty-string label is authorable and renders empty (the VALUE is non-blank, so the blank-form placeholder does not fire). Membership validation (`params_enum_mismatch`) always matches the member's VALUE; labels never participate. A labeled member must declare a scalar `value` (a container value is a parse error), and a mistyped pair key (`lable:`) is a located parse error like any other unknown key. Capability key: `definitions.enum.labels`. ## Constraints and params validation When `validate` receives BOTH definitions and params, it checks the params tree against the schema — warnings only (rendering proceeds; blanks are the placeholder feature's domain): - `required` keys must be present and non-`null` (`params_missing_required`). - Values must match their declared base type (`params_type_mismatch`; `integer` rejects fractional numbers). - `minimum`/`maximum` bound numbers (`params_out_of_range`); `minLength`/`maxLength` bound string length in characters and `minItems`/`maxItems` bound array length (`params_length_out_of_range`). - `enum` membership (`params_enum_mismatch`). - Params keys not declared anywhere warn `params_unknown_key` (typo detection on the data side; the unknown subtree is not entered). A **blank** value (`null` or `""` — the same predicate the blank-form placeholder uses) skips every check, whatever the declared type: a blank-form params variant fills even number fields with `""`. An empty array is NOT blank and stays subject to `minItems`. ## How templates reference definitions - A scalar binding `data: { key: receipt.number }` binds the **dotted path** through the object properties. - A `table` / `repeat` / `repeat_flow` / `list` binds an **array property's dotted path** (`data: { key: items }`, or `order.lines` nested); its columns/cells then bind row-relative field keys (dotted through nested row objects). - A row's own `type: array` child (a `list` inside a repeat cell) is a data source in its own right. Its key is bound row-relatively (`data: { key: items }` inside the cell), and its `items:` schema describes ONE ENTRY — so the list's per-entry `text:` keys are checked against it, and those entries carry their declared display formats, `placeholder` and `enum` labels exactly as a top-level array's rows do. Nesting is not limited to one level. - A `format:` variant on a binding must appear in that field's `displayFormats` list, the template's `formats:` registry, the currency variants (`default`/`symbol`/`name` on a currency field; `symbol`/`name` also pass on a number field, which they promote to currency at render), `value` on a field with enum display labels (the label escape), or be a type override — `unknown_format` warns otherwise. The field's own `displayFormat:` is its default when the placement picks nothing ([precedence](data-binding.md)). ## Limits and typo safety Unknown keys are **located parse errors**, never silently dropped — including the retired v1 `groups:` form, which is answered with a migration hint naming this page. A misspelled `type:` value is likewise rejected. `pattern` (regex) is reserved and not accepted in this version — semantic generation hints belong in `format`. Caps: schemas nest at most 16 levels (`MAX_SCHEMA_DEPTH`), declare at most 4096 nodes (`MAX_SCHEMA_NODES`), and an `enum` lists at most 256 members (`MAX_ENUM_VALUES`) — labeled or bare. If a definitions file has **zero properties**, it defines no keys, so every template binding reports `unknown_data_key`. Validation surfaces the upstream cause once as `empty_definitions`. ## Limitations - Definitions are not required to render. They enrich `validate`; a document with none still produces the same PDF. - Params checks REPORT, they do not gate: a type, range, enum, length or required violation warns (`params_type_mismatch`, `params_out_of_range`, `params_enum_mismatch`, `params_length_out_of_range`, `params_missing_required`) and the render continues. - A semantic `format` on a base type it does not apply to is ignored (`definitions_format_ignored`), and labeled `enum` members on a field that is not plain text render unlabeled (`definitions_enum_labels_ignored`). - A file that declares no properties makes every binding read as unknown (`empty_definitions`). - `locale` and `currency` are no longer definitions keys — they live in `defaults:`, and a stale copy is a located parse error (`parse_error`). ## Diagnostics | Code | Meaning | | --- | --- | | `unknown_data_key` | a template binding's key is not in definitions (validate) | | `empty_definitions` | definitions was supplied but declares no properties; every binding will read as unknown (validate) | | `unknown_format` | display variant not declared for the field (validate) | | `definitions_format_ignored` | a known semantic `format` sits on a base type it does not apply to (validate) | | `definitions_enum_labels_ignored` | a labeled `enum` member on a field that is not plain text; its values render unlabeled (validate) | | `params_missing_required` | a `required` key is absent or `null` in params (validate) | | `params_type_mismatch` | a params value's JSON type differs from the declared type (validate) | | `params_out_of_range` | a number violates `minimum`/`maximum` (validate) | | `params_length_out_of_range` | a string violates `minLength`/`maxLength`, or an array `minItems`/`maxItems` (validate) | | `params_enum_mismatch` | a value is not in the declared `enum` (validate) | | `params_unknown_key` | a params key is not declared in definitions (validate) | Params diagnostics carry their location in the `key` arg (`items[1].name`), never in `path` — the `path` field's grammar is template box paths. ## See also - [data-binding.md](data-binding.md) — how bindings and formats resolve - [table.md](table.md) / [repeat.md](repeat.md) / [list.md](list.md) — array-property consumers --- ## docs/engine/diagnostics.md — The complete registry of every code the engine can emit, with severity and meaning. # Diagnostics reference Everything the engine wants to tell a human, a GUI, or an AI flows through structured diagnostics: ```json { "severity": "warning", "code": "invalid_font_size", "category": "layout", "message": "fontSize -5 is not a positive finite number; using 10", "path": "sections.body.items[2]", "args": { "value": -5, "default": 10 }, "origin": "layout/src/engine/resolve.rs:126" } ``` with severity `error` | `warning` | `info`. The engine **degrades, it does not panic**: hostile or wrong input produces a diagnostic and a defined fallback. `shojiku validate` reports the static set; `render`/`inspect`/`preview` add the layout-time set. The fields separate concerns so a localizing consumer (the React GUI's ICU catalog) can render its own message and the engine never translates: - **`code`** — a stable machine-readable identifier and the catalog key. Codes and their per-code **arg keys are an append-only frozen contract**: new codes are added, existing ones are never renamed or repurposed. - **`args`** — typed interpolation data (`String | Number | Bool`, serialized as bare JSON scalars); a consumer formats its own localized message from these. String args are control-character-stripped and length-clipped (untrusted echo). Omitted when empty. - **`message`** — the English default rendering of `code`'s template filled with `args`; a fallback for AI/CLI/dev, not the translation. - **`category`** — a coarse, **re-categorizable** domain (`parse` | `data` | `style` | `layout` | `font` | `asset` | `format` | `limits`). Not part of the frozen contract; the emitting module is never folded into `code`. - **`path`** — **always a structural address in the template** (`sections.body.items[2]`, `….cell.items[0]`, `….columns[1]`, `….headerGroups[0]`), in the same grammar as the `inspect` box index, so a consumer can select the offending node. Engine-synthesized structure only: a data key rides in `args.key`, never here. Layout-stage diagnostics carry the innermost item being laid out — including `page_overflow`, which names the item that ran the page count away. Only what is raised before the walk descends into anything carries no path (`page_margin_too_large`, `orientation_ignored`). A once-per-key warning (an unknown `fontFamily`, a repeated formatter degradation) names the FIRST item that triggered it. Capability key: `diagnostics.layout.path`. - **`origin`** — the engine source location (`file:line`) that emitted it. Non-contract, free to churn, and safe to strip from untrusted output; a GUI hides it, AI reads it to investigate. Omitted when absent. Duplicate diagnostics sharing a `(code, path, message)` are collapsed at the output boundary (a warning re-emitted for one item across a measure and a render pass), keeping the first occurrence. ## Parse errors Malformed YAML/JSON, non-finite numbers (`.nan`/`.inf`), unknown keys anywhere in the template (**every wire struct rejects them** — a typo'd key is never a silent no-op), negative padding, `padding: auto`, shorthand edge strings, invalid page sizes, and unknown enum values are **structural parse failures**: the template is rejected outright, so none of the codes below apply. `shojiku validate` surfaces the failure as a single diagnostic instead of an opaque error so a GUI can render it inline: | Code | Severity | Meaning | | --- | --- | --- | | `parse_error` | error | a structural parse failure, with `args` `what` (which artifact) + `path` (the field path, e.g. `sections.body`) + `detail` (the underlying message) + `line`/`column` when known. An error inside an internally-tagged item (`Body`/`Item`) truncates the path to the enum boundary and omits `line`/`column`, so the location is never over-promised | | `non_finite_number` | error | a `.nan`/`.inf`/overflowing number anywhere in the artifact (`args` `what`) | ## Validation (static, `shojiku validate`) | Code | Severity | Meaning | | --- | --- | --- | | `unknown_data_key` | error | a binding key is not in definitions (scalar, column, cell, or card scope) | | `empty_definitions` | warning | definitions was supplied but declares no properties; every binding will read as `unknown_data_key` (check the file's top-level structure) | | `unknown_format` | error | a format variant not declared for the field | | `not_an_array` | error | a table/repeat/list source key is not an array property | | `interpolation_key_charset` | warning | a `{…}` that looks like an intended key uses characters outside `[A-Za-z0-9_.]`, so it prints its own braces; declare a name under `bindings:` to reach the key | | `unused_binding` | warning | a `bindings:` declaration that no string in the item references | | `binding_shadows_key` | warning | a declared name that also resolved at the ambient scope, redirected by the declaration (which wins); attaching options to the same key is silent | | `invalid_binding_name` | warning | a `bindings:` name outside the reference charset, so no `{name}` could reach it | | `definitions_format_ignored` | warning | a known semantic `format` (`currency`, `date-time`, …) sits on a base type it does not apply to; the base type is used | | `definitions_enum_labels_ignored` | warning | a labeled `enum` member (`{ value, label }`) on a field that is not plain text; its values render unlabeled | | `params_missing_required` | warning | a schema-`required` key is absent or `null` in params | | `params_type_mismatch` | warning | a params value's JSON type differs from the declared schema type (`integer` rejects fractional numbers) | | `params_out_of_range` | warning | a number violates the schema's `minimum`/`maximum` | | `params_length_out_of_range` | warning | a string violates `minLength`/`maxLength` (characters), or an array `minItems`/`maxItems` | | `params_enum_mismatch` | warning | a params value is not one of the schema's declared `enum` values | | `params_unknown_key` | warning | a params key is not declared in definitions (the unknown subtree is not entered); location rides the `key` arg, never `path` | | `image_source_conflict` / `image_source_missing` | error | both / neither of `src`+`data` on an image | | `column_content_conflict` / `column_content_missing` | error | both / neither of `data`+`cell` on a table column (`type`/`fit` beside `cell` conflict too); `cell` wins the render — see [table.md](table.md) | | `undefined_style_name` | warning | `styleNames` references a name not in `styles:` | | `too_many_styles` / `too_many_style_names` | warning | registry > 256 / name list > 16; extras ignored | | `span_content_conflict` | warning | `spans` beside `text`/`data` (spans win), or a span with both `text` and `data` (data wins) | | `empty_span` | warning | a span with neither `text` nor `data`; renders nothing | | `empty_ruby_entry` | warning | a `ruby` entry with an empty `base` or `text`; entry skipped | | `ruby_entry_too_long` | warning | a `ruby` entry whose `base` or `text` exceeds 64 characters; entry skipped | | `too_many_spans` | warning | over the 256-span cap; extras dropped | | `too_many_ruby_entries` | warning | over the 256 `ruby`-entries cap; only the first 256 apply | | `ignored_span_style` | warning | span-inert style keys on a span's inline `style` | | `shape_style_ignored` | warning | inert (text/box) keys on a shape item's (`rect`/`ellipse`/`checkbox`/`mark`) inline `style` | | `shape_border_sides_ignored` | warning | per-side `borderWidth` on `ellipse`/`checkbox`/`mark` reduced to the top side | | `layout_key_on_leaf` | warning | flex/grid keys on a non-container box | | `grid_key_ignored` | warning | grid keys without `box.type: grid` | | `table_pagination_key_ignored` | warning | pagination keys (`keepTogether`, `emptyBehavior` …) on a non-flow (bounded) table | | `ignored_column_key` | warning | `fit` on a non-image table column | | `mark_content_conflict` | warning | checkbox sets both `checked` and `data` (`data` wins) | | `mark_binding_not_boolean` | warning | an `equals`-less mark binding targets a non-boolean field | | `row_condition_not_boolean` | warning | an `equals`-less `row.conditionalStyles` entry targets a non-boolean field | | `reserved_format_name` | warning | a `formats:` registry entry shadows a builtin variant name; ignored | | `too_many_formats` | warning | `formats:` registry over the 256-entry cap; extras ignored | | `too_many_row_conditions` | warning | a table's `row.conditionalStyles` over the 16-entry cap; extras ignored | | `too_many_bindings` | warning | an item's `bindings:` over the 256-entry cap; advisory only — every declaration still resolves | | `too_many_document_entries` | warning | `document.keywords` / `document.authors` over the 64-entry cap; only the first 64 are written | | `container_depth_exceeded` | error | nesting > 32 (also enforced independently at layout) | ## Layout — geometry & resolution | Code | Severity | Meaning | | --- | --- | --- | | `length_out_of_range` | warning | resolved length exceeds ±1e6 pt; default used | | `percent_of_auto` | warning | `%` against an auto-height parent; value dropped | | `page_margin_too_large` | warning | margins consume a page axis; that axis falls back to 0 | | `orientation_ignored` | warning | `orientation: landscape` on a custom `{ w, h }` page size is ignored; express the orientation in the dimensions | | `container_depth_exceeded` | error | nesting > 32; subtree skipped | | `container_overflow` | warning | content taller than a definite-`h` content box; suppressed by `overflow: hidden` | | `section_overflow` | warning | an unsplittable item is taller than the flow region | | `horizontal_overflow` | warning | **RETIRED — emitted by nothing.** It carried a whole English sentence in one free-text `detail` arg, which a translating consumer could only pass through. Every reason it covered now has its own number-carrying code (the five below). The entry remains because codes and arg keys are append-only | | `flow_item_overflow` | warning | a definite-width flow item reaches `over` pt past the right edge of the `avail` pt flow region and renders off-sheet | | `flex_row_overflow` | warning | a row's fixed children + gaps need `needed` pt but the parent content box is only `avail` pt wide; unsized children shrink instead and never warn, and `overflow: hidden` suppresses it | | `vertical_text_overflow` | warning | a vertical text block needs `columns` columns (`needed` pt) but its box is `avail` pt wide. The flow paginator reads this code's PRESENCE as "a policy already resolved this block's overflow", so it places whole instead of paginating its columns | | `sheet_overflow` | warning | a band / absolute-body item (a `line`'s endpoints included) reaches `over` pt past the right edge of the **sheet** and renders off-paper. The bound is the paper, not the margin box: reaching into the margins is a deliberate escape hatch, so only ink that leaves the sheet warns. Filling items never warn | | `child_overflow` | warning | a column or `x`/`y`-positioned box child overflows its parent's `avail` pt content box by `over` pt. States the magnitude, never a side — cross-axis alignment runs after the check, and `alignItems: center`/`end` push the excess LEFT. Suppressed under `overflow: hidden`; a ROW child is reported once, by the row-level `flex_row_overflow` | | `grid_column_overflow` | warning | a grid child (`child` pt) is wider than the `track` pt run of `span` column tracks it sits in, so it spills over its neighbour (an `auto` column sizes to its content; a fixed or `fr` one does not) | | `page_overflow` | error | layout exceeded 500 pages; output truncated | | `grid_tracks_clamped` | warning | grid `columns`/`rows` outside 1..=64 tracks; clamped | | `grid_cell_overflow` | warning | grid child TALLER than its explicit row track; the `extent` arg says `row track` or `spanned rows` (auto rows grow instead of warning). The width axis is `grid_column_overflow` above | | `imposition_grid_clamped` | warning | `repeat` grid over 64 cells/page (or a zero axis); clamped | | `char_grid_clamped` | warning | `char_grid` outside 1..=4096 cells/sheet; clamped | | `char_grid_markup_clamped` | warning | an aozora note asks for more cells than the grid holds (a large-writing scale > `min(columns, lines)`, an indent/raise past the line); clamped | | `invalid_cell_size` | warning | `char_grid` cell size not positive and finite; item skipped | | `vertical_text_unsupported` | warning | `writingMode: vertical_rl` reached a text `mark:` (the circled-text overlay), whose glyph-band overlay is horizontal-only; the mark is skipped. Rich `spans` / `list` / table cells / `page_number` now render vertically. See [vertical_text.md](vertical_text.md) | | `vertical_style_ignored` | warning | registered but no longer emitted: the block-level knobs (`textOverflow`, `textDecoration`, `verticalAlign`, `textSpacingTrim`, `hangingPunctuation`) now apply on vertical blocks ([vertical_text.md](vertical_text.md)); the code stays for the append-only GUI catalog contract | ## Layout — placement rules | Code | Meaning | | --- | --- | | `table_in_cell` | `table` inside a `repeat` cell / `repeat_flow` card / a table column's `cell:`; skipped (everywhere else a table places as a bounded block — see [table.md](table.md)) | | `repeat_in_absolute_body` / `repeat_in_band` / `repeat_in_container` | `repeat` outside a flow body; skipped | | `repeat_flow_in_absolute_body` / `repeat_flow_in_band` / `repeat_flow_in_container` | `repeat_flow` outside a flow body; skipped | | `page_break_in_absolute_body` / `page_break_in_band` / `page_break_in_container` | `page_break` outside a flow body; skipped | | `page_number_in_body` / `page_number_in_container` | `page_number` outside a band; skipped | | `grid_span_clamped` | warning | `columnSpan`/`rowSpan` beyond the axis; clamped | | `span_outside_grid` | warning | span keys on a child of a non-grid box; inert | | `grid_fr_no_basis` | warning | `fr` row tracks in an auto-height container; sized as auto rows | | `reflow_budget_exhausted` | warning | too many nested boxes needing a second placement (auto-height `stretch` rows, `flexGrow` columns, `fr`-over-auto grids); the innermost children keep their content size | | `cut_marks_clipped` | warning | `cutMarks` have no room outside the grid on a sheet side; those ticks are omitted | | `header_group_span_clamped` | warning | `headerGroups` spans exceed the table's columns | | `anchor_unknown_target` | warning | a `line` endpoint or an `ellipse` names an `id:` no item carries; the anchored item is not drawn ([line.md](line.md)) | | `anchor_cross_page` | warning | a line's two anchored endpoints land on different pages; the line is not drawn | | `anchor_ambiguous_target` | warning | the anchored id is placed more than once on the page (a `repeat` element, a duplicate id); the FIRST placement in document order is used | ## Layout — content & data | Code | Meaning | | --- | --- | | `missing_data` (warning) / `not_an_array` (error) | params problems at a bound key; `missing_data` is suppressed when a binding/field `placeholder` covers a blank value (data-binding.md) | | `format_error` | value cannot render as the requested type; suppressed for a blank value covered by a `placeholder`, but NOT for a present-but-invalid one | | `empty_text_item` / `empty_qr_code_item` / `empty_image_item` / `empty_char_grid_item` | neither `text`/`src` nor `data` set | | `char_grid_overflow` | content past a band/absolute `char_grid`'s single sheet; dropped | | `ruby_markup_invalid` | malformed aozora markup (unclosed `《`, empty reading, no base, over-cap, dangling `\|`; a large-writing note whose `「…」` target is not just before it or asks for a scale < 2; a placement note off a line head or a second one on a line); rendered literally | | `aozora_note_ignored` | a `[#…]` aozora note the engine does not act on (not a sheet break, a large-writing, or a placement note); rendered literally | | `ruby_overflow` | a reading overflows its base run even at the 4pt floor | | `ruby_base_not_found` | a `ruby` entry's `base` never occurs in the drawn text; reading skipped | | `rect_missing_size` / `image_missing_size` / `qr_missing_size` | required `box.w`/`box.h` absent | | `text_overflow` | wrapped text exceeds a definite `h` (`textOverflow: visible`, or `shrink` at its 4pt floor) | | `span_overflow_unsupported` | `textOverflow: shrink`/`ellipsis` on a rich (`spans:`) block; falls back to `visible` | | `unknown_font_family` | `fontFamily` matches no loaded family; the default face is used (warns once per family) | | `missing_glyph` | characters the font cannot map (tofu); deduped per block | | `unknown_format_variant` | a picked format variant exists nowhere; the default form rendered (deduped) | | `unknown_currency` | a currency code without display data; the code itself used as symbol/name | | `unknown_unit` | a semantic unit key missing from the locale pack; rendered verbatim | | `format_pattern_ignored` | an inline `format: { pattern }` on a non-dated type; the default form rendered | | `mark_missing_size` | a form mark without a positive `box.w`/`box.h` (a checkbox may omit them — it auto-sizes; an `ellipse` may not); skipped | | `mark_equals_type_mismatch` | a mark's params value type differs from its `equals` literal; not drawn. Also raised at validate when the DECLARED field type differs, which no params value can satisfy | | `mark_equals_not_declared` | a mark's `equals` literal is outside the field's declared `enum`, so the mark can never be drawn | | `mark_value_not_bool` | an `equals`-less mark binding's value is not a boolean; not drawn | | `row_condition_type_mismatch` | a row's value type differs from a `conditionalStyles` entry's `equals`; the layer is not applied. Also raised at validate against the DECLARED field type | | `row_condition_equals_not_declared` | a `conditionalStyles` entry's `equals` literal is outside the field's declared `enum`, so the layer can never apply | | `row_condition_value_not_bool` | a row's value is not a boolean under an `equals`-less `conditionalStyles` entry; the layer is not applied | | `visible_not_boolean` | an item's `visible:` field is declared non-boolean and the binding has no `equals`, so the item can never be shown | | `visible_equals_not_declared` | an item's `visible.equals` literal is outside the field's declared `enum`, so the item can never be shown | | `visible_type_mismatch` | an item's params value type differs from its `visible.equals`; not shown. Also raised at validate against the DECLARED field type | | `visible_value_not_bool` | an `equals`-less `visible:` binding's value is not a boolean; not shown | | `qr_content_too_long` / `qr_module_too_small` | QR content over 1 KiB (skipped) / modules under 1 pt (drawn) | | `unsupported_link_scheme` / `link_url_too_long` / `empty_link_url` | resolved `link.url` outside http/https/mailto/tel (or control chars) / over 2048 bytes / empty — the link is dropped, the item still renders | | `document_metadata_control_chars` / `document_metadata_too_long` / `invalid_document_language` | a resolved `document:` value carries control characters / is over its byte cap (2048, or 64 for `language`) / is not a `[A-Za-z0-9-]` language tag — that field is not written to the PDF and is NOT replaced by its fallback | | `table_too_wide` | sized columns exceed the flow width | | `row_overflow` | a row overflows with `autoPageBreak: false` | | `invalid_column_width` / `invalid_row_height` / `invalid_cell_padding` | negative table geometry; clamped to 0 / treated as auto | ## Layout — style guards (hostile values) | Code | Fallback | | --- | --- | | `invalid_font_size` | 10 pt | | `font_size_out_of_range` | 10 pt (cap 1000 pt) | | `invalid_line_height` | 1.4 | | `line_height_out_of_range` | 1.4 (cap 1000×) | | `invalid_letter_spacing` | 0 (magnitude cap ±1000 pt) | | `invalid_flex_grow` | 0 (negative / non-finite `flexGrow`) | | `invalid_border_width` | no border (cap 0..=1000 pt) | | `invalid_line_width` | 1 pt, the `line` wire default (cap 0..=1000 pt) | | `invalid_border_radius` | square corners (a negative or non-finite radius) | | `border_radius_ignored` | square corners (a per-side/`double` border, a `table`, or a form mark cannot round) | | `invalid_opacity` | opaque (out-of-range / non-finite `opacity`; never invisible) | | `invalid_color` | default color (echo snippet-capped) | ## Assets (`prepare_assets`) | Code | Meaning | | --- | --- | | `missing_asset` / `assets_root_missing` / `asset_traversal` | bundled path absent / no assets root / path escapes the root | | `invalid_image_asset` (error) / `invalid_image_data` (warning) | undecodable static asset / dynamic data | | `remote_asset_unsupported` | remote URL source; the render path has no network I/O | | `dynamic_image_denied` | params-bound image blocked by the asset policy | | `svg_unsupported` | SVG constructs outside the subset parser | | `cell_image_assets_capped` | per-element cell images (table columns + repeat/repeat_flow cells) over the shared 1000-load cap; the rest are skipped | ## Limitations - Codes and argument keys are APPEND-ONLY: an existing code never changes meaning, so a consumer that matches on one keeps working across engine versions. - The engine does not translate. Messages are English and carry typed arguments; rendering them in another language is the consumer's job. - A retired code stays listed and is emitted by nothing, rather than being removed (`horizontal_overflow`, `vertical_style_ignored`) — a matcher keyed on it must not start matching something else. ## See also - [layout-model.md](layout-model.md) — the resolve caps that emit the geometry codes - Each feature page lists the codes it can emit. --- ## docs/engine/document.md — Document metadata written into the PDF's properties: title, description, keywords, language, authors. # `document:` — document metadata What the file says it *is*, as opposed to what it draws: the title, description, keywords, language and authors that land in the PDF's document properties. A reader's Properties panel shows them, a search index reads them, a screen reader announces the language — and an AI consumer handed the file can tell what it is holding without parsing the page. Nothing here appears on the page. PNG previews carry none of it (the format has no metadata channel), exactly as they carry no [links](link.md). ## Syntax ```yaml document: title: 請求書 {invoice.number} description: '{invoice.subject}({billing.name} 様)' keywords: [請求書, invoice, '{invoice.number}'] language: ja-JP authors: ['{issuer.name}'] ``` | Key | Type | Description | | --- | --- | --- | | `title` | string | The document title. Unset → the template `name:`, then `Shojiku Document`. | | `description` | string | A short summary (the PDF `/Subject`). | | `keywords` | list of strings | Search keywords. Max 64 entries. | | `language` | string | BCP 47 tag (`ja-JP`). Unset → `defaults.locale`. | | `authors` | list of strings | Max 64 entries. | Every value takes `{key}` / `{key:format}` interpolation exactly like static text, resolved against top-level params. Unknown keys inside `document:` are parse errors (typo safety). There is no `bindings:` map here, so an interpolation name must be inside the reference charset (`A-Z a-z 0-9 _ .`) — a non-ASCII params key cannot be referenced from `document:`, and writing one warns `interpolation_key_charset` like anywhere else. ## Where each value goes | Key | PDF `/Info` | XMP | Also | | --- | --- | --- | --- | | `title` | `/Title` | `dc:title` | | | `description` | `/Subject` | `dc:description` | | | `keywords` | `/Keywords` (comma-joined) | `pdf:Keywords` | | | `authors` | `/Author` (comma-joined) | `dc:creator` | | | `language` | — | `dc:language` | the catalog `/Lang`, which is what assistive technology reads | ## Rules - **Blank is unset.** A value that interpolates to nothing (a blank binding) writes nothing and reports nothing beyond the ordinary `missing_data` warning; an empty list entry is dropped. - **A rejected value is not replaced.** The `title` → `name` and `language` → `defaults.locale` fallbacks cover an *absent* value only. If a value is rejected (below), the field is simply not written — a substituted value would hide the refusal behind plausible output. - **Rejections** (each a warning of its own; the field is dropped and everything else still renders): - control characters in any value — they are invalid in the XMP packet whatever the escaping, and confuse readers in `/Info`; - a value over 2048 bytes after interpolation; - a `language` that is not `[A-Za-z0-9-]`, or over 64 bytes. The tag is the one metadata value written into the XMP packet *unescaped*, so the charset is a hard requirement rather than tidiness. - **Over-long lists** warn `too_many_document_entries` and only the first 64 entries are written. - **No creation date.** `creationDate` is deliberately not authorable and none is written: a rendered timestamp would make the same inputs produce different bytes, and byte-identical output is what signing and verification rest on. ## Limitations - PDF only. PNG previews have no metadata channel, so nothing here is observable in a preview. - `keywords` and `authors` are capped at 64 entries; only the first 64 are written (`too_many_document_entries`). - Each value is capped at 2048 bytes, and `language` at 64 (`document_metadata_too_long`); control characters are refused (`document_metadata_control_chars`) and `language` must be a `[A-Za-z0-9-]` tag (`invalid_document_language`). ## Diagnostics | Code | When | | --- | --- | | `document_metadata_control_chars` | a resolved value carries control characters | | `document_metadata_too_long` | a resolved value is over the byte cap (2048; 64 for `language`) | | `invalid_document_language` | `language` is not a `[A-Za-z0-9-]` tag | | `too_many_document_entries` | `keywords` / `authors` over the 64-entry cap | | `missing_data` | an interpolated key is not in params | | `interpolation_key_charset` | a `{…}` name that cannot be an interpolation key | Capability key: `template.document.metadata`. --- ## docs/engine/flex.md — The default container mode: CSS-flex semantics down to the defaults, keyed on `box.type`. # Flex layout (`box.type: flex`, the default) A container box (and a `repeat` cell / `repeat_flow` card) lays out its children **flex-like by default** — the layout-mode key lives on the box (`box.type`; unset behaves the same as `flex`). ## Syntax ```yaml - type: container box: { h: 120, direction: row, gap: 10, alignItems: center, justifyContent: space_between } items: - type: image box: { w: 36, h: 36 } src: logo.svg - type: text # no w: takes the leftover width text: "{issuer.name}" ``` ## Keys (on the container's `box`) | Key | Values | Default | Description | | --- | --- | --- | --- | | `direction` | `column` \| `row` | `column` | Main axis. `column` stacks; `row` sets children side by side. | | `gap` | [Length](length.md) | 0 | Main-axis gap between flex children (`%` of the main-axis content size; negative = 0). Absolutely placed children ignore it. | | `alignItems` | `stretch` \| `start` \| `center` \| `end` \| `baseline` | `stretch` | Cross-axis alignment. `stretch` fills an unset cross size: a child with no `w` fills a `column`'s width, and a child with no `h` **is resized to** a `row`'s cross size — the row's own height when it has one, otherwise its tallest child's (CSS Flexbox §9.4). A cross-axis `auto` margin opts a child out, since an `auto` margin beats alignment everywhere else here too. `baseline` aligns row children on their **first text baseline** — a child with no text (a mark, rect, image, or clipped box) synthesizes its baseline from its bottom edge, so a `checkbox` bottom sits exactly on its label's baseline (the natural look for label + mark rows; `center` centers *line boxes*, which reads as skew when the font carries large below-baseline space). In a column it behaves like `start` (the CSS fallback); cross-axis auto margins win over any alignment. | | `justifyContent` | `start` \| `center` \| `end` \| `space_between` \| `space_around` \| `space_evenly` | `start` | Main-axis distribution of free space when the container's main size is definite (an auto-height column has none, so it is inert there). Negative free space degrades the CSS way (`space_*` act like `start`). | Keys are camelCase, values snake_case — like every wire enum. ## Child key (on a flex item's own `box`) | Key | Values | Default | Description | | --- | --- | --- | --- | | `flexGrow` | number ≥ 0 | 0 | The child's share of the **leftover** main-axis size, among children without an authored size on that axis (CSS `flex-grow`). `flexGrow: 2` next to `flexGrow: 1` takes ⅔ of what is left after every child's basis. The default is CSS's: nothing grows unless asked. | | `flexBasis` | `content` \| `0` | `content` | The main size such a child **starts from**, before `flexGrow` shares out the rest (CSS `flex-basis`). `content` is its max-content width — the width at which its text would not wrap. `0` starts it at nothing, so `flexGrow` divides the whole row: that is CSS's `flex: 1`, and it is what this engine used to do unconditionally. A length basis is deliberately not accepted — `w` already sizes a child. | Unlike the container keys above, these are **child** properties, so they are valid on a leaf box (a `text` without `w` sizes to its content). Both are inert on a child with an authored size on the main axis, and under `grid` (cells are track-sized). `flexGrow` works on **both axes**, each on its own main axis: in a `row` it shares out leftover WIDTH, and in a `column` leftover HEIGHT, against a definite parent height. `flexBasis` is a row key — a column child's basis is its content height, which the engine measures rather than letting you name. **Not every kind has a max-content width.** Where one is undefined the child keeps a plain share of the row, exactly as `flexBasis: 0` would: - `rect`, `ellipse`, `image` and `qr_code` **require** an authored `w`/`h` and warn without one, so an unsized one never lays out at all — there is no case for a basis to size. - **vertical-writing text** has no width-intrinsic size: its inline axis runs downward, so its horizontal extent is a function of the available HEIGHT, not of the text. - **`table`** resolves its column widths as `%` of the region it is placed in, so it has no region-free intrinsic width. - **`list`, `char_grid`** and **rich `spans` text** are measurable in principle but resolve their content through the data scope or a per-span style chain; they are not measured today. - A **`container`** measures its own children — side by side in a `row`, the widest in a `column`. One with no flex children at all (only absolutely placed ones, or only a `line`) measures 0, as CSS says of a flex container with nothing in flow. ## Participation rule A child that authors **neither `box.x` nor `box.y`** is a flex item; a child with either keeps absolute placement (the escape hatch every pre-flex template already uses, so existing templates are unchanged). Paint order stays document order. ## Behavior - **Main axis**: flex items place in document order with `gap` between them. In a `row`, children with an authored `w` keep it; children without **start at their content width and then split the leftover by their `flexGrow` weight** — CSS's `flex-basis: auto`. Write `flexBasis: 0` together with `flexGrow: 1` — CSS's `flex: 1` — for a child that starts from nothing and takes an equal share of the whole row. That pair is the migration for any template written before the `flexGrow` default became 0. A negative / non-finite `flexGrow` warns (`invalid_flex_grow`) and contributes 0; if every unsized child weighs 0 the split falls back to equal so the row is never silently empty. - **A `column` shares leftover HEIGHT the same way**, when the parent height is definite: a child with no `h` starts from its content height and takes its `flexGrow` share of what is left. It costs nothing unless asked for, since the default is 0. An auto-height column has no leftover to share, exactly as CSS says of an indefinite main size. - **A row that cannot fit shrinks before it overflows.** When the children's content is wider than the container they give the excess back in proportion to their bases — CSS's `flex-shrink: 1` — so text re-wraps rather than running off the edge. `minWidth` floors that (and `maxWidth` caps growth): a child pinned at a bound stops flexing and its share moves to the others. Only a row that still does not fit afterwards warns `flex_row_overflow`. - **Auto margins beat alignment and justification** (CSS order): on the main axis they absorb all free space before `justifyContent`; on the cross axis they override `alignItems`. In a row, unsized children consume the free space first, so auto margins and `justifyContent` only act when every child has a width. - **The flow body** is the column-flex special case with pagination; flow items additionally honor **horizontal** auto margins (`margin: { left: auto }` right-aligns a fixed-width flow item). Vertical auto margins do nothing there, which is CSS: a block-level box in normal flow gives `margin-top: auto` and `margin-bottom: auto` a used value of 0 (CSS 2.1 §10.6.3), and they absorb free space only in flex and grid. For a block pinned to the bottom of every page, use a `footer` band; for one at a fixed offset, an absolute `box.y`. Bands and the absolute body are untouched (absolute placement). - **`%` resolves against the container**, for every flex child. A `%` length or `%` margin on an unsized row child means a share of the flex container's content width, as in CSS — not of the slot the child happened to get, which would make one authored `10%` mean different numbers depending on how many siblings there were. - **Known deviation from CSS**: a `column` grows but never SHRINKS. When its children's content heights already exceed a definite parent height they keep them, rather than being squeezed in proportion the way a row's widths are. Shrinking a width makes text re-wrap and stay visible; height has no equivalent, so a column shrink would only clip — and `column` is the default `direction`, so it would re-size the children of every container ever authored. Deliberate, and the reason is the asymmetry rather than the effort. ## Limitations - No wrapping. A row is one line: fixed children plus gaps wider than the content box warn (`flex_row_overflow`), and unsized children shrink instead and never warn. - A negative or non-finite `flexGrow` is treated as 0 (`invalid_flex_grow`). - Flex keys on a leaf box are inert (`layout_key_on_leaf`). - There is no `position` key: authoring `x` or `y` is what opts a child out of flex, and it opts out of BOTH axes. - The second-placement pass is budgeted; past it the innermost children keep their first placement (`reflow_budget_exhausted`). ## Diagnostics | Code | Meaning | | --- | --- | | `layout_key_on_leaf` | flex keys on a leaf item's box (nothing to lay out) | | `invalid_flex_grow` | `flexGrow` is negative / non-finite; degraded to 0 | | `reflow_budget_exhausted` | too many nested boxes needing a second placement; the innermost keep their content size | Capability keys: `box.flex`, `box.flexGrow`, `box.flexBasis`. ## See also - [grid.md](grid.md) — the explicit track-based mode - [box.md](box.md) — auto margins, participation via `x`/`y` - [container.md](container.md) — the item that hosts flex children ## Overflow A row whose fixed widths + gaps exceed the parent content box warns `flex_row_overflow` (unsized children shrink their shares instead and never warn). A parent with `overflow: hidden` clips by intent and stays silent. Definite-width flow items reaching past the flow region's right edge warn the same code. Individual children are checked too, against the box they were placed in: a **column** child or an `x`/`y`-positioned (absolute) child whose border box plus right margin passes its parent's content box warns `child_overflow`, and the diagnostic names the CHILD (`items[i]`), not the container. A ROW child is deliberately not re-checked here — the row-level check above already speaks for it, and both firing would report one overflow twice. The message states **how much** a child overflows by, never which side it spills off: cross-axis alignment is applied after the check, and an over-wide child under `alignItems: center` puts half the excess past the LEFT edge, under `end` all of it. Only the amount is invariant. (Auto margins absorb nothing when space is already short, so they never move an overflowing child.) --- ## docs/engine/flow.md — A flow body stacks items top-down and paginates when content passes the region bottom. # Flow — stacking & pagination A `type: flow` body stacks items top-down with `gap` between them and paginates when content passes the region bottom. "Auto move up" is inherent: a short table pulls later items up. ## Syntax ```yaml sections: body: type: flow box: { x: 0, y: 105, w: "100%", h: 620 } # omitted = whole margin box gap: 16 # pt between stacked items items: - { type: text, text: "…" } - { type: table, data: { key: order_items }, columns: [ … ] } - { type: page_break } - { type: text, text: "next page" } ``` | Key | Type | Default | Description | | --- | --- | --- | --- | | `box` | map | whole margin box | The region the flow occupies on every page, resolved against the page margin box (`%` works). | | `gap` | [Length](length.md) | 0 | Vertical gap between stacked items, additive with item margins (no collapse). `%` resolves against the flow-region height (matching `repeat_flow.gap`); negatives clamp to 0. | | `items` | array | `[]` | Laid out in order. | ## Behavior - **Stacking**: each item occupies its resolved height at the cursor; `box.y` is ignored (`box.x` still offsets horizontally). Fixed-width flow items honor **horizontal auto margins** (`margin: { left: auto }` right-aligns). - **Pagination**: an item that doesn't fit moves to the next page as a whole — each flow item is an *atom*, the indivisible unit the paginator places — with these refinements: - **Tables** paginate row by row and repeat headers ([table.md](table.md)). - **Auto-height flow text** taller than the region splits at line boundaries the same way: it fills the space left on the current page, then continues line by line, decoration and padding cloned onto every fragment. A definite-`h` text never splits — that overflow belongs to [`textOverflow`](text.md). - **`repeat`** aligns its grid to fresh pages, unless `breakBefore: auto` starts it at the cursor (a shorter first page, same-size cells — [repeat.md](repeat.md)); **`repeat_flow`** paginates card-by-card ([repeat_flow.md](repeat_flow.md)). - **`type: page_break`** starts the next item on a fresh page; a break at the top of an untouched page is a no-op, so consecutive breaks collapse and blank pages are never generated ([page_break.md](page_break.md)). - Any other item taller than the flow region warns `section_overflow` and draws over; more than 500 pages (`MAX_PAGES`) errors `page_overflow` and truncates output. ## Flow-only items `repeat`, `repeat_flow`, and `page_break` are only valid directly in a flow body — in bands, absolute bodies, or containers they warn (`_in_band` / `_in_absolute_body` / `_in_container`) and are skipped. A `table` is not flow-only: it only *paginates* in the flow body — in a band, absolute body, or container it renders as one **bounded** block instead ([table.md](table.md) § Placement with `box`); the one place a table cannot go is inside a `repeat` / `repeat_flow` / `cell:` cell (`table_in_cell`). ## Limitations - An item that cannot be split and is taller than the region overflows rather than shrinking (`section_overflow`). - Layout stops at 500 pages and truncates the output (`page_overflow`). - Only a flow body paginates. An absolute body is a single page, and a band repeats rather than continuing. - Blank pages are never generated: a `page_break` with nothing after it produces no page. ## Diagnostics | Code | Meaning | | --- | --- | | `section_overflow` | an unsplittable item is taller than the flow region | | `page_overflow` | layout exceeded 500 pages; output truncated (error) | ## See also - [layout-model.md](layout-model.md) — the box tree and bases - [flex.md](flex.md) — the flow body is the paginating column-flex case --- ## docs/engine/fonts.md — Locale packs and font packs: where formatting data and typefaces come from. # Locales & fonts (packs) Formatting (dates, numbers, currency, era tables) and fonts come from two places, split by how they scale: - **Builtin locale data** — `ja-JP` and `en-US` chrome (CLDR-generated) is compiled into the engine; `--lang ja-JP` needs no locale file. A `packs/locale/.yml` file is an **overlay** deep-merged over the builtin per key (mappings merge recursively, scalars/sequences replace), or the whole pack for a locale with no builtin. It **references font packs by id**. Cheap, per-country. - `packs/fonts//` — a fonts-only pack: `manifest.yml` + font files, shared across locales (one Latin pack serves many locales). Per script. Templates stay locale-independent; the locale data supplies the defaults at render time. ## Which locale is used Resolution order (first match wins): 1. `--lang ` on the CLI (e.g. `--lang en-US`) 2. `defaults.locale` in `templates.yml` ([defaults.md](defaults.md)) 3. `ja-JP` (the engine default) Builtin matching is case-insensitive and a bare language tag selects its unique builtin (`--lang ja` → `ja-JP`). For overlays and non-builtin locales the id is lowercased to a filename (`ja-JP` → `ja-jp.yml`), looked up across the **locale search dirs**. An id that is neither a builtin nor a file errors, listing the builtin ids and searched dirs; `shojiku capabilities` reports the builtin list as `builtinLocales`. ## Where packs are found Both fonts and locales use an **additive search list**, highest priority first — later entries add to (never replace) earlier ones, so a `--font-dir` shadows the bundled packs on a matching face id: | Kind | Search order | | --- | --- | | Locale files | `--locale-dir` (repeatable) → `$SHOJIKU_LOCALE_DIR` → `./packs/locale` | | Font packs | `--font-dir` (repeatable) → `$SHOJIKU_FONT_DIR` → `./packs/fonts` | An unset flag simply contributes nothing. The Docker image bakes `$SHOJIKU_FONT_DIR` / `$SHOJIKU_LOCALE_DIR`. ## Builtin locales Regenerated from CLDR by `scripts/gen-locale-builtins.py` into `engine/formatter/src/lang/builtin/` (checked in; the render path stays network-free): | Locale | Notes | | --- | --- | | `ja-JP` | The default. `uses: [biz-ud, ipamj-mincho, noto-sans-mono]`, `¥`/JPY currency, `yyyy/MM/dd(E)`-style date patterns, weekday short names, the modern era table (Meiji through Reiwa) + `wareki` date/datetime variants. | | `en-US` | `uses: [noto-sans, noto-sans-mono]`, USD default, `MM/dd/yyyy`-style patterns. | ### Shipped locale packs (`packs/locale/`) Every other locale is a **pack file**, not engine data — generated from the same script's `PACK_CONFIG` into `packs/locale/.yml` and found via `--locale-dir` / `$SHOJIKU_LOCALE_DIR` (default `./packs/locale`), or injected as a string by a WASM host. Adding one needs no engine change. | Locale | Notes | | --- | --- | | `zh-TW` | `uses: [noto-sans-tc, noto-sans-mono]`, TWD default (CLDR `$` — the local dollar), `y年M月d日` patterns, weekday short names. | | `zh-CN` | `uses: [noto-sans-sc, noto-sans-mono]`, CNY default (`¥`), `y年M月d日` patterns, weekday short names. | | `hi-IN` | `uses: [noto-sans-devanagari, noto-sans, noto-sans-mono]`, `fallback: [noto-sans]` for Latin, INR default (`₹`), `d MMM y` patterns. Groups digits in 3s, not lakh/crore — see [features.md](../engine/features.md) § Locale data. | | `fil-PH` | `uses: [noto-sans, noto-sans-mono]` (Latin script — no font of its own), PHP default (`₱`), `MMM d, y` patterns. | | `th-TH` | `uses: [noto-sans-thai, noto-sans, noto-sans-mono]`, `fallback: [noto-sans]` for Latin, THB default (`฿`), `d MMM y` patterns. Dates carry the **Buddhist era**: the pack declares one open-ended era, so `y` renders 2026 CE as 2569 BE and `yyyy` stays Gregorian (the `gregorian` variant). Thai also wraps at word boundaries — see [text.md](text.md) § Wrapping & line breaking. | ## Font packs & `fontFamily` `style.fontFamily` selects a **font family** by its global id; unset uses the locale's `fonts.default`. Families/face ids are a **flat global namespace** — a pack is just physical grouping, so the same id resolves however the packs are laid out. The bundled packs provide: | `fontFamily` | Pack (license) | Notes | | --- | --- | --- | | `biz-udp-gothic` | `biz-ud` (OFL-1.1) | **the ja default**; proportional kana; real bold | | `biz-ud-gothic` | `biz-ud` (OFL-1.1) | fixed-pitch (aligned digits, full-width kana); real bold | | `ipamj-mincho` | `ipamj-mincho` (IPA-1.0) | fallback-only mincho, MJ set (~55k glyphs) for the rare-name tail (e.g. `𠮷`); bold/italic synthetic | | `noto-sans` | `noto-sans` (OFL-1.1) | **the en-US default**; real bold / italic / bold-italic (no synthetic slant); also the fil-PH default and the hi-IN / th-TH Latin fallback | | `noto-sans-mono` | `noto-sans-mono` (OFL-1.1) | monospace for code/technical text (every bundled locale); real bold; CJK via the locale fallback chain | | `noto-sans-tc` | `noto-sans-tc` (OFL-1.1) | **the zh-TW default**; Traditional Chinese + its own Latin; real bold; OTF/CFF outlines | | `noto-sans-sc` | `noto-sans-sc` (OFL-1.1) | **the zh-CN default**; Simplified Chinese + its own Latin; real bold; OTF/CFF outlines | | `noto-sans-devanagari` | `noto-sans-devanagari` (OFL-1.1) | **the hi-IN default**; Devanagari only (Latin comes from the `noto-sans` fallback); real bold | | `noto-sans-thai` | `noto-sans-thai` (OFL-1.1) | **the th-TH default**; Thai only (Latin comes from the `noto-sans` fallback); real bold | Each pack ships its license text (`OFL.txt` / `IPA_Font_License_Agreement_v1.0.txt`) beside the fonts. An unknown family warns `unknown_font_family` (once per family) and falls back to the locale default face. ### A locale's font policy The locale's `fonts:` block references packs and names its default / fallback faces (all by global id): ```yaml fonts: uses: [biz-ud, ipamj-mincho, noto-sans-mono] # packs to load (packs/fonts//) default: biz-udp-gothic # face id used when fontFamily is unset fallback: [ipamj-mincho] # fallback chain, tried in order for missing glyphs ``` A `uses:` entry names a **directory** under a font search dir, so it must be a plain single path segment: letters, digits, `-` and `_`, 1–64 characters. Anything else — a `/`, a `..`, an absolute path — fails the locale pack's parse rather than being looked up. ### A font pack's manifest `packs/fonts//manifest.yml` declares the faces plus one license and per-face integrity: ```yaml version: 1 license: OFL-1.1 redistributable: true faces: - id: noto-sans # family defaults to the id file: NotoSans-Regular.ttf sha256: 478c558e… # verified against the file bytes at load - id: noto-sans-bold file: NotoSans-Bold.ttf sha256: 1df075a3… family: noto-sans # same family, different weight weight: bold # normal | bold (default normal) - id: noto-sans-italic file: NotoSans-Italic.ttf sha256: 467e3f89… family: noto-sans style: italic # normal | italic (default normal) ``` At load each file's **sha256 is verified** (a mismatch from tamper/corruption fails the load) and its OS/2 **`fsType` embedding rights** are checked — a restricted face is rejected (`font_embedding_restricted`) unless the manifest sets `embeddingAttested: true`. One license per pack: mixed-license fonts split into separate packs (so IPAmj Mincho is its own `ipamj-mincho` pack, not part of `biz-ud`). A `file:` stays **inside the pack directory**. It may be neither absolute nor `..`-climbing, and once symlinks are followed the file it resolves to must still be under the pack — a link pointing out of the pack is refused, one pointing within it is fine. The pack directory itself may not be a symlink. A `file:` that is merely *absent* is not an error: that is the pinned-reference case below. ### Adding your own font (`shojiku font add`) A font you hold a licence for becomes an ordinary pack — no different from a bundled one — with one command per face: ```bash shojiku font add MyCorporate-Regular.ttf --family my-corporate --license Proprietary ``` That creates `packs/fonts/my-corporate/`, copies the file in, and writes the `manifest.yml` with the file's sha256 already pinned. Run it again with the same `--pack` to add another face to the same family: ```bash shojiku font add MyCorporate-Bold.ttf --family my-corporate --license Proprietary \ --weight bold ``` | Flag | Effect | | --- | --- | | `--family ` | *(required)* what `style.fontFamily` names. | | `--license ` | *(required)* one licence for the whole pack. | | `--pack ` | Pack directory name. Default: the family id. | | `--face-id ` | Default: the family id plus `-bold` / `-italic` / `-bold-italic`. | | `--weight normal\|bold`, `--style normal\|italic` | This face's variant keys. | | `--url ` | Record a `url:` pin hint (see below). | | `--license-file ` | Copy the licence text into the pack. | | `--redistributable` | Mark the pack redistributable. **Off by default** — a licensed font usually may not be. | | `--embedding-attested` | See below. | | `--dir ` | Font dir to create the pack in. Default: the first of `$SHOJIKU_FONT_DIR`, else `./packs/fonts`. | Ids are the same charset a `uses:` entry takes: letters, digits, `-`, `_`, 1–64 characters. The command refuses rather than writing a pack the engine would later reject: a file that does not parse as a font, a face id the pack already declares, a file name already present with different bytes, a second licence in one pack, and a manifest already there that will not parse. A refusal writes nothing at all. **If the font's OS/2 `fsType` forbids embedding**, `font add` refuses it — the renderer would refuse it too (`font_embedding_restricted`), so the useful place to say so is before the pack exists. `--embedding-attested` asserts a separately held embedding licence and writes `embeddingAttested: true`; the run says on stderr that the guard no longer applies to that pack. There is no silent path between the two. Shojiku never scans system fonts. A pack exists because someone ran this command, and it is loaded because a run named it — so what a document was rendered with is an input, not a property of the machine. ### Using a pack the locale does not name (`--font-pack`) A pack is loaded only if it is named. A locale's own `fonts.uses` is one way; the other is per run: ```bash shojiku render --templates templates.yml --params params.json \ --output out.pdf --font-pack my-corporate ``` `--font-pack ` is repeatable and **adds to** the locale's `uses` rather than replacing it — which is what makes it the short way round: an overlay's sequences replace, so putting one pack into builtin `ja-JP` by hand means restating `[biz-ud, ipamj-mincho, noto-sans-mono]` too. These packs resolve **before** the locale's own, so a face id they declare shadows a bundled one of the same id — the same "earlier wins" rule that lets an earlier `--font-dir` override a bundled pack. A hostile id is refused by the same guard a locale pack's entry meets; the flag buys no trust. Put the pack in the locale's `uses` instead when every render of that locale should have it (a deployment's corporate font); use `--font-pack` when one run needs it. ### Pinned faces & auto-fetch (`url:`) A face may carry a **`url:`** alongside its `sha256`, so a pack can travel as a *pinned reference* — a `manifest.yml` whose font files are not present: ```yaml faces: - id: noto-sans file: NotoSans-Regular.ttf sha256: 478c558e… # the guarantee url: https://github.com/notofonts/…/NotoSans-Regular.ttf # only a hint ``` When a declared `file` is missing, the **CLI** fills a local cache before rendering: cache hit → use it; otherwise fetch `url`, check the bytes against `sha256`, cache, and use them. A mismatch is a hard error and the bytes are discarded — **never** a fallback to a different font, which would silently change the document. Faces whose files ARE present are untouched (no cache, no network). This is the only network access in the tool, it happens *before* layout, and it only fills a cache — **rendering, signing, and verifying never open a socket**. A face resolved from the cache renders byte-for-byte what an installed one does. | Flag / variable | Effect | | --- | --- | | *(default)* | Missing pinned faces are fetched automatically. | | `--offline` | Never fetch. A missing, uncached face is an error. Warm cache + `--offline` = air-gapped runs, identical output. | | `--font-fetch-allow ` | Trust an extra host (repeatable), e.g. an internal mirror. | | `$SHOJIKU_CACHE_DIR` | Cache location. Defaults to `$XDG_CACHE_HOME`/`~/.cache/shojiku` (Linux), `~/Library/Caches/shojiku` (macOS), `%LOCALAPPDATA%\shojiku` (Windows). | `url:` is fetched only from `fonts.gstatic.com`, `github.com`, `objects.githubusercontent.com`, and `raw.githubusercontent.com` (https only, checked on every redirect hop) unless `--font-fetch-allow` widens it. The allowlist is defense-in-depth — the sha256 pin is the actual integrity control; the list just bounds where a manifest can point the host. Cached blobs are named by their digest and re-verified on read, so a corrupted cache heals itself rather than failing forever. Older engines **reject** a manifest containing `url:` (unknown keys are parse errors), so a tool that generates pinned manifests should gate on the `fonts.face.url` capability key. A WASM host reads the pins back through `fontFacesNeeded(packId)` — JSON `[{file, url?}]` for a declared (injected, not-yet-loaded) pack — so it can fetch a pinned pack's bytes itself without re-parsing `manifest.yml`; the sha256 stays engine-side and is verified at load exactly as above. Capability key `wasm.fonts.faces`; the file-name-only `fontFilesNeeded` is unchanged. ### Fallback chain A glyph the chosen face cannot map is drawn by the first face in the locale's `fallback` chain that has it, before the `.notdef` box. It is bundled-only (never system fonts, to keep sign/verify reproducible) and per-glyph: within one run, characters the primary covers stay in it and only the uncovered ones swap to a fallback face. `missing_glyph` warns only when **no** face in the chain maps the character. ### Face variants `fontWeight: bold` / `fontStyle: italic` pick the family's matching face when one exists; the weight and style axes fall back independently (a `bold italic` request against a family with only a real *bold* keeps the real bold and synthesizes just the italic). When no real variant exists the engine renders the effect **synthetically** (faux stroke / skew — [style.md](style.md)). The `biz-*-gothic` families ship a real **Bold** and `noto-sans` ships real bold/italic/bold-italic; `ipamj-mincho` ships no variants, so `bold`/`italic` on it is synthetic. ## What else the locale data controls - `dateFormats` / `datetimeFormats` — named patterns the `format` variants select (`{issued_at:date}`, `format: long`); see [data-binding.md](data-binding.md) for the pattern tokens. - `number.groupSeparator` / `number.decimalSeparator`, plus optional `number.groupSize` (digits in the rightmost group, default 3) and `number.secondaryGroupSize` (the repeating size left of it, default = `groupSize`) — CLDR `#,##,##0` locales set 3 + 2, giving Indian `1,23,45,678`. A size of `0` disables grouping. - `currency.` — symbol, precision, and layout (`"{symbol}{amount}"`) per currency; `currencyDefault` when neither the field nor definitions names one. - `units` — quantity unit strings (`点`, `items`). - `weekdaysShort` — the `E` token's names. - `eras` (`[{ name, start: "yyyy-mm-dd" }]`) + `eraYearOne` — the era table behind the `G`/`y` pattern tokens (wareki); see [data-binding.md](data-binding.md). Every key can be overridden per key by an overlay file — an overlay only needs the keys it changes (see `packs/locale/README.md`). Capability keys: `locale.builtin` (the ja-JP/en-US builtins + the overlay merge; engines without it need a pack file even for those two). The shipped `packs/locale/` packs are host-supplied data, not a capability. ## Limitations - `fontFamily` names a loaded face id, not a system font. An unmatched family falls back to the default face (`unknown_font_family`, warned once per family) rather than failing. - Characters no loaded face can map render as tofu (`missing_glyph`). - Rendering never uses the network. A pinned face is fetched at authoring/bundle time; `--offline` refuses instead, and an image source is refused outright (`remote_asset_unsupported`). - A pack is loaded only when something NAMES it — a locale's `fonts.uses`, or `--font-pack`. Dropping a pack directory in place does not make it available. ## See also - [data-binding.md](data-binding.md) — how `{key:format}` uses these - [style.md](style.md) — `fontFamily` / `fontWeight` / `fontStyle` - [README.md](README.md#rendering-a-template) — passing `--lang` / `--font-dir` / `--locale-dir` --- ## docs/engine/form_marks.md — Choice marks drawn as vector paths — circling a printed option, or a checkbox. # `type: ellipse` / `type: checkbox` — form marks Choice marks for real forms: circling a printed choice, a checkbox, an application-form option field. Both render as **vector paths** (never font glyphs, so output never depends on font coverage) and work in flow, absolute bodies, bands, containers, and `repeat` cells. The design goal is the **blank↔filled one-template workflow**: the same template ships with a blank-form params file and a filled-sample one. A mark's *presence* is content (params-driven), but its *geometry is template-fixed* — an unmatched mark still reserves its box, so layout never shifts a single point between the two params sets. ## `ellipse` A box-inscribed oval. With no `data:` it always draws (decoration — e.g. an oval circling a heading); with `data:` it draws only when the binding matches (e.g. circling the chosen payment method on a receipt). ```yaml # Always-on decoration (no data): a red outline circling a heading. - { type: ellipse, box: { x: 10, y: 10, w: 60, h: 30 }, style: { borderColor: "#cc0000" } } ``` The style is the unified [`Style`](style.md) decoration subset (`borderWidth`/`borderColor`/`backgroundColor`/`opacity`, plus named styles via `styleNames`). Unlike `rect`, a mark whose layers author no `borderWidth` keeps a **1 pt black outline default** — a mark's visible geometry is its function, so the blank-form state must print. Marks stroke one closed path: a per-side `borderWidth` map reduces to the top side and warns `shape_border_sides_ignored`; text keys on the inline style warn `shape_style_ignored`. To circle a **text choice** (the common case), do not hand-place a standalone `ellipse` over the label — its glyph band sits a couple pt above the box center, so the oval needs per-font pixel-tuning. Use the text-anchored [`mark:`](#text-anchored-circle-mark-on-text) below, which auto-centers on the glyphs. ### `anchor:` — circling an item you cannot author `mark:` on `mark:` is authored on the text item itself. When the thing to circle is NOT yours to edit that way — a `rect`, a table cell's label, a text item whose circle must be a separate conditionally-drawn item — an `ellipse` can name it by `id:` instead: ```yaml - { type: text, id: chosen, text: "Express", box: { x: 20, y: 40, w: 80, h: 16 } } - { type: ellipse, anchor: chosen, data: { key: express } } ``` It centres on the target's **glyph band** (the inked extent from its text metrics) — not on its padded box — and, unsized, takes that band plus 0.4 of its height as clearance on every side. The clearance is the point: an oval on the band's exact extent is widest at mid-height, so its arcs cross the glyphs and it reads as a strikethrough. (The text `mark:` overlay pads by the same fraction of the font size, for the same reason.) A target with no text metrics — a `rect`, an image, a QR — falls back to its border box, padded the same way. Like an anchored [`line`](line.md) endpoint, this makes the ellipse absolutely positioned: it reserves no space, it is drawn on the page its TARGET landed on, and it paints after that page's in-flow content. `box.w`/`box.h` still SIZE it (centred on the same band, clearance included in the default only); `box.x`/`box.y` are not read — the anchor decides where it sits, and the Designer therefore refuses to drag an anchored ellipse rather than writing coordinates the engine ignores. Prefer `mark:` when the text is yours to author on; reach for `anchor:` when it is not. ## Text-anchored circle (`mark:` on text) A `mark:` on a **text item** overlays an oval that auto-centers on the item's glyph band and auto-sizes to it — no hand-measured coordinates, and a font change never invalidates the fit. It is **paint-only**: the oval never changes the text's reserved box, so a blank↔filled params pair never shifts layout. ```yaml # Circle the chosen payment method. Each label is a normal text item; only the # matching oval draws. No x/y tuning — the oval finds the glyphs. - { type: text, box: { x: 80, y: 40, w: 40 }, style: { textAlign: center }, text: 現金, mark: { data: { key: payment, equals: cash } } } - { type: text, box: { x: 132, y: 40, w: 56 }, style: { textAlign: center }, text: カード, mark: { data: { key: payment, equals: card } } } # params: payment: "card" # Always-on decoration (no data) with a red outline, plus extra clearance. - { type: text, text: 合計, mark: { padding: 4, style: { borderColor: "#cc0000" } } } ``` - **`data:`** is the same presence binding as a standalone mark (below): absent = always draw (decoration); with `equals`/boolean it draws only on a match. Omit it for a pure decoration oval. - **`padding:`** ([Length](length.md)) overrides the clearance between the glyph band and the oval. Unset applies an em-proportional default with a perceptual **overshoot** baked in (a round shape flush with the caps reads smaller than it is), so the oval circles the text without cutting it. A negative value clamps so the oval stays positive. - **`style:`** is the unified [`Style`](style.md) decoration subset (`borderWidth`/`borderColor`/`backgroundColor`/`opacity`, + `styleNames`), like a standalone ellipse — 1 pt outline default. - A multi-line label gets one oval spanning all its lines (best for short labels). ## `checkbox` The **frame** (a stroked box) is chrome and always draws; the **check mark** is content, drawn when static `checked: true` is set or `data:` matches. An empty box is the blank-form state. ```yaml - { type: checkbox, box: { x: 40, y: 100, w: 10, h: 10 }, data: { key: agree } } # bound to a boolean - { type: checkbox, box: { x: 40, y: 116, w: 10, h: 10 }, data: { key: status, equals: "2" } } - { type: checkbox, box: { x: 40, y: 132, w: 10, h: 10 }, checked: true } # static - { type: checkbox, box: { x: 40, y: 148, w: 10, h: 10 } } # always-empty chrome ``` `checked` and `data:` are mutually exclusive; if both are set `data:` wins and validation warns `mark_content_conflict`. The check mark is stroked in the frame's `borderColor` (default black). For a checkbox beside a text label, put the pair in a flex row with `alignItems: baseline` ([flex.md](flex.md)) — the frame's bottom edge sits on the label's baseline. **Omit `box.w`/`box.h`** and the frame defaults to the inherited font's **cap-height square** (≈ 0.8em), which is the size that reads as matched to the label; baseline alignment puts any excess height *above* the caps, so an oversized frame floats high. ```yaml - type: container box: { direction: row, gap: 6, alignItems: baseline } items: - { type: checkbox, data: { key: agree } } # auto cap-height square - { type: text, text: "利用規約に同意する", style: { fontSize: 11 } } ``` An explicit `box.w`/`box.h` still wins when you want a specific size; a present `box:` with only `x`/`y` (placement, no size) also auto-sizes. ## Binding presence: `data: { key, equals }` > The same predicate is available on **every** item type as > [`visible:`](visible.md) — this section describes the mark's own `data:` > key, and `visible:` reuses its grammar and its truth table unchanged. Use > `data:` to decide whether a MARK is drawn; use `visible:` to decide whether > any item is shown at all. `key` reads a params value (scoped to the enclosing `repeat` element, else top-level params; `data: { key, scope: document }` is the [escape](data-binding.md#scope--the-escape-back-to-the-document) for a page-global flag that should tick the mark in every cell). Then: - **`equals` set** — the mark draws when the value **equals** it. The comparison is **type-strict**: `equals: "2"` (string) never matches the number `2`, and vice-versa. A type mismatch warns `mark_equals_type_mismatch` (it usually means the wrong literal was authored — e.g. `equals: 0` vs a DB string `"0"`). Numbers compare exactly by representation, so an integer `2` and a float `2.0` are different values — match the form your system emits. Feed a DB enum code straight through: `status: "shipped"` + `equals: "shipped"`. - **`equals` set, value is an array** — **multi-select**: the mark draws when the array *contains* the value. One field drives a whole group: ```yaml # params: causes: ["1", "3"] → boxes 1 and 3 check, box 2 does not. - { type: checkbox, box: {...}, data: { key: causes, equals: "1" } } - { type: checkbox, box: {...}, data: { key: causes, equals: "2" } } - { type: checkbox, box: {...}, data: { key: causes, equals: "3" } } ``` - **`equals` omitted** — the value is read as a **boolean**; the mark draws when it is `true`. A non-boolean warns `mark_value_not_bool` (layout) / `mark_binding_not_boolean` (validation, needs a `boolean` field in [definitions](definitions.md)). The same `{ key, equals? }` predicate — and the same truth table — selects a table row's conditional style ([table.md](table.md#conditional-row-styles)); there it reads a key relative to the row element and carries its own diagnostic codes. `equals` accepts a string, number, or boolean scalar; a map or sequence is a parse error. A **missing** params value draws nothing, silently — a blank form simply omits the key. ## Sizing `box.w`/`box.h` take any [Length](length.md) — pt, `%`, mm/cm/in, or `em`/`rem` (`w: 1rem` sizes the mark to the document text). A **checkbox** may omit them (or its whole `box:`) to default to the inherited font's cap-height square; that default is the metric size and ignores `minWidth`/`maxWidth`/`minHeight`/`maxHeight` (set an explicit `box.w`/ `box.h` if you need a bounded size). A standalone **ellipse** still requires a size — an absent or non-positive one warns `mark_missing_size` and skips. A text-anchored `mark:` never takes a size (it auto-fits the glyphs). ## Limitations - An `ellipse` needs a positive `box.w`/`box.h`; only a `checkbox`, or an `ellipse` with `anchor:`, may omit them (`mark_missing_size`). - An anchored `ellipse` is not draggable on the canvas, and there is no Designer field for `anchor:` yet — it is authored in the file. Its clearance is not authorable either: give an explicit `box.w`/`box.h` to override the default size. - An anchored `ellipse` ignores `box.x`/`box.y`, resolves to the FIRST placement of that id on the page (`anchor_ambiguous_target` when there is more than one), and draws nothing when the id is unknown (`anchor_unknown_target`). - Per-side `borderWidth` is reduced to the top side (`shape_border_sides_ignored`), and a radius is refused (`border_radius_ignored`). - Text and box style keys are inert on a mark (`shape_style_ignored`). - An `equals`-less binding must target a boolean (`mark_binding_not_boolean`, `mark_value_not_bool`). With `equals`, the literal must sit inside the field's declared enum (`mark_equals_not_declared`) and match the value's type (`mark_equals_type_mismatch`). - Two shapes only. There is no radio, no cross, no tick-style key. ## Diagnostics | Code | Severity | Meaning | | --- | --- | --- | | `mark_missing_size` | warning | `box.w`/`box.h` absent or non-positive; skipped | | `anchor_unknown_target` | warning | no item carries the `anchor:` id; nothing drawn | | `anchor_ambiguous_target` | warning | the id is placed more than once on the page; the first placement wins | | `mark_content_conflict` | warning | checkbox sets both `checked` and `data` (`data` wins) | | `mark_equals_type_mismatch` | warning | value type differs from `equals`; not drawn. With definitions, the DECLARED type is checked the same way at validate | | `mark_equals_not_declared` | warning | the `equals` literal is outside the field's declared `enum` — a mark that can never be drawn | | `mark_value_not_bool` | warning | `equals`-less binding value is not a boolean; not drawn | | `mark_binding_not_boolean` | warning | (validation) an `equals`-less binding targets a non-boolean field | | `unknown_data_key` | error | (validation) the binding key is not in definitions | | `shape_style_ignored` | warning | (validation) text/box keys on a mark's inline `style` have no effect | | `shape_border_sides_ignored` | warning | a per-side `borderWidth` map on a mark reduced to the top side | Capability keys: `ellipse`, `ellipse.anchor`, `checkbox`, `text.mark`, `checkbox.auto_size`, `inspect.text_metrics`, `style.shapes.unified`. ## See also - [rect.md](rect.md) — the shape style ellipse/checkbox share - [definitions.md](definitions.md) — the `boolean` field type for checkbox bindings - [data-binding.md](data-binding.md) — how `key` resolves against params and cells - [visible.md](visible.md) — the same presence predicate on any item, with the reserve-box / `collapse:` choice --- ## docs/engine/grid.md — Explicit column tracks — `fr` weights and `auto` sizing — instead of a flex stack. # Static grid (`box.type: grid`) An explicit `box.type: grid` on a container (or `repeat` cell / `repeat_flow` card) tiles its children into **column tracks** instead of a flex stack. Grid is explicit-only: grid keys without `type: grid` warn `grid_key_ignored` rather than implying a mode switch. ## Syntax ```yaml - type: container box: { type: grid, columns: ["30%", "70%"], rowGap: 4 } items: # row-major: label, value, label, value, … - { type: text, text: "小計" } - { type: text, text: "{amount.subtotal}", style: { textAlign: right } } - { type: text, text: "合計" } - { type: text, text: "{amount.total}", style: { textAlign: right } } ``` ## Keys (on the container's `box`) | Key | Values | Default | Description | | --- | --- | --- | --- | | `type` | `grid` | — | Required to activate grid placement. | | `columns` | count \| list of [Length](length.md)/`fr`/`auto` | 1 column | Column tracks: a **count** (equal split of the content width minus gaps) or a **track list** (`["auto", "1fr", "30%", 50]`, any Length, `fr` weight — see [`fr` weights](#fr-weights) — and/or `auto`, sized to the widest cell placed in the track). | | `rows` | count \| list of Length/`fr`/`auto` | auto rows | A count needs a definite container height (auto-height degrades to auto rows with a diagnostic); rows beyond an explicit list are implicit — auto, sized by their tallest child. Writing `auto` in the list says exactly that, so `["auto", "1fr"]` reads the way it looks. An `fr` row needs a definite height (auto-height degrades with `grid_fr_no_basis`). | | `columnGap` / `rowGap` | Length | 0 | Per-axis gaps; the flex `gap` doubles as the both-axes shorthand (specific keys win). Negative gaps are 0. | | `direction` | `row` \| `column` | `row` | Fill order (CSS `grid-auto-flow` analog): `row` = row-major. | | `justifyContent` | flex values | `start` | Distributes leftover width across tracks (only meaningful for track lists — counts consume the axis). | | `alignItems` | flex values | `stretch` | Aligns children within their row (with vertical auto margins). | Child keys (on a grid child's `box`, like `flexGrow` in flex): | Key | Values | Default | Description | | --- | --- | --- | --- | | `columnSpan` | count ≥ 1 | 1 | How many column tracks the child spans; the cell run is the spanned widths plus the gaps between them. Clamped to the track count (`grid_span_clamped`). | | `rowSpan` | count ≥ 1 | 1 | How many row tracks the child spans; cells beneath are reserved. Clamped to 64 (`grid_span_clamped`). | ## Behavior - **Fill order** is document order along `direction`. The participation rule is the flex one: authored `box.x`/`box.y` = absolute escape hatch. - **Spans** consume a rectangle of cells from an occupancy map: each child takes the first free run that fits in fill order (a span that cannot fit in the row's remaining columns wraps whole). A row-spanning child pours any height its spanned rows don't cover into its **last** spanned auto row (the v1 distribution); over explicit tracks it warns `grid_cell_overflow` instead. Span keys outside a `type: grid` parent warn `span_outside_grid` and are inert. - **Cells**: a child fills its track width (`%` resolves against the cell); horizontal auto margins center/push within the cell. A child taller than its explicit row track warns `grid_cell_overflow` and overflows visually, CSS-like — to clip, author `overflow: hidden` on the child container itself. A child *wider* than its column-track run warns `grid_column_overflow` (its own code, carrying only numbers — `child`/`track`/`span` — so a translating consumer writes its own sentence). A definite-width child either fits its track run or spills over its neighbour; a child with no authored `w` fills the run and never warns. Sizing a track to its content is what `auto` columns are for. - **Caps**: track counts and list lengths clamp to `MAX_GRID_TRACKS` (64) per axis with `grid_tracks_clamped`, so hostile counts cannot drive allocation. ## `fr` weights A track in a list may be an **`fr` weight** (`"1fr"`, `"2.5fr"`) instead of a fixed [Length](length.md). After the fixed tracks and the gaps are subtracted from the axis, the leftover distributes across the `fr` tracks in proportion to their weights — the same machinery as flex `flexGrow`: ```yaml box: { type: grid, columns: ["1fr", "2fr", 90] } # 90pt fixed; the # rest splits 1:2 ``` - `fr` is a **grid-track-only unit** — it is not a `Length`, so `"1fr"` anywhere a plain length is expected (box `w`, margins, gaps, column widths) is an "invalid length" parse error. - Weights must be finite and non-negative (parse-rejected otherwise); a `0fr` track takes no leftover. When the fixed tracks already fill (or overflow) the axis, `fr` tracks collapse to 0. All-zero weights degrade to an equal split. - **Columns** always have a width basis, so column `fr` always resolves. **Rows** need a definite container height (like a row *count*); an auto-height container degrades `fr` rows to auto with `grid_fr_no_basis`. - An `fr` row splits the leftover after the fixed rows, the gaps **and the auto rows** — mixing `fr` rows with `auto` or implicit content rows in one definite-height grid works, and the auto rows are measured before the split rather than counting as nothing. - **Limitation**: a row-SPANNING child does not feed that measurement. A span pours its overflow into its LAST spanned row only once the rows it covers have sizes, and one of those is the `fr` size being computed — genuinely circular, unlike the column case. So an auto row whose extra height would have come from a span is measured without it, and the `fr` rows take slightly more than they should. Keep row-spanning children out of grids that mix `fr` with auto rows, or give those rows fixed heights. ## `auto` tracks A track in a list may also be **`auto`**. An `auto` **column** is as wide as the widest cell placed in it — its content width, the width at which that cell's text would not wrap: ```yaml box: { type: grid, columns: ["auto", "1fr"] } # label column fits its # text; the rest follows ``` - Sizing runs **before** the cells are laid out, which is why the grid assigns cells first and sizes tracks second. `fr` tracks still take whatever is left after the fixed and `auto` ones. - Only children occupying a **single** column contribute a width. CSS spreads a spanning child's demand across the tracks it covers; doing that needs the other tracks' sizes, which is the circularity this ordering avoids. An `auto` column holding nothing but spanning children therefore sizes to 0. - Content is measured with no container to bound it, so a long unwrapped string can ask for more than the whole grid. The `auto` tracks then scale down together to what is actually there, and the content wraps inside them. - The measurement is the same one flex `flexBasis: content` uses, so the kinds with **no** max-content width are the same — see the list on [flex.md](flex.md#child-key-on-a-flex-items-own-box). A cell with an authored `w` contributes that width directly. - `auto` in a **row** list is the implicit auto row (as tall as its tallest child), which is what omitting the entry already means. It adds no machinery; it is accepted so a mixed list reads plainly. - Like `fr`, `auto` is **grid-track-only** — `"auto"` where a plain length is expected is a parse error, and older engines reject it the same way. ## Limitations - Explicit only: grid keys without `box.type: grid` are ignored (`grid_key_ignored`) and never imply the mode. - 1..=64 tracks per axis (`grid_tracks_clamped`); a `columnSpan`/`rowSpan` past the axis is clamped (`grid_span_clamped`). - `fr` ROW tracks need a definite height. In an auto-height container they size as auto rows instead (`grid_fr_no_basis`). - A child wider than its track run spills over its neighbour (`grid_column_overflow`); taller than an EXPLICIT row track it warns (`grid_cell_overflow`) — auto rows grow instead. - Span keys on a child of a non-grid box are inert (`span_outside_grid`). - No named areas and no auto-placement algorithm: children fill the tracks in order. ## Diagnostics | Code | Meaning | | --- | --- | | `grid_tracks_clamped` | `columns`/`rows` outside 1..=64 tracks (or an empty list); clamped | | `grid_cell_overflow` | child TALLER than its explicit row track; overflows visually | | `grid_column_overflow` | child WIDER than the column-track run it spans; overflows visually | | `grid_key_ignored` | grid keys authored without `box.type: grid` | | `grid_span_clamped` | `columnSpan`/`rowSpan` beyond the axis; clamped | | `span_outside_grid` | span keys on a child of a non-grid box; ignored | | `grid_fr_no_basis` | `fr` row tracks in an auto-height container; sized as auto rows | Capability keys: `box.grid`, `grid.span`, `grid.fr`. ## See also - [flex.md](flex.md) — the default mode and the shared alignment keys - [repeat.md](repeat.md) — the *data-driven* grid (one cell per array element) --- ## docs/engine/image.md — A raster or SVG image from a template-time source or a params-bound value. # `type: image` An image item. The source comes from `src` (template-time: a path under the assets directory, a `data:` URI, or inline SVG markup) or `data` (a params-bound value, subject to the host's asset policy) — exactly one should be set. `box.w`/`box.h` are required to reserve space (layout never sizes from image pixels). ## Syntax ```yaml - type: image box: { w: 120, h: 40 } src: logo.svg # path under the assets root (--assets-dir, # default: the template file's directory) # src: "data:image/png;base64,…" # src: "" # inline SVG # data: { key: product.image } # params-bound (policy-gated) fit: contain # contain | cover | stretch | none style: { backgroundColor: "#f2f2f2", borderWidth: 0.5 } ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `src` | string | | Bundled path, `data:` URI, or inline SVG. A path resolves against the **assets root** — `--assets-dir`, defaulting to **the template file's directory** (so `examples/business/receipt-ja/templates.yml` can say `src: assets/logo.svg`); paths escaping the root are rejected (`asset_traversal`). Remote URLs are recognized and rejected (`remote_asset_unsupported` — the render path has no network I/O, and unlike a font there is no `sha256` pin that would make fetching an image deterministic). | | `data` | `{ key }` | | Dynamic source from params; the host's asset policy (open vs bundled-only, per-item allow/deny via CLI `--allow/deny-dynamic-image`) gates it. | | `bindings` | map of name → binding | | Named declarations for the `{name}` interpolations in this item's `link.url` ([data-binding.md](data-binding.md#named-binding-declarations)). | | `fit` | `contain` \| `cover` \| `stretch` \| `none` | `contain` | CSS `object-fit`, all centered. `contain` preserves aspect ratio inside the box (letterbox); `cover` preserves aspect ratio filling the box and crops the overflow; `stretch` fills the box exactly (distorting); `none` draws at the asset's intrinsic size (raster px at 72dpi / SVG viewBox units). `cover`/`none` clip anything past the content box — and an **SVG clips under every fit** (see below). | | `box.w` / `box.h` | [Length](length.md) | required | The fit box (inset by padding). | | `style` / `styleNames` | | | Decoration (`backgroundColor`, `borderWidth`/`borderColor`) plus **`opacity`** (see below); text properties are unused. | | `link` | `{ url }` | | Hyperlink over the draw box. See [link.md](link.md). | ## The box always holds An image never paints outside its content box. For a raster that falls out of the fit math — the pixels are exactly their own rect — so only `cover`/`none` need the crop. An **SVG** is different: its intrinsic size is the `viewBox`, but nothing stops a path from being drawn outside it, so the fitted rect is not the painted rect. The `viewBox` is the viewport and a viewport clips (the outermost `` carries `overflow: hidden`), so every SVG is clipped to the content box under every fit — `contain` and `stretch` included. This matters when the asset is not yours: without it, one hostile or merely sloppy SVG could paint over the whole page. If your own artwork looks cropped, its shapes extend past the `viewBox` — widen the `viewBox` rather than expecting the overflow to draw. ## Opacity `style: { opacity: 0.5 }` applies a whole-image paint alpha (`0..=1`). Both backends composite the raster or vector image as **one group**, so a partly-transparent image reads as a single unit (overlapping SVG paths do not double-blend at their seams). Out-of-range or non-finite values warn `invalid_opacity` and draw opaque. ## Formats - **Raster**: PNG, JPEG, GIF, WebP — sniffed by magic bytes, header dimensions validated, size-capped by the asset policy. - **SVG**: a deliberate subset parser (paths, basic shapes, groups, affine transforms, presentation attributes; node/depth caps). Text inside SVG is not rendered. Unsupported constructs warn `svg_unsupported`. - **Gradients**: `` / `` fills are supported — `` colors/opacity (including via `style="…"`), both `gradientUnits` (`userSpaceOnUse` and the `objectBoundingBox` default), `gradientTransform`, `spreadMethod` (`pad`/`reflect`/`repeat`), radial focal point, and one-level `href`/`xlink:href` stop inheritance (the Inkscape/Illustrator stop-holder pattern). Gradient **strokes** are not yet drawn (they warn and fall back to the inherited solid stroke); an undefined `url(#id)` reference warns and leaves the shape unpainted. Both the PDF and PNG backends draw gradients identically. ## In `repeat` / `repeat_flow` cells Images work inside `repeat` cells, `repeat_flow` cards, and a table column's `cell:`. A static `src:` is one shared asset drawn once per element; a `data:` binding is **element-scoped** — its key is a field of the bound array element and each element loads its own asset (`dyn:[].`), under the same per-element load cap as table image columns (`cell_image_assets_capped`). A `data:` binding with [`scope: document`](data-binding.md#scope--the-escape-back-to-the-document) is the third case: the key reads top-level params, so the cell draws ONE shared asset (`dyn:`, loaded once and uncounted against the per-element cap) — the shop logo on every ticket, without hard-coding a `src:` path. The host asset policy gates dynamic cell images by the image item's `id`. ## Limitations - `box.w`/`box.h` are required — layout reserves the space before it knows the source (`image_missing_size`). - Exactly one of `src`/`data` (`image_source_conflict` / `image_source_missing`). - No network at render time: a remote URL source is refused (`remote_asset_unsupported`). - A bundled path must stay under the assets root (`missing_asset`, `assets_root_missing`, `asset_traversal`). - SVG is a SUBSET parser; constructs outside it leave the shape unpainted (`svg_unsupported`). - Per-element cell images share a 1000-load cap across table columns and `repeat`/`repeat_flow` cells (`cell_image_assets_capped`), and a params-bound image can be refused by the host's asset policy (`dynamic_image_denied`). ## Diagnostics | Code | Meaning | | --- | --- | | `image_missing_size` | `box.w`/`box.h` absent | | `empty_image_item` / `image_source_conflict` | neither / both of `src`+`data` set | | `missing_asset` / `assets_root_missing` / `asset_traversal` | bundled path problems (absent, no root, escapes the root) | | `invalid_image_data` / `invalid_image_asset` / `image_source_missing` | undecodable or unresolvable sources | | `remote_asset_unsupported` | remote URL source; rejected | | `dynamic_image_denied` | params-bound image blocked by the asset policy | | `svg_unsupported` | SVG constructs outside the subset | | `cell_image_assets_capped` | per-element cell images (table columns + repeat cells) exceed the shared load cap; the rest are skipped | | `invalid_opacity` | out-of-range / non-finite `opacity`; draws opaque | Capability keys: `image`, `image.fit.cover_none` (the `cover` / `none` fit modes), `image.svg.gradient` (gradient fills in the SVG subset — gate gradient previews on it; older engines leave gradient-filled shapes unpainted with an `svg_unsupported` warning), `image.cells` (images inside repeat/repeat_flow cells — older engines warn+skip there), and `image.opacity` (whole-image group alpha; older engines ignore it). ## See also - [link.md](link.md) — `link: { url }` over the image - [qr_code.md](qr_code.md) — vector codes with no asset pipeline --- ## docs/engine/layout-model.md — How a template plus params becomes the resolved layout tree every backend draws. # The layout model How `shojiku-layout` turns a template + params into the fully resolved layout tree (`engine/layout/src/tree.rs`) that every render backend draws. This page carries the cross-cutting model; the authorable surface is split per feature — see the [reference index](README.md). ## The one invariant **Everything resolves to absolute pt at layout time.** Containers, relative offsets and `%` lengths all disappear inside the layout pass; the `LayoutDocument` handed to `render-pdf` / `render-png` contains only absolute page coordinates. Renderers never re-measure, re-format, or re-resolve. (The tree also carries the document's resolved [metadata](document.md) — interpolated and gated by layout, written by the PDF backend, ignored by PNG. Not geometry, same rule: the renderer writes what it is given.) The single exception: `overflow: hidden` / `textOverflow: clip` emit a rectangular **clip group** (`kind: clip`) — the one nested tree node — and both backends honor it (krilla clip path / tiny-skia mask). Renderers still never re-measure; they only cut at the rect layout resolved. A degenerate clip rect draws nothing (fail closed), and renderers cap clip nesting at `MAX_CLIP_DEPTH` (64) against hand-built trees. ## The box tree ```text Page (margin box = coordinate origin) Band (header/footer) — absolute items, margin box is the parent box Body: absolute — absolute items, margin box is the parent box Body: flow — items stack vertically inside the flow box Container — establishes origin + resolved size Container… — nests (children resolve against it) ``` - **Parent box (`Basis`)**: bands and the absolute body resolve against the page **margin box** ([page.md](page.md)); flow items resolve against the flow region; container children resolve against the container. The flow region's own `box` resolves against the margin box; **omitted, the flow occupies the whole margin box**. - Geometry keys and their semantics: [box.md](box.md). Child placement (flex/grid, participation): [flex.md](flex.md) / [grid.md](grid.md). Stacking and pagination: [flow.md](flow.md). ## Style cascade — a separate axis from positioning Positioning resolves by **containment**: a child's `%` and origin are computed against its parent box (`Basis`). Text **style** resolves by **inheritance**, the CSS way: an unset property takes its value from the nearest ancestor that set it. These two axes are deliberately independent — **a container never passes its position down as inheritance**, and style never participates in the `Basis` math. The cascade resolves every property to a concrete `ComputedStyle` (precedence low→high: engine default ← inherited ← named styles in listed order ← inline); carried through the walk as engine state, not through `Basis`. Property table and inheritance flags: [style.md](style.md). ## Resolve caps (untrusted templates) Templates are untrusted input; the resolve pass is guarded: - **Nesting depth** ≤ `MAX_CONTAINER_DEPTH` (32, mirroring the SVG group cap): deeper subtrees are skipped with `container_depth_exceeded` (validate reports it statically; layout enforces it independently). - **Resolved size bound**: any resolved length with `|v| > 1_000_000pt` (or non-finite) is dropped with `length_out_of_range` and the caller's default applies — a chain of >100% values cannot amplify geometry. - **Unresolvable `%`**: `percent_of_auto`, value dropped (above). - **Page cap**: 500 pages (`MAX_PAGES`), then `page_overflow` truncates. - Params never supply geometry: all `x`/`y`/`w`/`h` are template-owned (the params ↔ geometry boundary). Per-construct caps live on their pages: grid tracks ([grid.md](grid.md)), imposition cells ([repeat.md](repeat.md)), list entries ([list.md](list.md)), QR content ([qr_code.md](qr_code.md)), style registry ([style.md](style.md)). ## The box index (GUI sidecar) Alongside the tree, `layout()` returns a **`BoxIndex`**: per page, one `{ path, id?, border, content }` placement for **every** laid-out item — id-carrying or not (repeat-cell and card items once per element, band items once per page, split text once per fragment). A `table`, `repeat`, or `repeat_flow` item yields one fragment rectangle per page it spans (none for a page where nothing landed); a table column additionally yields one placement per cell, header included. Exposed through the `shojiku inspect` envelope (`{ engine, document, boxes, margin }`) for Designer overlays; **not** part of the renderer contract. Capability key: `inspect.boxes.all_items`. `path` is the box's stable structural address in the same grammar the parse/validate diagnostics use, so a GUI correlates canvas geometry back to a YAML node without its own id injection: - section base `sections.body` / `sections.header` / `sections.footer`, - an item at index `i` in a list of items → `…items[i]`, nesting through containers/grids (`…items[i].items[j]`), - a `repeat` cell container `…items[i].cell` and its children `…items[i].cell.items[j]`; a `repeat_flow` card `…items[i].item` and `…items[i].item.items[j]`, - a table cell → `…items[i].columns[c]` (one box per column per row, so the same path recurs per row/page — the GUI groups by path); a `headerGroups` cell → `…items[i].headerGroups[g]` (its own authored position, never the leftmost column it spans). The cells layout synthesizes — the trailing header region no group covers, the all-empty `mergeEmptyCells` collapse — are authored nowhere and emit no box: a click there falls through to the table fragment. `path` is always present and synthesized from structure alone (never from an authored `id:` or data key). `id` is the item's authored `id:` when it has one — a lookup alias, omitted from the JSON when absent. The geometry is identical whether or not an item authors an `id:`. A `line` reports its endpoint bounding box (zero-thickness when axis-aligned — the stroke inks `width/2` beyond it; hit-test tolerance is the overlay's job); `page_break` has no geometry and emits no box, but still consumes its `items[i]` index. A **text** item's placement additionally carries per-line **text metrics**: for each line, `{ x, width, baseline, capTop, emTop, emBottom }` in page coordinates — the baseline and the cap-height/em bands a GUI or AI needs to snap overlays (underlines, circle guides, alignment checks) to real glyph geometry without re-measuring a preview. Capability key: `inspect.text_metrics`. ## Fonts & text pipeline Text measurement and drawing share one policy (`FontFace`): wrapping, kinsoku, `letterSpacing`, and synthetic bold/italic are decided at layout and recorded on the tree; renderers only execute. Missing glyphs draw as `.notdef` and warn `missing_glyph` once the fallback chain is exhausted. Details: [text.md](text.md). ## Phasing The capability inventory and the reasoning behind each feature live in [features.md](../engine/features.md). ## Limitations - The caps are hard: 500 pages (`page_overflow`), 32 levels of container nesting (`container_depth_exceeded`), and ±1e6 pt on any resolved length (`length_out_of_range`). - The second-placement pass is budgeted; past it the innermost children keep their first placement (`reflow_budget_exhausted`). - The engine computes GEOMETRY, never data. No sums, no sorting, no filtering: what the params carry is what the page shows. - Renderers never re-measure. Anything a backend would need to decide for itself has to be decided here first. --- ## docs/engine/length.md — Every geometry value: absolute units, `%`, `em`/`rem`, and where each has no basis. # Lengths & units Every geometry value (`box` coordinates and sizes, min/max bounds, margins, padding, gaps, table column widths and row heights) is a **`Length`**. The canonical unit is the PDF point (1 pt = 1/72 inch). ## Accepted forms | Form | Example | Meaning | | --- | --- | --- | | bare number | `w: 120` | pt. The canonical unit. | | `"N%"` string | `w: "50%"` | Percent of the parent's resolved size along the same axis: `x`/`w` resolve against the parent's **width**, `y`/`h` against its **height**. Exception: margin/padding sides resolve `%` against the parent **width** for all four edges (the CSS rule). | | `"Npt"` string | `w: "120pt"` | Explicit pt. | | `"Nmm"` / `"Ncm"` / `"Nin"` | `w: "80mm"` | Physical units, absolute like pt (1 in = 72 pt, 1 cm = 10 mm, 1 mm = 72/25.4 pt). They need no layout context, so unlike `%` they also work where no basis exists (custom `page.size`, boxes inside auto-height containers). | | `"Nem"` string | `w: "2em"` | Multiples of the **inherited font size** — the font size in effect where the length resolves (see below). Works on both axes, including inside auto-height containers. | | `"Nrem"` string | `w: "1.5rem"` | Multiples of the **engine default font size** (10 pt), independent of the cascade — a stable scale unit. | `px` is deliberately **not** a template unit; px exists only as the preview backend's scale factor. `fr` is **not** a Length either — it is a grid-track-only weight ([grid.md](grid.md)), so `"1fr"` anywhere a plain length is expected is an "invalid length" parse error. ## What `em` means `em` in a box length resolves against the font size the item **inherits** at that point in the tree — the enclosing container's computed `fontSize` (or the engine default 10 pt at the document root). This is one uniform rule; note the deliberate divergence from CSS: an item's **own** inline `fontSize` does not affect its **own** box lengths (a container with `style: { fontSize: 20 }` gives its *children* a 20 pt em, while its own `padding` em still uses what the container inherited). Two style properties follow CSS instead: - `fontSize: "1.5em"` / `"150%"` resolve against the *inherited* font size (nested relative sizes multiply); `"1.2rem"` scales the engine default. - `letterSpacing: "0.1em"` resolves against the item's **own** computed font size (a same-layer `fontSize` applies first). `letterSpacing` rejects `%` at parse — CSS letter-spacing has no percentage form. The `rem` root is the engine default font size today; a template-level root style may replace that base later (pre-1.0). ## Round-trip The authored unit is preserved: serialization writes `80mm` back, never a normalized pt number. Bare numbers stay bare numbers. ## Guards (untrusted input) - Non-finite values are rejected at parse time — bare numbers by the YAML guard, string forms like `"1e309%"` by the `Length` parser. For physical units both the authored value and its pt conversion must be finite (`"1e308in"` is rejected). - At layout, any resolved length with magnitude > 1,000,000 pt is dropped with `length_out_of_range` and the caller's default applies — a chain of >100% values cannot amplify geometry. - A `%` that needs the height of an auto-height parent cannot resolve; it is dropped with `percent_of_auto`. ## Where `%` has no basis - `page.size` custom dimensions (parse error; `em`/`rem` are rejected there too — page geometry must be absolute). - Heights (`h`, `minHeight`, `maxHeight`, `y`) inside an auto-height container (`percent_of_auto` at layout). `em`/`rem` need no height basis and still resolve there. A `line`'s `from`/`to` take the full vocabulary on both axes (`x` against the placement box's width, `y` against its height) — see [line.md](line.md); a `%` `y` under an auto-height parent drops with `percent_of_auto` like any other height. Capability keys: `length.physical`, `box.percent`, `length.em_rem`, `style.fontSize.length`, `style.letterSpacing.length`, `flow.gap.length`, `line.length`. ## Limitations - `%` needs a definite basis. Against an auto axis the value is dropped (`percent_of_auto`). - A resolved length past ±1e6 pt falls back to the key's default (`length_out_of_range`). - Physical units are STRINGS (`"15mm"`); a bare number is always pt. - `page.margin` takes a bare pt number or a per-side map — a single unit-bearing scalar (`margin: 15mm`) is refused. - There is no `calc()` and no arithmetic of any kind. ## See also - [box.md](box.md) — where lengths are authored - [layout-model.md](layout-model.md) — the resolve pass and its caps --- ## docs/engine/line.md — A stroked segment between two points — no box, its own style shape. # `type: line` A straight stroked segment between two points. A line has no `box`; `from`/`to` resolve against the same parent origin as sibling items, and both axes take a full [`Length`](length.md) — `x` against the placement box's width, `y` against its height. That is what makes an **underline under a flex child** expressible: the child's real width is a share of the row, decided at layout time, so no pt value can be written down while authoring. Put the line inside the child and reach its edge with `to: { x: "100%" }`. Bare numbers are `pt`, so every template written before this is unchanged — and they serialize back as bare numbers, never as `"0pt"`. **No `styleNames` and no cascading `style` properties**: `line` is a stroke primitive with no box, so it keeps its own style shape (`width`/`color`/`opacity`/`style` only, `deny_unknown_fields`) — the one shape that did NOT converge onto the unified [`Style`](style.md). Use `text`/`container`/`rect` items when you need the shared style vocabulary. ## Syntax ```yaml - type: line from: { x: 0, y: 28 } to: { x: "100%", y: 28 } # the right edge of the box this line sits in style: width: 0.8 # pt, default 1 color: "#000000" # default black opacity: 0.5 # 0..=1 paint alpha, default 1 (opaque) style: dashed # solid (default) | dashed | dotted | double ``` ### Anchored to another item An endpoint can name another item's `id:` instead of coordinates, and the line then runs to wherever that item ends up: ```yaml - type: line from: { x: 0, y: 40 } to: { item: total_box, edge: left, offset: { x: -4 } } ``` This is CSS anchor positioning, and it carries CSS's consequences — stated here rather than left to be discovered: - **The line becomes absolutely positioned.** It reserves no height wherever it is authored, and it **paints after** everything else on its page (CSS 2.1 Appendix E paints positioned content above in-flow content). A leader line therefore crosses over the content it points at, not under it. - **It is drawn on the page its TARGET landed on**, not on the page the surrounding section happened to be building. - A **mixed** endpoint pair — one coordinate, one anchor — resolves the coordinate half against the **page margin box**, the same rule an absolutely-placed line already follows. - `edge` is OPTIONAL here, defaulting to `center`. CSS makes `` mandatory because `anchor()` answers a one-axis inset question; a line endpoint is a point — both axes at once — so there is no axis to make the side mandatory for. An endpoint is one arm or the other, never a mix: `{ x: 0, item: total }` is a parse error naming both keys. A dashed cut-here guide is one item: ```yaml - type: line from: { x: 0, y: 4 } to: { x: 515, y: 4 } style: { width: 0.8, color: "#adb5bd", style: dashed } ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `from` / `to` | `{ x, y }` ([`Length`](length.md) each) **or** `{ item, edge?, offset? }` | required | Endpoints relative to the parent origin (margin box / flow region / container **content** box). `x` resolves against that box's width, `y` against its height; a bare number is pt. A `%` `y` under an auto-height parent has no basis — it warns `percent_of_auto` and drops to 0, like every other vertical `%`. | | `from.item` / `to.item` | string (another item's `id:`) | — | Anchors this endpoint to that item's placement. Makes the whole line absolutely positioned; see above. No item with that id warns `anchor_unknown_target` and nothing is drawn. | | `from.edge` / `to.edge` | `top` \| `right` \| `bottom` \| `left` \| `center` | `center` | Which point of the target's border box to land on — the `` subset CSS Anchor Positioning Level 1 defines. | | `from.offset` / `to.offset` | `{ x, y }` (pt numbers) | `{ x: 0, y: 0 }` | Shifts the resolved point. Either axis may be omitted. | | `style.width` | number (pt) | `1` | Stroke width. Capped 0..=1000 pt — it shares `borderWidth`'s bound, since both reach the renderers' stroke math directly. A negative, non-finite, or over-cap width warns `invalid_line_width` and strokes at the 1 pt default (unlike `borderWidth`, whose `0` legitimately means "no border", a `line` that strokes nothing draws nothing). An authored `0` passes through undiagnosed. | | `style.color` | `#rrggbb` | black | Stroke color. | | `style.opacity` | number `0..=1` | `1` | Paint alpha for the stroke. Out-of-range warns `invalid_opacity` and draws opaque. | | `style.style` | `solid` \| `dashed` \| `dotted` \| `double` | `solid` | Stroke pattern, sharing the border wire's keyword set ([style.md](style.md)). `dashed` paints three widths on and three off, `dotted` one and one. `double` draws two parallel lines a third of the width each, offset either side of the authored geometry along its normal — so a diagonal doubles correctly. Advertised as the `line.style` capability. | Unknown keys in `style:` are parse errors — `borderWidth` on a `line` is a typo pointing at `width`, not a silent no-op. In a flow, a line participates like any other item (its reserved height is the segment's vertical extent, floored at 0 — endpoints above the origin reserve nothing rather than pulling the flow cursor back over what is already placed); inside containers the endpoints are relative to the container's content box, so `padding` insets them. ## Underlining a flex child A line is never a flex item itself — it has no box to size — so it always takes the absolute path and resolves against its parent. Nest it in the child you want to underline: ```yaml - type: container box: { direction: row, gap: 12 } # each field is an equal share items: - type: container # ← the field; width decided at layout box: { h: 28, padding: { left: 2 } } items: - { type: text, data: { key: name } } - type: line from: { x: 0, y: 24 } to: { x: "100%", y: 24 } # ← spans the field, whatever it is style: { width: 0.6 } ``` In the `inspect` box index a line reports its **endpoint bounding box** (content == border; zero-thickness when axis-aligned — the stroke inks `width/2` beyond it, and hit-test tolerance is the overlay's job). Capability keys: `line`, `line.style`, `line.length` (the `Length` endpoints — an older engine rejects the string form on `from`/`to`), `line.anchor` (the `{ item, edge?, offset? }` endpoint arm). ## Limitations - A `line` has no `box`: no margin, no padding, no min/max, and it never participates in flex or grid. It always resolves against its parent's content box. - Its `style` is its OWN shape — `width`, `color`, `style`, `opacity` — not the full style property set. - A width that is negative or non-finite falls back to 1 pt (`invalid_line_width`). - `double` becomes two parallel strokes offset along the segment's normal; there is no other multi-stroke form, and a zero-length line stays one stroke. - In a band or an absolute body, endpoints past the sheet warn (`sheet_overflow`). - An anchored endpoint resolves to the **first** placement of that id on the page; a second placement of the same id there warns (`anchor_ambiguous_target`). Two anchored endpoints whose targets land on different pages draw nothing (`anchor_cross_page`). - Ids are not checked for uniqueness, and a line anchored to its own `id:` resolves to nothing (`anchor_unknown_target`) — an anchored item is never itself an anchor target, because the index is built from the pages as laid out. - There is no drag-to-attach on the canvas yet: the Designer edits the anchor as fields, not by dropping an endpoint onto a target. ## See also - [rect.md](rect.md) — for horizontal rules a thin rect also works --- ## docs/engine/link.md — A clickable URL emitted as a PDF link annotation over the item's drawn geometry. # `link:` — hyperlinks A clickable URL on a text item, an image item, or a rich-text span, emitted as a PDF link annotation over the item's drawn geometry. PNG previews ignore links (they have no visual form and no annotation surface). ## Syntax ```yaml - type: text text: ご注文の確認はこちら link: { url: "https://example.com/orders/{order.code}" } - type: image box: { w: 60, h: 24 } src: logo.svg link: { url: "https://example.com" } - type: text spans: - text: "詳しくは" - text: 利用規約 link: { url: "https://example.com/terms" } style: { textDecoration: underline, color: "#0a58ca" } - text: をご覧ください ``` Always the object form `link: { url: … }` — a bare string is a parse error, and unknown keys inside `link` are rejected (typo safety). The object form reserves room for internal destinations later. ## Behavior - `url` takes `{key:format}` interpolation exactly like static text, resolved against the current data scope — inside a `repeat` cell / `repeat_flow` card it resolves per element, so every ticket can carry its own URL. - **Activation area**: text links annotate one rect per wrapped line (per run for spans); image links annotate the draw box. A block-level `link` on a `spans` item reaches every span; a span's own `link` overrides it for that span's runs. - Links survive pagination (each fragment of a long flow text stays clickable) and `textOverflow: clip` / `overflow: hidden` (the visible part annotates; note the annotation rect itself is not pixel-clipped). - **URL gate** (resolved value, params are untrusted): only `http:` / `https:` / `mailto:` / `tel:` schemes, no control characters, at most 2048 bytes. Anything else warns and drops the link — the document still renders, without the annotation. - Styling is the author's job: links are not auto-underlined or recolored (use `textDecoration: underline` etc.). ## Limitations - Text items, image items and rich spans only. There is no link on a `rect`, a `table` cell, or a container. - PNG previews carry no annotation surface, so a link is observable in the PDF only. - `http`, `https`, `mailto` and `tel` only (`unsupported_link_scheme`), 2048 bytes maximum (`link_url_too_long`), and an empty URL drops the link (`empty_link_url`) while the item still renders. ## Diagnostics | Code | Severity | Meaning | | --- | --- | --- | | `unsupported_link_scheme` | warning | scheme outside http/https/mailto/tel, or control characters; link dropped | | `link_url_too_long` | warning | resolved URL over 2048 bytes; link dropped | | `empty_link_url` | warning | resolved URL empty/whitespace; link dropped | | `unknown_data_key` / `missing_data` | error / warning | `{key}` in the URL fails the usual binding checks | Capability key: `link.url`. ## See also - [text.md](text.md) / [image.md](image.md) — the carrying items - [data-binding.md](data-binding.md) — `{key:format}` interpolation - [diagnostics.md](diagnostics.md) — codes by stage --- ## docs/engine/list.md — A bounded per-element list: one entry per line, clamped with an overflow line. # `type: list` A bounded per-element list: renders an array field one entry per line, clamping at the last fitting entry and ending with an overflow line when entries were cut. No pagination by design — inside a `repeat` cell the box is a fixed slot; in a flow an auto-height list simply grows. ## Syntax ```yaml - type: list box: { w: "100%", h: 64 } # definite h activates the clamp data: { key: items } # array: params key, or element field in a cell text: "{name} ×{quantity}" # per-entry template ({key} against the entry) overflowText: "他{count}件" # default "+{count}" style: { fontSize: 8 } ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `data` | `{ key }` | required | The array to render — a params key, or inside a `repeat` cell / `repeat_flow` card a field of the bound element (scope-aware). `scope: document` reads the top-level array even from inside a cell — see [data-binding.md](data-binding.md#scope--the-escape-back-to-the-document). | | `text` | string | unset | Per-entry template with `{key}` interpolation against the entry object. The keys are the fields the array's `items:` schema declares, so a typo warns `unknown_data_key` at validate; each resolves through its declared field spec (display format, `placeholder`, `enum` label). Unset: scalar entries print directly (strings as-is, numbers in plain form — no locale formatting). | | `bindings` | map of name → binding | unset | Named declarations for the `{name}` interpolations in `text`. They resolve per ENTRY like `text` itself, unless one authors `scope: document` ([data-binding.md](data-binding.md#named-binding-declarations)). | | `overflowText` | string | `+{count}` | Template for the trailing overflow line; `{count}` = the number of entries that did **not** fit. | | `box` / `style` / `styleNames` | | | Usual forms; the text properties style every line. | ## Behavior - One line per entry, never wrapped: an entry wider than the box takes the per-entry **ellipsis** (`…`, kinsoku-aware) instead. - A definite `box.h` clamps at the last fitting entry **minus one** and appends the `overflowText` line; auto-height lists grow. - `MAX_LIST_ENTRIES` (1,000) bounds hostile arrays — capped entries still count into `{count}`. - Under `writingMode: vertical_rl` each entry is a right-to-left COLUMN (see [vertical_text.md](vertical_text.md)); tate-chu-yoko (`textCombineUpright`) applies per entry, and the definite-`box.h` `…` clamp keeps a combined group whole — kept or dropped, never split. ## Limitations - No pagination, by design. The box is a fixed slot: entries past it are cut and the overflow line ends the list. - One line per entry. There is no per-entry style, no nested item, and no bullet/numbering key — the entries are the array's own strings. - The source must be an array property (`not_an_array`); a missing key warns (`missing_data`) and an undeclared one (`unknown_data_key`). ## Diagnostics | Code | Meaning | | --- | --- | | `missing_data` / `not_an_array` | array source problems | | `unknown_data_key` | a `text:` key (or a `bindings:` declaration) that the array's declared element does not carry. Silent when no definitions declare the source, or when its `items:` is absent — an unknown shape claims nothing. | Capability key: `list`. ## See also - [table.md](table.md) — paginating columnar data - [repeat.md](repeat.md) — the living use case (`examples/business/shipping-labels-ja`: 2×3 shipping labels with an overflow-count line) --- ## docs/engine/page.md — Sheet geometry: paper size, orientation, and the margin box every coordinate resolves against. # `page` — size, orientation, margin The `page:` block fixes the sheet geometry for every page of the document. All three keys are optional. ## Syntax ```yaml page: size: A4 # A3-A5 | B4/B5 (JIS) | Letter/Legal/Tabloid | { w, h } # size: { w: 80mm, h: 200mm } # custom size, absolute units only orientation: portrait # portrait | landscape margin: 25 # bare number: all sides # margin: { top: 30, left: "5%" } # per-side map, unset side = 0 # margin: [25, 20, 25, 20] # legacy [t, r, b, l] array ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `size` | named \| `{ w, h }` | `A4` | Named size or custom dimensions. Named presets (capability key `page.size.presets`): ISO A — `A3` 841.89 × 1190.55, `A4` 595.28 × 841.89, `A5` 419.53 × 595.28 pt; **JIS** B (the Japanese B series, not ISO B) — `B4` 728.5 × 1031.81 (257 × 364 mm), `B5` 515.91 × 728.5 (182 × 257 mm); North American — `Letter` 612 × 792, `Legal` 612 × 1008, `Tabloid` 792 × 1224 pt. Every named size gets the same default 25pt margin. Custom `w`/`h` are absolute [lengths](length.md) (bare pt or `mm`/`cm`/`in`; `%` is rejected — there is no parent to resolve against), positive, ≤ 14,400 pt per side (the PDF page limit, `MAX_PAGE_PT`). | | `orientation` | `portrait` \| `landscape` | `portrait` | Landscape swaps the two dimensions of a **named** size. It is a **no-op for a custom `{ w, h }`** — a custom size already states its dimensions literally, so `orientation` never double-swaps it; express the orientation in the dimensions. The combination `custom size + orientation: landscape` warns `orientation_ignored`. | | `margin` | number \| map \| array | `25` (all sides) | Printable-area insets. **The all-sides form is a bare pt number only** — `margin: 15mm` (one value with a unit) is a parse error; for units use the per-side map (`{ top: "15mm", right: "15mm", bottom: "15mm", left: "15mm" }`). Sides accept every length unit; `%` resolves against the page **width** for all four sides (the CSS edge rule). **A per-side map specifies all four sides: an unset side is 0, not 25** — `margin: { top: 30 }` zeroes the other three. Negative sides, `auto`, wrong array lengths, and unknown map keys are parse errors. The authored form round-trips. | ## The margin box is the coordinate origin `x: 0` / `y: 0` mean the margin corner — for bands, both body kinds, and the flow region — and widths/heights fill and `%` values resolve against the margin box. Consequences: - **Absolute items may use negative coordinates** to reach into the margin (`x: -25` — bleed/crop-mark territory). - **`margin: 0`** is the one-line escape hatch for sheet-absolute coordinates (coordinate-faithful imports). - Margins that would consume a whole page axis fall back to 0 on that axis with a `page_margin_too_large` warning, so the origin always has positive room. - The resolved post-clamp margins are returned on `LayoutOutput::margin` and surfaced in the `inspect` envelope for Designer margin guides. - **The usable area is page size minus both margins per axis** — compute it once before placing absolute items or bands: A4 with the default `margin: 25` gives a margin box of 595.28 − 50 = **545.28 pt wide** and 841.89 − 50 = **791.89 pt tall**, so a full-width band line is `w: "100%"` (or 545.28) and a bottom-of-page footer item needs `y ≈ 791.89 − item height` (there is no footer-local origin — [page_number.md](page_number.md) shows the computed example). ## Limitations - `orientation` is IGNORED on a custom `{ w, h }` size (`orientation_ignored`) — express the orientation in the dimensions instead, or the two swap each other back. - Margins that consume a page axis fall that axis back to 0 (`page_margin_too_large`). - One geometry per document. Size, orientation and margin are fixed for every page; there is no per-section page setup. ## Diagnostics | Code | Meaning | | --- | --- | | `page_margin_too_large` | margins consume a page axis; that axis falls back to 0 | | `orientation_ignored` | `orientation: landscape` on a custom `{ w, h }` size is ignored (express orientation in the dimensions) | Capability keys: `page.margin`, `flow.box.optional`. ## See also - [template.md](template.md) — where `page:` sits in the file - [length.md](length.md) — the unit forms sides accept - [layout-model.md](layout-model.md) — the box tree the margin box roots --- ## docs/engine/page_break.md — An explicit break: the next flow item starts on a fresh page. # `type: page_break` An explicit page break: the next flow item starts on a fresh page. A bare item — no `box`, no `style`; just the type, an optional `id`, and `visible:` to make the break conditional. ## Syntax ```yaml - { type: text, text: "1ページ目" } - { type: page_break } - { type: text, text: "2ページ目" } ``` ## Behavior - **Flow-only**: bands, absolute bodies, and containers/cells warn (`page_break_in_band` / `page_break_in_absolute_body` / `page_break_in_container`) and skip. - A break at the top of an **untouched page is a no-op**, so consecutive breaks collapse and blank pages are never generated (CSS forced-break collapsing). - **Conditional**: `visible:` decides whether the break happens. A break paints nothing and reserves no box, so `collapse` makes no difference to it — a predicate that does not hold simply means no break. ```yaml - type: page_break visible: { key: order.long_form } ``` - The 500-page cap applies as usual. Capability key: `page_break`. ## Limitations - Flow bodies only. In a band, an absolute body or a container it is skipped (`page_break_in_band`, `page_break_in_absolute_body`, `page_break_in_container`). - No break-before/after on OTHER items: a break is its own item in the flow, not a property of its neighbours. - It cannot force a blank page — a break with nothing after it produces no page. ## See also - [flow.md](flow.md) — pagination semantics - [visible.md](visible.md) — the `visible:` predicate that makes a break conditional - [table.md](table.md) `keepTogether` / [repeat_flow.md](repeat_flow.md) — structure-owned break control --- ## docs/engine/page_number.md — The current page number — band-only, because the count is known at assembly. # `type: page_number` Draws the current page number. **Band-only** (header/footer): the page count is known only at assembly, and bands are the per-page surface. In a body or container it warns (`page_number_in_body` / `page_number_in_container`) and is skipped. ## Syntax ```yaml sections: footer: repeat: every_page items: - type: page_number # Bands share the margin-box origin (top-left) with the body — # there is no footer-local origin, so a footer needs a y near # the bottom: A4 + default margin 25 → margin box is 791.89pt # tall, and y: 780 leaves 12pt for the line. box: { x: 0, y: 780, w: "100%", h: 12 } format: "{page} / {pages}" # the default style: { fontSize: 8, textAlign: center } ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `format` | string | `"{page} / {pages}"` | `{page}` = current page (1-based), `{pages}` = total. Other text passes through (`- {page} -`, `p.{page}`). | | `box` / `style` / `styleNames` | | | Usual text forms ([style.md](style.md)). | Combine with the band's `repeat` mode (`every_page` / `except_first_page` / …) to control which pages show it. Capability key: `page_number`. ## Limitations - Bands only. In a body it warns (`page_number_in_body`) and in a container (`page_number_in_container`), and is skipped either way: the page count is known only at assembly. - The `format` string interpolates `{page}` and `{pages}` and nothing else. There is no numbering-style key (roman, kanji) and no per-section restart. ## See also - [template.md](template.md) — bands and their `repeat` modes --- ## docs/engine/qr_code.md — A QR code encoded at layout time into vector modules — static text or a bound value. # `type: qr_code` A QR code item. Content comes from `text` (static, with `{key}` interpolation) or `data` (a single bound value) — exactly like a text item; the engine encodes whatever string it gets (URL / number / opaque token, no semantics). Encoded at **layout time** into vector module rectangles, so it needs no asset pipeline and works inside `repeat` cells with element-scoped bindings — and in flow, absolute, bands, and containers (flex or absolute placement). ## Syntax ```yaml - type: qr_code box: { w: 60, h: 60 } # w/h required data: { key: url } # …or text: "https://example.com/{code}" errorCorrection: medium # low | medium | quartile | high style: { backgroundColor: "#ffffff" } # the scannable white backing ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `text` / `data` | | one required | Content, exactly like [text.md](text.md) — scope-aware, so per-element inside cells. | | `bindings` | map of name → binding | unset | Named declarations for this item's `{name}` interpolations — the option set the bare `{key}` grammar cannot carry, incl. a key outside `[A-Za-z0-9_.]` ([data-binding.md](data-binding.md#named-binding-declarations)). | | `box.w` / `box.h` | [Length](length.md) | required | The code draws **square** on the smaller content-box side, centered, with the ISO 18004 4-module quiet zone *inside* the box. | | `errorCorrection` | `low` \| `medium` \| `quartile` \| `high` | `medium` | Tolerated damage ~7/15/25/30%; higher levels need more modules for the same content. | | `style` / `styleNames` | | | Decoration only: `backgroundColor` paints under the modules (the usual white backing), plus `borderWidth`/`borderColor`. | ## Guards - The encoded string is the **formatted** value, like any text placement — a field with enum display labels encodes its LABEL; bind `format: value` to encode the machine value ([data-binding.md](data-binding.md#formats)). - Content is capped at **1 KiB** (`qr_content_too_long`) — params are untrusted and modules fan out into tree items. - Modules smaller than 1 pt draw but warn `qr_module_too_small` (scanners may struggle). ## Limitations - `box.w`/`box.h` are required (`qr_missing_size`). - Content is capped at 1 KiB; past that the item is skipped (`qr_content_too_long`). - Modules under 1 pt still draw but warn (`qr_module_too_small`) — a scanner may not read them. - QR only. No other symbology, and no barcode types. - The engine encodes the string it is given and reads no semantics from it: a payload's validity is the host's business. ## Diagnostics | Code | Meaning | | --- | --- | | `qr_missing_size` | `box.w`/`box.h` absent | | `empty_qr_code_item` | neither `text` nor `data` set | | `qr_content_too_long` | content over 1 KiB; skipped | | `qr_module_too_small` | modules < 1 pt at the authored size; drawn anyway | Capability key: `qr_code`. ## See also - [image.md](image.md) — raster/SVG assets (QR needs none) - [repeat.md](repeat.md) — per-element codes in n-up cells --- ## docs/engine/rect.md — A rectangle: pure decoration painted by the unified style properties. # `type: rect` A rectangle: a pure decoration box painted by the unified [`Style`](style.md) — the same `backgroundColor` / `borderWidth` / `borderColor` / `borderStyle` / `opacity` keys every other item uses, including named styles via `styleNames`. `rect` has no content, so `padding` and all text-level style keys are ignored (inert keys on the inline style warn `shape_style_ignored`); it needs explicit `w`/`h`. **Nothing draws unless a style layer authors it**: a bare `rect` is invisible (there is no implicit default stroke). Author `borderWidth` for an outline, `backgroundColor` for a fill. ## Syntax ```yaml - type: rect box: { x: 0, y: 0, w: 200, h: 40 } # w/h required (rect_missing_size) styleNames: [panel] # named styles, like any item style: borderWidth: { bottom: 2 } # scalar or per-side map, pt; unset = none borderColor: "#333333" # scalar or per-side; unset side = black borderStyle: double # solid (default) | double | dashed | dotted borderRadius: 6 # rounds the corners (uniform borders only) backgroundColor: "#f7f7f7" # fill, default none opacity: 0.5 # 0..=1 paint alpha, default 1 (opaque) ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `box` | map | required | Position and size ([box.md](box.md)); margins work, padding is ignored. | | `styleNames` | list | — | Named styles from the `styles:` registry, layered below the inline `style`. | | `style.*` | [Style](style.md) decoration subset | — | `backgroundColor`, `borderWidth`, `borderColor`, `borderStyle`, `borderRadius`, `opacity`. A side draws iff its computed width > 0. Text keys are inert and warn `shape_style_ignored`. | The retired shape-style spelling `fillColor` is a located parse error — use `backgroundColor`. Engines without the `style.shapes.unified` capability key expect the old `fillColor` wire instead. ## Limitations - `box.w`/`box.h` are required (`rect_missing_size`). - A `rect` has no content, so text and box style keys are inert on it (`shape_style_ignored`). - `borderRadius` is a single value for all four corners — there are no per-corner radii — and it is refused on a per-side or `double` border (`border_radius_ignored`, `invalid_border_radius`). ## Diagnostics | Code | Meaning | | --- | --- | | `rect_missing_size` | `box.w`/`box.h` absent | | `shape_style_ignored` | text/box keys on the inline `style` that have no effect on a rect | | `invalid_opacity` | out-of-range `opacity`; drawn opaque | | `invalid_border_width` | non-finite or absurd width; no border | Capability keys: `rect`, `style.shapes.unified`. ## See also - [line.md](line.md) — a stroked segment (keeps its own `width`/`color` style) - [style.md](style.md) — the shared decoration vocabulary - [form_marks.md](form_marks.md) — `ellipse`/`checkbox` (same Style, 1pt frame default) --- ## docs/engine/repeat.md — Imposition / n-up: N data-scoped copies of one cell laid onto each page. # `type: repeat` — imposition / n-up A `repeat` lays **N instances of a `cell`** onto each page, one per element of a `data` array — e.g. four gift receipts on an A4 sheet. Each cell is **data-scoped to its element**, so the cell template is authored once (no per-instance field renaming). Flow-body only. ## Syntax ```yaml - type: repeat data: { key: receipts } # array → one cell per element, in order breakBefore: auto # page (default) | auto — start at the cursor cutMarks: true # trim guides outside the grid grid: columns: 2 # cells across; columns × rows = cells/page rows: 2 # cells down (2×2 = 4-up) direction: row # fill order: row (default) | column gap: 15 # both axes (CSS shorthand) columnGap: 15 # Length, resolves against the region width rowGap: 15 # Length, resolves against the region height cell: # a container: fills its grid slot by default box: { padding: 8 } style: { borderWidth: 0.6 } items: - type: text data: { key: code } # resolves against the bound element - type: text data: { key: store_name, scope: document } # page-global value - type: qr_code box: { w: 60, h: 60 } data: { key: url } ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `data` | `{ key }` | required | The array params key. | | `breakBefore` | `page` \| `auto` | `page` | `page` aligns the grid to a fresh page. `auto` starts it at the flow cursor, so a title above the grid costs rows instead of a whole page — see [Behavior](#behavior). | | `grid.columns` / `grid.rows` | integer ≥ 1 | `1` / `1` | Cells across/down per page. `columns × rows` clamps to `MAX_IMPOSITION_PER_PAGE` (64) with `imposition_grid_clamped`. | | `grid.direction` | `row` \| `column` | `row` | `row` fills left-to-right then wraps down; `column` top-to-bottom then wraps right. | | `grid.gap` | [Length](length.md) | 0 | Both axes at once (the CSS shorthand, as on a [`box.type: grid`](grid.md) container). An axis key below wins over it. | | `grid.columnGap` / `grid.rowGap` | [Length](length.md) | `gap` | Slot width = `(region_w − columnGap·(columns−1)) / columns`; height likewise. `%` resolves against the region width / height respectively. Negative gaps are 0 (CSS). | | `cutMarks` | boolean | `false` | Draw trim guides for the grid — see [Behavior](#behavior). | | `cell` | container | required | A [`container`](container.md) whose parent box is the slot. It **fills the slot by default** (definite height), so `%` and `verticalAlign` resolve against the slot; an explicit `cell.box` insets/resizes within it. | ## Behavior - **Data scope**: inside a cell, every `data:` binding and every `{key}` interpolation resolves against the **bound array element** (like a table row). A value that belongs to the whole document rather than the element — a store name, a pickup date — takes the explicit escape `data: { key: …, scope: document }`. A bare `{key}` has no scope slot, so a mixed line declares the name it wants to escape under [`bindings:`](data-binding.md#named-binding-declarations). See [data-binding.md](data-binding.md#scopes). - **Cut marks** (`cutMarks: true`): short ticks just outside the grid's bounding box at every cut position — the grid's outer edges plus the centre of each interior gap (a gapless grid marks the shared cell edge). Two ticks per cut, one at each end, reaching **outward** so no ink lands on a cell; they extend into the page margin ([bleed/crop-mark territory](page.md)) and are clamped to the sheet. Every page the grid occupies is marked with that page's own row count, and the FULL grid is marked even on a partly filled last page (the sheet is cut into the same pieces either way). A side of the sheet with no room outside the grid draws nothing there and warns `cut_marks_clipped`. The ticks are chrome: they carry no `id` and never appear in the box index. - **Pagination**: cells fill the grid; when a page's grid is full the next cell starts a fresh page's grid. By default (`breakBefore: page`) a `repeat` aligns its grid to the region top on every page, so if the current page already has content it breaks to a fresh page first. Content **after** a `repeat` always starts on a new page, opt-in or not — the grid consumes the region. (For cursor-flowing cards, use [`repeat_flow`](repeat_flow.md).) - **`breakBefore: auto`** starts the grid at the flow cursor instead, so a heading above it no longer costs a page. Only the FIRST page's row count shrinks — to however many whole rows fit under the cursor — and every page after it is a full grid at the region top. **Cell geometry never changes**: slots are always derived from the full region, since an imposed sheet gets physically cut. If not even one row fits under the cursor, the grid falls back to a fresh page (silently: `auto` means "start here if it fits"). On an untouched page `auto` and the default are identical. - **Scope boundary (v1)**: `table` and `page_number` inside a cell are unsupported — they warn and skip. `qr_code` works (encoded at layout time, no asset pipeline), and so does `image`: a static `src:` is one shared asset, a `data:` binding loads one asset per element (element-scoped, capped) — see [image.md](image.md). - A cell-item `id:` yields one box-index placement per element. - The `repeat` item itself yields one box-index fragment **per page** it fills (border == content, at the flow region's x/width, spanning that page's grid top down to the deepest slot placed on it — so a partly filled last page gets a shorter fragment, and a `breakBefore: auto` first page starts at the cursor). The fragment carries the item's `path` and its authored `id:`. Nothing lands in the array (empty/missing data, or truncation before the first cell) means no fragment for that path — a box-index consumer must tolerate a path with zero placements. ## Limitations - Flow bodies only (`repeat_in_absolute_body`, `repeat_in_band`, `repeat_in_container`). - 64 cells per page maximum; a larger grid (or a zero axis) is clamped (`imposition_grid_clamped`). - A cell is a FIXED grid slot: it does not grow with its content. A card that should size to its own text is [`repeat_flow`](repeat_flow.md). - `table` and `page_number` are unsupported inside a cell (`table_in_cell`, `page_number_in_container`). - `cutMarks` with no room outside the grid omit those ticks (`cut_marks_clipped`). - Per-element cell images share the 1000-load cap (`cell_image_assets_capped`). ## Diagnostics | Code | Meaning | | --- | --- | | `repeat_in_absolute_body` / `repeat_in_band` / `repeat_in_container` | `repeat` outside a flow body; skipped | | `imposition_grid_clamped` | grid over the cells/page cap (or a zero axis); clamped | | `cut_marks_clipped` | a sheet side has no room outside the grid; those ticks are omitted | | `cell_image_assets_capped` | per-element cell images over the shared load cap; the rest are skipped | | `missing_data` / `not_an_array` | array source problems | Capability keys: `repeat`, `repeat.breakBefore` (the `breakBefore` key — older engines parse-reject it), `repeat.grid.gap`, `repeat.cutMarks`, `binding.scope` (the `scope: document` escape), `repeat.boxes` (the per-page fragments). Examples: `examples/business/event-tickets-ja`, `examples/business/shipping-labels-ja`, `examples/dev/layout-showcase` (the imposition section demos `breakBefore: auto`). ## See also - [repeat_flow.md](repeat_flow.md) — the flowing card-list counterpart - [grid.md](grid.md) — the *static* grid (fixed children, no data) --- ## docs/engine/repeat_flow.md — One auto-height card per array element, in normal flow — a vertical card list. # `type: repeat_flow` — flow repeat (card list) A `repeat_flow` lays **one auto-height card per element** of a `data` array in normal flow — a vertical card list. Where [`repeat`](repeat.md) is the rigid n-up sheet (fixed grid slots, whole pages at a time — even `breakBefore: auto` only lets the grid START at the cursor), `repeat_flow` is the flowing counterpart: cards start at the cursor, stack with `gap`, and paginate card-by-card. Flow-body only. Unknown keys are parse errors. ## Syntax ```yaml - type: repeat_flow data: { key: cards } # array → one card per element, in order gap: 8 # Length between cards; % of region height item: # a container: the per-element card box: { padding: 8 } style: { backgroundColor: "#f7f7f7" } items: - type: text data: { key: title } # resolves against the bound element - type: text text: "{summary}" ``` ## Keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `data` | `{ key }` | required | The array params key. | | `gap` | [Length](length.md) | 0 | Between cards; negative clamps to 0, out-of-range drops with `length_out_of_range`. | | `item` | container | required | The per-element card, a [`container`](container.md): auto height by default (`box.h` fixes it), horizontal auto margins work, its `style`/`styleNames` cascade to children, and its decoration paints per instance. | ## Behavior - **Data scope**: identical to `repeat` cells — every `data:` / `{key}` inside the card resolves against the bound element; `validate` checks card bindings against the array property's row schema. - **Pagination = keep-together**: a card is an atom; one that doesn't fit moves whole to the next page (no mid-card split), so keep-together is inherent — there is no `keepTogether` key. A card taller than the region warns `section_overflow` and overflows. Bounded by the 500-page cap; the element loop stops once the cap truncates output. - **v1 in-card boundaries** (same as `repeat` cells): `table`/`page_number`/nested repeats warn and skip. `image` works — static `src:` shared, `data:` element-scoped (see [image.md](image.md)). - **Card bindings are element-scoped**, with the same [`scope: document`](data-binding.md#scope--the-escape-back-to-the-document) escape a `repeat` cell takes for a value that belongs to the whole document rather than the card. A `{key}` interpolation reaches that escape by declaring the name under [`bindings:`](data-binding.md#named-binding-declarations). - An empty array places nothing. A card-item `id:` yields one box-index placement per element. - The `repeat_flow` item itself yields one box-index fragment **per page** it spans (border == content, at the flow region's x/width): cards sharing a page merge into one span, the inter-card gap absorbed. The fragment carries the item's `path` and its authored `id:`. An empty array (or truncation before the first card) leaves the path with zero placements — a box-index consumer must tolerate that. ## Limitations - Flow bodies only (`repeat_flow_in_absolute_body`, `repeat_flow_in_band`, `repeat_flow_in_container`). - One card per row. There is no grid — a fixed n-up sheet is [`repeat`](repeat.md). - The per-element sub-template is `item:`, not `cell:`; writing `cell:` is a parse error (`parse_error`). - `table` is unsupported inside a card (`table_in_cell`). - A single card taller than the flow region overflows rather than splitting (`section_overflow`). ## Diagnostics | Code | Meaning | | --- | --- | | `repeat_flow_in_absolute_body` / `repeat_flow_in_band` / `repeat_flow_in_container` | outside a flow body; skipped | | `missing_data` / `not_an_array` | array source problems | | `section_overflow` | a single card taller than the flow region | Capability key: `repeat_flow`. ## See also - [repeat.md](repeat.md) — the rigid n-up counterpart - [table.md](table.md) — columnar data instead of free-form cards --- ## docs/engine/style.md — Every appearance property and the three-surface cascade that resolves them per item. # `style` — appearance properties & the cascade Appearance is a CSS-style property bag: every property is optional, and **unset means inherit** (for inherited properties) or the engine default. Styles come from three authoring surfaces, resolved per item in precedence order (low → high): ```text engine default ← inherited ancestor ← named styles in listed order ← inline style ``` - **Inline**: `style: { fontSize: 12 }` on the item. - **Named styles**: the template-level `styles:` registry; items reference entries with `styleNames: [a, b]` (later wins, layered below the inline style). Caps: 256 registry entries (`MAX_STYLES`), 16 names per item (`MAX_STYLE_NAMES`); an unknown name warns `undefined_style_name`. - **Inherited**: a `container` (and `repeat` cell / `repeat_flow` card) cascades its resolved *inherited* properties to descendants. Unknown property keys are parse errors (`deny_unknown_fields`). ## Inheritance is a separate axis from positioning Positioning resolves by **containment** (a child's `%` and origin compute against its parent box); style resolves by **inheritance** (an unset property takes the nearest ancestor's value). These axes are independent — a container never passes its position down, and style never participates in geometry. See [layout-model.md](layout-model.md). ## Property reference | Property | Type / values | Engine default | Inherited | Description | | --- | --- | --- | --- | --- | | `fontSize` | number (pt) or length string | `10` | yes | Strings take `pt`/`mm`/`cm`/`in`, `em`/`%` (of the *inherited* size — nested relative sizes multiply), or `rem` (of the engine default). Non-positive/non-finite computed values fall back with `invalid_font_size`; computed sizes over 1000 pt fall back with `font_size_out_of_range` (the cap pairs with `lineHeight`'s so the tallest admitted line box is the ±1,000,000 pt resolved-length cap). | | `fontFamily` | string (face id from the lang pack) | pack default face | yes | Valid ids and the default: [fonts.md](fonts.md) (default `biz-udp-gothic`; also `biz-ud-gothic`, `ipamj-mincho`). | | `fontWeight` | `normal` \| `bold` | `normal` | yes | `bold` selects the family's real bold face when it ships one (the `biz-*-gothic` families do); otherwise it renders as synthetic emboldening (glyphs stroked in the text color, advances unchanged — CSS faux bold). | | `fontStyle` | `normal` \| `italic` | `normal` | yes | Synthetic baseline-anchored skew (~12°), advances unchanged. | | `letterSpacing` | number (pt, negative allowed) or length string | `0` | yes | Strings take `pt`/`mm`/`cm`/`in` or `em`/`rem` (`em` = the item's own computed fontSize); `%` is a parse error (CSS letter-spacing has none). Added to every character advance; measurement, wrapping, alignment, and drawing share the one policy. Magnitude capped at ±1000 pt (`invalid_letter_spacing` → 0). | | `lineHeight` | number (multiplier of fontSize) | `1.4` | yes | Non-positive/non-finite falls back with `invalid_line_height`; multipliers over 1000 fall back with `line_height_out_of_range`. | | `color` | `#rrggbb` string | black | yes | Text color. | | `textAlign` | `left` \| `center` \| `right` | `left` | yes | On a vertical (`vertical_rl`) block the keyword names the line's end along the column (`right` = bottom). A `char_grid` honors it too — filling a partly filled line toward its end — but reads it from the ITEM only, never inherited ([char_grid.md](char_grid.md)). | | `verticalAlign` | `top` \| `middle` \| `bottom` | `top` | no | Vertical alignment within the item's box (needs a definite `h`, or `minHeight` slack). On a vertical (`vertical_rl`) block it maps CSS-logically to the column-stack shift: `top` → right edge, `middle` → centered, `bottom` → left edge ([vertical_text.md](vertical_text.md)). | | `lineBreak` | `normal` \| `strict` \| `loose` \| `anywhere` | `normal` | yes | CSS `line-break`. `normal`/`strict`/`loose` pick the CJK kinsoku strictness — `strict` also holds small kana/`ー`/`〜゠` off a line start, `loose` frees centered punctuation (`・:;!?`) and inseparables (`‥…`); `anywhere` breaks between any two characters with no kinsoku. Per-mode sets and migration: [text.md](text.md). | | `textSpacingTrim` | `space_all` \| `normal` \| `trim_start` | `space_all` | yes | Half-width punctuation (CSS `text-spacing-trim` subset). `space_all` (the default) trims nothing — output is unchanged. `normal` trims the internal space between two adjacent fullwidth punctuation glyphs (e.g. `」「`, `、」`) to half-width; `trim_start` also trims a fullwidth opening bracket at a line head (a column head on a vertical block — [vertical_text.md](vertical_text.md)). Engine-synthesized after shaping (works on every face, not font-feature dependent). A v1 subset — see [text.md](text.md). | | `hangingPunctuation` | `none` \| `allow_end` \| `force_end` | `none` | yes | Hanging punctuation (CSS `hanging-punctuation` subset). A line-terminating comma / full stop (`、。,.`) hangs past the end edge. `allow_end` hangs one that would otherwise wrap (keeping the line count down); `force_end` also excludes a *fitting* trailing comma from the alignment width so it hangs into the margin under center/right alignment. Plain text blocks in v1 (vertical columns hang on both the plain and `spans` paths — [vertical_text.md](vertical_text.md)). See [text.md](text.md). | | `backgroundColor` | `#rrggbb` string | none | no | Filled rectangle covering the border box, under the content. Honored on text-drawing items, `container`, `repeat` cells, `image`, and `qr_code` boxes. | | `borderWidth` | number (pt) \| `{ top/right/bottom/left }` map | `0` (no border) | no | Border stroke over the border box, drawn iff a side's computed width > 0. The bare number is all four sides; the map is per side (unset side = 0; negative widths are parse errors). Does not change geometry (each side draws centered on the box edge). Capped 0..=1000 pt (`invalid_border_width`). On a `table` the map form draws the outer frame only ([table.md](table.md)). `rect` items use their own style shape instead. | | `borderColor` | `#rrggbb` string \| per-side map | black | no | Inert while the side's width is 0 — a named style can carry a palette color items opt into with a width. | | `borderStyle` | `solid` \| `double` \| `dashed` \| `dotted` \| per-side map | `solid` | no | `double` splits the side's width into two lines of a third each (CSS). `dashed` paints three stroke widths on and three off, `dotted` one and one; both intervals are floored at 0.25pt so a hair-thin border cannot explode the dash walk. A uniform border (one width, one colour, and any style except `double`) stays a single stroked rect carrying the pattern; a per-side or `double` border renders as filled bands per side, with each dashed/dotted side stroked as a centred line instead (corners overlap; paint order top/right/bottom/left). | | `borderRadius` | number (pt) \| length string | `0` (square) | no | Rounds the border box's corners (CSS `border-radius`, single value — no per-corner form). `%` resolves against BOTH axes independently, so `50%` is a circle on a square box and a full ellipse on an oblong one; an absolute radius too large to fit shrinks by ONE uniform factor (CSS overlapping-curves rule), which is what makes a big value a stadium ("pill") rather than an ellipse. Honored on a box whose border is uniform, and on its `backgroundColor` fill. A per-side or `double` border, a `table`, or a form mark warns `border_radius_ignored` and draws square corners; a negative or non-finite value warns `invalid_border_radius`. `overflow: hidden` clips to the rounded box. | | `textOverflow` | `visible` \| `shrink` \| `ellipsis` \| `clip` | `visible` | no | What text does when it exceeds a **definite `box.h`** (auto-height boxes grow instead). On a vertical block the overflow axis is the box **width**, and a direct-flow `visible` overflow paginates by columns ([vertical_text.md](vertical_text.md)). See [text.md](text.md). | | `overflow` | `visible` \| `hidden` | `visible` | no | What a container-like box (`container`, `repeat` cell, `repeat_flow` card) does with children outside its border box. `hidden` clips children to the border box (decoration stays outside — it *is* the box) and suppresses `container_overflow`; an auto-height box still clips horizontally. Inert on other items (text uses `textOverflow: clip`). | | `textDecoration` | `none` \| `underline` \| `line_through` | `none` | no | Decoration line on text (also `list` entries and table cells via the column style). Position/thickness come from the font's own metrics (post/OS2 tables, conventional fallback), drawn in the text color, sized to each line's measured width; follows `shrink` (computed at the final size) and pagination. On a vertical block it draws a SIDE band per column — underline right of the em cell (the JLREQ side-line convention), line-through on the column axis ([vertical_text.md](vertical_text.md)). Not inherited (matches CSS; decoration *propagation* is not modeled) — set it on the text item itself. | | `opacity` | number `0..=1` | `1` | no | Paint alpha for the item's own painting: text glyphs + decoration line, `backgroundColor` fill, and border stroke alike. **Per-item paint alpha, not CSS group compositing** — nested items don't multiply, and a container's opacity does not affect its children. `rect`/`line` items take `opacity` in their own style shapes. Out-of-range warns `invalid_opacity` and draws opaque. QR modules ignore it (scannability). | | `writingMode` | `horizontal_tb` \| `vertical_rl` | `horizontal_tb` | yes | CSS `writing-mode` subset. `vertical_rl` turns a `type: text` item into a vertical block: characters fill top-to-bottom and columns lay out right-to-left. Honored on every text surface — plain text, rich `spans`, `list`, table text cells, `page_number`; a text `mark:` (the circled-text overlay) is the one warned fallback (`vertical_text_unsupported`). See [vertical_text.md](vertical_text.md). | | `textOrientation` | `mixed` \| `upright` | `mixed` | yes | CSS `text-orientation` subset; consulted only inside a vertical block. `mixed` keeps CJK/kana upright and rotates Latin/digits 90° clockwise; `upright` keeps every character upright. See [vertical_text.md](vertical_text.md). | | `textCombineUpright` | `none` \| `{ digits: 2..=4 }` \| `all` | `none` | yes | Tate-chu-yoko (CSS `text-combine-upright` subset): `digits` makes runs of up to N consecutive ASCII digits share one upright cell of a vertical column; `all` combines the whole styled scope (meant for a short span) into one cell. Plain blocks, rich `spans` (per-span override), vertical `list` entries, and vertical [`char_grid`](char_grid.md) cells (digits only). Longer runs stay uncombined; out-of-range `digits` / unknown keywords are parse errors. Inert in horizontal text. See [vertical_text.md](vertical_text.md) § Tate-chu-yoko. | Colors are `#rrggbb` only; invalid colors warn `invalid_color` (echoes are snippet-capped — colors are untrusted input). ## Named styles ```yaml styles: heading: { fontSize: 24, textAlign: center, lineHeight: 1.2 } framed: { borderWidth: 0.8, borderColor: "#333333" } sections: body: type: flow items: - type: text styleNames: [heading, framed] # listed order, later wins style: { color: "#aa0000" } # inline overrides named text: 領収書 ``` Styles are flat — a style cannot reference another — and stay **named** in the serialized template (round-trip; GUI style picker). ## Box decoration `backgroundColor` (fill) + `borderWidth`/`borderColor` (stroke) decorate the resolved border box, under the item's content — for text blocks, containers, repeat cells/cards, images, and QR codes alike. A uniform solid border is one filled+stroked rectangle; a per-side or `double` border renders as edge-centered filled bands per side (see the `borderWidth`/`borderStyle` rows above), and a dashed/dotted side of such a border is stroked as a centered line so its gaps survive. `borderRadius` rounds the whole border box — fill and stroke follow the same curve, and `overflow: hidden` clips to it. ### Stroke/fill spellings | Surface | Fill | Stroke width | Stroke color | | --- | --- | --- | --- | | every boxed item (`style:`) — text, containers, tables, images, **`rect`**, **`ellipse`/`checkbox`/text `mark`** | `backgroundColor` | `borderWidth` (default 0; marks default 1) | `borderColor` | | `line` (own style shape — a stroke primitive, not a box) | — | `width` (default 1) | `color` | The shape items converged onto the unified `Style` (capability `style.shapes.unified`): `rect` gets per-side borders, `borderStyle`, and named styles like everything else, and — like everything else — **draws nothing unless authored** (the old implicit 1pt rect stroke is gone). Form marks (`ellipse`/`checkbox`/`mark`) keep a 1pt outline default when no layer authors a width, stroke uniformly (a per-side map warns `shape_border_sides_ignored`), and warn `shape_style_ignored` for inert text keys on their inline styles. The retired `fillColor` spelling is a parse error pointing at `backgroundColor`. `line`'s style **rejects unknown keys like every other wire struct** — `borderWidth` on a `line` is a parse error pointing at the typo, not a silent no-op ([line.md](line.md)). ## Limitations - Unset means inherit (for inherited properties) or the engine default. There is no `initial`/`unset` keyword to reset one. - The `styles:` registry is capped at 256 (`too_many_styles`) and `styleNames` at 16 per item (`too_many_style_names`); an undefined name warns and is skipped (`undefined_style_name`). - Out-of-range values FALL BACK rather than failing the render: a bad or oversized font size becomes 10 pt (`invalid_font_size`, `font_size_out_of_range`), a line height 1.4 (`invalid_line_height`, `line_height_out_of_range`), letter spacing 0 (`invalid_letter_spacing`), a border none (`invalid_border_width`), opacity opaque (`invalid_opacity`), and a colour the default (`invalid_color`). - `borderRadius` is one value for all corners, and is refused on a per-side or `double` border, on a `table`, and on the form marks (`border_radius_ignored`). - Not a CSS engine: no selectors, no media queries, no pseudo-classes, no transitions. `styleNames` and a table's `row.conditionalStyles` are the only conditional surfaces. ## Diagnostics | Code | Meaning | | --- | --- | | `undefined_style_name` | `styleNames` references a name not in `styles:` | | `too_many_styles` / `too_many_style_names` | registry/name-list caps exceeded; extras ignored | | `shape_style_ignored` | inert (text/box) keys on a shape item's inline style | | `shape_border_sides_ignored` | per-side `borderWidth` on `ellipse`/`checkbox`/`mark` reduced to the top side | | `border_radius_ignored` | `borderRadius` on a per-side/`double` border, a `table`, or a form mark; square corners drawn | | `invalid_border_radius` | a negative or non-finite `borderRadius`; square corners drawn | | `invalid_font_size` / `invalid_line_height` / `invalid_letter_spacing` / `invalid_border_width` / `invalid_color` / `invalid_opacity` | hostile values; guarded fallback | | `font_size_out_of_range` / `line_height_out_of_range` | finite but absurd values past the 1000 caps; guarded fallback | Capability keys: `styles`, `styleNames`, `style.fontWeight`, `style.fontStyle`, `style.letterSpacing`, `style.lineBreak`, `style.lineBreak.strict_loose`, `style.textSpacingTrim`, `style.hangingPunctuation`, `style.backgroundColor`, `style.backgroundColor.box`, `style.border`, `style.border.sides`, `style.borderStyle`, `style.borderStyle.dashed_dotted`, `style.borderRadius`, `style.textOverflow`, `style.textOverflow.clip`, `style.overflow`, `style.verticalAlign`, `style.textDecoration`, `style.opacity`. ## See also - [text.md](text.md) — text layout, wrapping, overflow policies - [container.md](container.md) — the cascade carrier - [rect.md](rect.md) / [line.md](line.md) — their non-cascading style shapes --- ## docs/engine/table.md — A data-driven table: bound or container columns, spanning headers, conditional rows, row-by-row pagination. # `type: table` A data-driven table: rows come from an array params key, and each column either binds a key relative to the row object or hosts a **`cell:`** sub-template of freely placed items ([Container cells](#container-cells)). In the **flow body** tables paginate row by row and repeat headers on continuation pages. With the **`box:`** they can also be placed like any other item — narrowed and centered in the flow, or dropped into a container / absolute body / band / grid cell — where they render as **one bounded block** (see [Placement with `box`](#placement-with-box)). All four table structs reject unknown keys, and every optional scalar is omitted-when-unset (round-trip clean; effective defaults below). ## Syntax ```yaml - type: table id: items_table data: { key: order_items } # array property in definitions autoPageBreak: true # default true repeatHeader: true # default true keepTogether: false # default false emptyBehavior: collapse # collapse | reserve cellPadding: 4 # pt, default 4 styleNames: [table_frame] style: { borderWidth: 0.5 } # scalar: grid stroke + cascade into cells # style: { borderWidth: { top: 2, right: 2, bottom: 2, left: 2 } } # the per-side map draws an OUTER FRAME around each page fragment # instead (the inner grid keeps the 0.5pt default); borderColor / # borderStyle sides apply to that frame (double / dashed / dotted # included); borderRadius is refused on a table (a ruled grid cannot # meet a curve) and warns border_radius_ignored mergeEmptyCells: false # true: empty-cell runs merge rightward headerGroups: # optional spanning row above the labels - { label: 期間, span: 2 } - { label: 内容, span: 1, style: { fontWeight: bold } } header: height: 22 # Length, fixed style: { backgroundColor: "#ededed", fontWeight: bold } row: minHeight: 24 # Length, default 24pt (auto rows grow) # height: 20 # fixed rows: activates cell textOverflow style: { backgroundColor: "#ffffff" } alternateStyle: { backgroundColor: "#f7f7f7" } # zebra: even rows conditionalStyles: # data-driven layers, over the zebra one - when: { key: kind, equals: heading } # row-relative, form-mark form style: { textAlign: center } columns: - { id: name_col, label: 品名, data: { key: name } } - label: 数量 data: { key: quantity } width: "15%" # Length; omitted = equal leftover share style: { textAlign: right } - label: 金額 data: { key: amount, format: currency } width: 90 style: { textAlign: right, textOverflow: ellipsis } - { label: QR, data: { key: token }, type: qr_code, width: 60 } - { label: 写真, data: { key: photo }, type: image, fit: cover, width: 60 } - label: 明細 # a `cell:` column instead of `data:` width: 120 cell: box: { padding: 3, gap: 2 } items: - { type: text, data: { key: name }, style: { fontWeight: bold } } - { type: text, text: "備考: {note}", style: { fontSize: 8 } } ``` ## Spanning & non-text cells - **`headerGroups`** renders one extra row above the column labels (its `label` interpolates exactly like a column's); each group spans `span` columns (cumulative span clamps to the column count, `header_group_span_clamped`; uncovered columns become one unlabeled trailing cell). It repeats with the header on every page. A group's `style` applies in full: the text properties (`color`, `fontWeight`, `fontSize`) and its own `backgroundColor` / border, which paint over the group row's band so each group can be tinted independently. A group that authors neither keeps the band's default fill (`#ededed`, or `header.style.backgroundColor` when set). - **`mergeEmptyCells: true`**: in a body row, a run of empty text cells merges into the next non-empty cell to its right (trailing empties extend the last non-empty cell; an all-empty row is one full-width cell) — section-heading rows (a rirekisho's education/employment headings) read as one wide cell with correct rules. Swallowed cells lose their column-`id` placement; qr/image/`cell:` columns never merge. Explicit body rowspan/colspan stays out of scope (rows are data-driven). - **`type: qr_code` columns** encode the bound value at layout time (the same caps and diagnostics as the `qr_code` item); the code square scales to the row height minus the cell padding, centered. - **`type: image` columns** draw a per-element asset: at prepare time every row's bound value (data URI or bundled path) loads under `dyn:[].`, gated by the asset policy with the **column `id`** as the policy identity and capped at 1000 loads per template (`cell_image_assets_capped`). `fit` picks the object-fit (default `contain`; `cover`/`none` overflow is clipped). `fit` on a non-image column warns `ignored_column_key`. - Qr/image cells scale to the row height instead of driving it — pair them with `row.height` (or let text cells set the height). ## Container cells A column with **`cell:`** renders a per-row sub-template instead of a bound value: any items a container may hold (`text` / `rect` / `image` / `qr_code` / `list` / `char_grid` / form marks / nested `container`s, flex or `type: grid`), laid out **with the cell's own top-left as the coordinate origin**. It is the `repeat` cell ([repeat.md](repeat.md)), in a table column — same `ContainerItem`, same row scoping. ```yaml - label: 明細 width: 120 cell: box: { padding: 3 } # the cell's own inset; `cellPadding` does not apply style: { fontSize: 8 } # cascades to the items below items: - { type: text, data: { key: name } } # scoped to THIS row - { type: text, text: "残り {days} 日" } # interpolation too ``` - **`data:` and `cell:` are mutually exclusive**, and a column needs one of them (`column_content_conflict` / `column_content_missing`). The `data`-only knobs `type:` and `fit:` are conflicts on a `cell:` column. When a column authors both anyway, layout draws the `cell` (like `src` winning over `data` on an image) so a preview still renders. - **Bindings are row-scoped**: `data: { key: … }` and `{key}` inside the cell read the bound row element, exactly like a table column's own binding or a `repeat` cell's — unless the binding authors [`scope: document`](data-binding.md#scope--the-escape-back-to-the-document), which reads top-level params instead. A bare `{key}` has no scope slot of its own; to escape one name inside a mixed line, declare it under [`bindings:`](data-binding.md#named-binding-declarations). An `image` inside a cell loads one asset per row (`dyn:[].`), sharing the per-template cell image cap; a document-scoped one loads once (`dyn:`). - **Row height**: an auto row is as tall as its tallest cell (the cell's `box.y` offset, content, padding, and vertical margins). A `%` height inside a cell resolves against the row's FINAL height and does not drive it. A fixed `row.height` wins, and content past it is the cell's own overflow story — `overflow: hidden` on the cell clips it ([style.md](style.md)). - **Coordinate origin**: the cell corner. `cellPadding` insets text / qr / image cells only; a container cell uses `cell.box.padding` instead, so `box: { x: 3, y: 4 }` on a child always means 3pt/4pt from the cell's own edge. - **Style layering**: the column's `style`/`styleNames` are the cell's cascade layer (as they are for a text column); `cell.style` layers on top of it, and both reach the items inside. - **Addressing**: the cell is `…columns[c].cell` in the box index and its items `…columns[c].cell.items[j]` — one set per row. - A `table` inside a cell is not supported (`table_in_cell`; skipped). ## Table keys | Key | Type | Default | Description | | --- | --- | --- | --- | | `data` | `{ key }` | required | The array params key; one row per element. | | `columns` | array | required | See below. | | `autoPageBreak` | bool | `true` | Rows that don't fit continue on the next page. `false`: overflowing rows warn `row_overflow`. | | `repeatHeader` | bool | `true` | Redraw the header on continuation pages. | | `keepTogether` | bool | `false` | A table that would split but fits on one page breaks to a fresh page first; taller-than-a-page tables paginate as usual. | | `emptyBehavior` | `collapse` \| `reserve` | `collapse` | Empty array: `collapse` hides the table entirely; `reserve` renders the header row only. | | `cellPadding` | number (pt) | `4` | Inner padding of every cell; negative clamps with `invalid_cell_padding`. | | `style` / `styleNames` | | | The grid stroke (`borderWidth`, table default **0.5 pt** — `0` removes the grid; `borderColor` default black) + inherited properties cascade into every cell. | Row keys live under `row:` (`minHeight` / `height` / `style` / `styleNames` / `alternateStyle` / `alternateStyleNames` / `conditionalStyles`) — see [Rows & header](#rows--header). ## Columns | Key | Type | Description | | --- | --- | --- | | `label` | string | Header cell text (a `cell:` column's header is still a plain label). **Interpolates `{key}` like static text**, against **top-level** params — header chrome belongs to the document, not to any row — so `label: "{labels.amount}"` lets one template print its headings in whichever language the params carry. A label with no `{…}` resolves to itself. | | `data` | `{ key, format? }` | Row-relative binding ([data-binding.md](data-binding.md)); `scope: document` reads top-level params instead, so a column can show one document-wide value (a `type: image` column then loads one shared asset rather than one per row). Exclusive with `cell`; one of the two is required. | | `type` | `text` \| `qr_code` \| `image` | How the bound value renders (default `text`). `qr_code` encodes it at layout time; `image` loads it as a per-row asset — both scale to the row height and are detailed under [Spanning & non-text cells](#spanning--non-text-cells). Only meaningful with `data`. | | `fit` | `contain` \| `cover` \| `stretch` \| `none` | Object-fit for a `type: image` column (default `contain`; same semantics as the [image item](image.md)). On any other column it warns `ignored_column_key`. | | `cell` | container | A per-row sub-template — see [Container cells](#container-cells). Exclusive with `data` / `type` / `fit`. | | `width` | [Length](length.md) | `%` of the flow region width; physical units work; omitted = an **equal share of the leftover** after the sized columns (all omitted = an even split). Negative → 0 with `invalid_column_width`. | | `style` / `styleNames` | | Cell styling: `backgroundColor`/border decorate the full cell; `textOverflow` becomes meaningful with fixed row heights. | | `id` | string | One box-index placement per cell (header included), content box inset by the cell padding. | ## Rows & header - `row.minHeight` (Length, default 24 pt): auto rows grow from it. - `row.height` (Length): fixes every body row — activating the column-level `textOverflow` policies (`shrink`/`ellipsis`/`clip`). Negative → auto with `invalid_row_height`. `%` resolves against the region height. - `row.style`/`styleNames` fill/style body rows; `row.alternateStyle`/`alternateStyleNames` overlay **even** rows (2nd, 4th, … — the `nth-child(even)` analog) for zebra striping. - `row.conditionalStyles`: data-driven row layers — see [Conditional row styles](#conditional-row-styles). - `header.height` (Length): fixes the header row. `header.style.backgroundColor` replaces the default header fill (`#ededed`). - **Vertical alignment** defaults to `middle` in every table row, and an authored `verticalAlign` wins wherever it is written: on a column (for its body cells AND its own label), on `header.style` (every label in that row), or on a `headerGroups` entry (that group). A label takes its column's value over the header's — the precedence `textAlign` already follows there. Capability key: `table.header.style.verticalAlign`. ## Conditional row styles `row.conditionalStyles` styles the rows whose **own data** matches a predicate — the rirekisho heading-row case, where a heading row must centre while the rest of the column stays left-aligned. Entries apply in listed order **after** the base and zebra layers, so a later entry wins over an earlier one and any of them wins over `alternateStyle`. ```yaml row: alternateStyle: { backgroundColor: "#f6f8fa" } # zebra first conditionalStyles: # …then the matches - when: { key: kind, equals: heading } # row-relative key styleNames: [banner] # optional style: { backgroundColor: "#dbe7ff", textAlign: center } - when: { key: flagged } # no `equals` → boolean style: { backgroundColor: "#fff3cd" } ``` - **`when` is the form-mark predicate** ([form_marks.md](form_marks.md)), read **relative to the row element** exactly like a column's `data:`: `equals` matches a scalar type-strictly (`"2"` never equals `2`) or, for an array value, by contains (multi-select); with no `equals` the value is read as a boolean and the entry applies when it is `true`. - A **missing key is silent** — a blank-form params set simply matches nothing, and the table renders exactly as it would without the entries. A value the predicate cannot act on warns (`row_condition_type_mismatch` / `row_condition_value_not_bool`) and the layer is not applied. - The layers behave like `row.style`: **inherited** properties (`textAlign`, `color`, `fontWeight`, …) cascade into the row's cells; **non-inherited** ones (`backgroundColor`, borders) decorate the row band. A column's own `style` still wins for its own cell. - The **header row is never conditioned** (it is chrome, not a bound element), and `mergeEmptyCells` composes: a merged full-width cell takes the conditional alignment across the whole row. - At most **16 entries** per table (`too_many_row_conditions`; extras are ignored). Every body row evaluates every entry. ## Placement with `box` `box` is the same geometry map every item carries (`x`/`y`/`w`/`h`, `margin`/`padding`, min/max — [box.md](box.md)). It is **geometry only**; the grid border stays on `style`. Its effect depends on where the table sits: - **In the flow body**: `box` narrows the table horizontally — `box.w` sets the width, `box.x` offsets it, `auto` left/right margins center it. `box.y` and height stay flow-owned (the table still stacks and **paginates** as usual). Use it for a narrow centered totals table. - **Everywhere else** — a container child, an absolute body, a band, or a `grid` cell — the table renders as **one bounded block** at its `box` and does **not** paginate; `repeatHeader`/`autoPageBreak`/ `keepTogether` are inert there (validate warns `table_pagination_key_ignored`). A block taller than a definite `box.h` (or its container) is the parent's overflow story (`overflow: hidden` clips — [style.md](style.md)). This is what lets two variable-row tables sit **side by side** (each in a `direction: row` container child, or each with its own `box.x`/`box.w`) — the A3 two-page-spread rirekisho layout. A table inside a cell — a `repeat`/`repeat_flow` cell or another table's `cell:` column — is not supported yet (`table_in_cell`; skipped). ```yaml - type: container # left / right columns of an A3 spread box: { direction: row, gap: 20 } items: - type: table # bounded block, no pagination data: { key: education } columns: [ { label: 年, data: { key: year }, width: 40 }, … ] - type: table data: { key: licenses } columns: [ … ] ``` ## Pagination & the box index In the flow body rows paginate with `autoPageBreak`; headers repeat with `repeatHeader`. The table yields **one fragment rectangle per page** it spans in the `inspect` box index (path `…items[i]`); every column yields one placement per cell (path `…items[i].columns[c]`, header included) — id-carrying or not; a `cell:` column adds its container (`…columns[c].cell`) and every item inside it. A `headerGroups` cell is addressed by its own authored position (`…items[i].headerGroups[g]`, repeated with the header on every page), never as the leftmost column it spans — a group click and a column click are different selections. The cells layout synthesizes (the trailing region no group covers, the all-empty `mergeEmptyCells` collapse) are authored nowhere and emit no box, so a click there falls through to the table fragment. A bounded (`box`-placed) table never paginates, so it is a single rectangle. An authored `id:` on the table or a column adds a stable lookup alias on top of the path (a group authors no `id:`). ## Limitations - Not inside a cell. A `table` in a `repeat` cell, a `repeat_flow` card, or a column's `cell:` is skipped (`table_in_cell`). - No body-cell spanning (colspan). `headerGroups` spans the HEADER only, and a span past the column count is clamped (`header_group_span_clamped`); a full-width banner row is expressed from the data with `row.conditionalStyles` + `mergeEmptyCells`. - One grid stroke width — there is no thick-outer/thin-inner pair. - A column takes exactly one of `data`/`cell` (`column_content_conflict`, `column_content_missing`), and `fit` on a non-image column is ignored (`ignored_column_key`). - Outside a flow body a table is one BOUNDED block: the pagination keys warn and do nothing (`table_pagination_key_ignored`). - Sized columns wider than the flow width warn (`table_too_wide`), and with `autoPageBreak: false` an overflowing row warns (`row_overflow`). - `row.conditionalStyles` is capped at 16 entries (`too_many_row_conditions`). - `cellPadding` does not inset a container cell — use `cell.box.padding` — and a radius is refused on a table (`border_radius_ignored`). ## Diagnostics A problem inside a CELL names where the cell's content is authored: a column cell names its column (`…items[i].columns[c]`), so two columns with the same problem report separately instead of collapsing into one warning about the table; a `headerGroups` cell names its group (`…items[i].headerGroups[g]`) — the same address its box carries, so a diagnostics-row jump lands on the cell to fix. Problems about the `headerGroups` LIST itself (`header_group_span_clamped`) and the table's own `data` problems stay on the table item. | Code | Meaning | | --- | --- | | `table_in_cell` | table inside a `repeat`/`repeat_flow` cell or a `cell:` column; skipped | | `column_content_conflict` | a column authors both `data` and `cell` (or `type`/`fit` beside `cell`); `cell` wins | | `column_content_missing` | a column authors neither `data` nor `cell`; the cell renders empty | | `table_pagination_key_ignored` | `repeatHeader`/`autoPageBreak`/`keepTogether` on a bounded (non-flow) table; inert | | `not_an_array` / `missing_data` | the bound source is not an array / absent | | `table_too_wide` | sized columns exceed the flow width | | `row_overflow` | a row overflows with `autoPageBreak: false` | | `invalid_column_width` / `invalid_row_height` / `invalid_cell_padding` | negative geometry; clamped/auto | | `header_group_span_clamped` | `headerGroups` spans exceed the columns; clamped/dropped | | `row_condition_not_boolean` | an `equals`-less `conditionalStyles` entry targets a non-boolean field | | `row_condition_type_mismatch` | a row value's type differs from the entry's `equals`; layer not applied. With definitions, the DECLARED type is checked the same way at validate | | `row_condition_equals_not_declared` | the entry's `equals` literal is outside the field's declared `enum` — a layer that can never apply | | `row_condition_value_not_bool` | a row value is not a boolean under an `equals`-less entry; layer not applied | | `too_many_row_conditions` | more than 16 `conditionalStyles` entries; the rest are ignored | | `ignored_column_key` | `fit` on a non-image column; ignored | | `cell_image_assets_capped` | per-element cell images over the 1000-load cap; rest skipped | | `missing_asset` / `empty_qr_code_item` / `qr_content_too_long` | non-text cell content problems; the cell stays empty | Capability keys: `table`, `table.column.width.length`, `table.row.height`, `table.style`, `table.keepTogether`, `table.boxes`, `table.headerGroups`, `table.headerGroups.style.fill` (per-group fills/borders paint), `table.header.style.verticalAlign`, `table.mergeEmptyCells`, `table.column.type`, `table.row.conditionalStyles`, `table.column.cell` (container cells), `table.box` (placement), `style.border.sides` (the outer-frame form). ## See also - [repeat_flow.md](repeat_flow.md) — free-form cards instead of columns - [list.md](list.md) — a bounded per-element list without pagination --- ## docs/engine/template.md — The file's own shape: top-level keys, the header/body/footer sections, and what every item shares. # Template file structure A template (`templates.yml`, YAML or JSON) describes one document: the page geometry, an optional named-style registry, and the sections whose items produce content. Parsing rejects non-finite numbers (`.nan`/`.inf`) anywhere in the document. ## Syntax ```yaml version: 0.1.0 # optional, informational name: receipt_ja # optional, informational document: # what the PDF says it IS → document.md title: 領収書 {order.code} language: ja-JP page: # page size / orientation / margin → page.md size: A4 margin: 25 styles: # named-style registry (CSS classes) → style.md heading: { fontSize: 24, textAlign: center } sections: header: # optional band, repeats per page repeat: every_page # every_page | first_page | except_first_page | last_page height: 100 # pt, informational (items use absolute coords) items: [ ... ] body: # required: exactly one body type: flow # flow | absolute box: { x: 0, y: 105, w: "100%", h: 620 } # flow only; omitted = whole margin box gap: 16 # flow only: pt between stacked items items: [ ... ] footer: # optional band, same shape as header repeat: every_page items: [ ... ] ``` ## Top-level keys | Key | Type | Required | Description | | --- | --- | --- | --- | | `version` | number or string | no | Author-owned version marker; the engine does not interpret it. `version: 1`, `1.5`, and `"2.0"` all parse and round-trip in the authored form (capability key `template.version.scalar`). | | `name` | string | no | Template name. | | `document` | map | no | Document metadata written into the PDF's `/Info` dictionary and XMP: `title`, `description`, `keywords`, `language`, `authors`. Each value interpolates like static text. PDF only — PNG previews carry no metadata. See [document.md](document.md). | | `page` | map | no | Page size, orientation, and margin. Defaults: A4 portrait, 25pt margin. See [page.md](page.md). | | `styles` | map of name → style | no | Named-style registry; items reference entries via `styleNames`. Max `MAX_STYLES` (256) entries. See [style.md](style.md). | | `defaults` | map | no | Document presentation defaults: `style` (the cascade root; the rem root follows it) + `formats` (per-type format defaults). See [defaults.md](defaults.md). | | `formats` | map of name → format | no | Named format registry (date/datetime patterns), referenced via `format:`. Max 256 entries. See [defaults.md](defaults.md). | | `sections` | map | yes | `header` / `body` / `footer`. Only `body` is required. | ## Sections **Bands** (`header` / `footer`) hold absolutely positioned items repeated on pages selected by `repeat` (default `every_page`). Coordinates resolve against the page margin box. `page_number` items are only valid here. **Body** is either: - `type: flow` — items stack top-down with `gap` between them and paginate; the region `box` resolves against the margin box and may be omitted (= the whole margin box). See [flow.md](flow.md). - `type: absolute` — items place at their authored coordinates on page 1 (no pagination); the margin box is the parent box. ## Items Every entry in an `items:` array is a map with a `type:` discriminator. See the [item-type table in the reference index](README.md#item-types). Common keys shared by (almost) all items: | Key | Type | Description | | --- | --- | --- | | `type` | string | Required discriminator (`text`, `rect`, …). | | `id` | string | Optional stable id. Every item gets per-page resolved rectangles in the `inspect` box index (GUI overlays), addressed by structural `path`; an `id` adds a stable lookup alias on top. | | `box` | map | Geometry: position, size, margin/padding, min/max, layout mode. See [box.md](box.md). | | `style` | map | Inline appearance properties. See [style.md](style.md). | | `styleNames` | array of string | Named styles from `styles:`, applied in listed order (later wins), below `style`. Max 16 per item. | | `link` | `{ url }` | Hyperlink → PDF annotation; text/image items (and rich spans) only. See [link.md](link.md). | Exceptions: `line` has `from`/`to` points instead of `box` and its own `style` shape; `rect` uses its own `style` shape ([rect.md](rect.md)); `page_break` takes only `id`. ## Round-trip fidelity The wire format is designed so a parsed template serializes back to what the author wrote: optional keys are omitted (never injected as defaults), authored length units are preserved (`80mm` stays `80mm`), and named styles stay named. Unknown keys anywhere in the template — every wire struct rejects them — are parse **errors**, not silent no-ops: a typo cannot mean "unset". ## Limitations - Unknown keys anywhere are parse ERRORS, not silent no-ops (`parse_error`): a typo cannot mean "unset", in any wire struct. - `.nan`, `.inf` and overflowing numbers are refused anywhere in the document (`non_finite_number`). - One `body`, and `header`/`footer` are the only bands. There is no third section and no per-page section switch. - `version` is author-owned: the engine records it and interprets nothing. - There is no include/import. A template is one file. ## See also - [README.md](README.md) — the reference index - [layout-model.md](layout-model.md) — how items resolve to absolute pt - [data-binding.md](data-binding.md) — `data:` bindings and `{key}` interpolation --- ## docs/engine/text.md — Static, interpolated, or bound text — inline spans, ruby, wrapping, and overflow. # `type: text` A text item draws static, interpolated, or bound text. Content comes from `text` (static, with `{key}` / `{key:format}` interpolation), `data` (a single bound value), or `spans` (inline rich text, below) — exactly one should be set. None set warns `empty_text_item`; with both `text` and `data` set, **`data` wins** and `text` is ignored (no diagnostic); a non-empty `spans` wins over both (warns `span_content_conflict`). ## Syntax ```yaml - type: text id: subtotal_label # optional; adds an id alias to the inspect box (every item is path-addressed) box: { x: 0, y: 0, w: "50%", h: 16, padding: 2 } text: "小計 {amount.subtotal:currency}" # static + interpolation… # data: { key: order.note } # …or a single bound value styleNames: [amount] style: { fontSize: 9, textAlign: right, textOverflow: ellipsis } ``` | Key | Type | Description | | --- | --- | --- | | `text` | string | Static content with `{key}` / `{key:format}` interpolation ([data-binding.md](data-binding.md)); `{{` escapes a literal `{`. | | `data` | `{ key, format? }` | A single bound params value, formatted per the [format rules](data-binding.md). | | `bindings` | map of name → `{ key, format?, placeholder?, scope? }` | Named declarations for this item's `{name}` interpolations — the option set the bare `{key}` grammar cannot carry, incl. a key outside `[A-Za-z0-9_.]` ([data-binding.md](data-binding.md#named-binding-declarations)). | | `spans` | list | Inline rich text (below): styled fragments drawn as one wrapped block. | | `ruby` | list of `{ base, text }` | Readings (furigana) — verbatim strings matched in order against the drawn text, drawn above the base runs (horizontal; § Ruby below) or beside them (vertical; [vertical_text.md](vertical_text.md) § Ruby). Plain and `spans` blocks alike. | | `rubySize` | [length](length.md) | Ruby reading font size; unset = half the item's font size. | | `box` | map | See [box.md](box.md). `h` omitted = auto height (grows with the wrapped text); `w` omitted = fill. | | `style` / `styleNames` | | Full property set — see [style.md](style.md). | ## Inline rich text (`spans`) Word-level bold / color / decoration inside one block: `spans` is a list of fragments, each with the same `text`-or-`data` content rule as the item and its own `style` / `styleNames` layered **on top of the block's computed style** (named styles in listed order, then the inline style). Fragments concatenate in order with no separator; wrapping runs over the joined text, so a Latin word crossing a span boundary still wraps as one word and kinsoku moves characters across span edges. ```yaml - type: text box: { w: "60%" } style: { fontSize: 12 } # the block style spans layer on spans: - text: "合計 " - data: { key: total, format: currency } style: { fontWeight: bold, color: "#c00000", fontSize: 16 } - text: "(税込)" styleNames: [muted] ``` Only the text-run properties apply per span: `fontSize`, `fontFamily`, `fontWeight`, `fontStyle`, `letterSpacing`, `color`, `textDecoration` (a block-level `textDecoration` propagates to spans that don't set their own, CSS-style). Anything else authored on a span's inline `style` warns `ignored_span_style` and does nothing — alignment, overflow, decoration-of-the-box, and `lineHeight` stay block-level. Mixed sizes share one **uniform line grid**: the largest span size drives the block's line height and the deepest ascent one shared baseline, so lines form a baseline grid (per-line line boxes are a recorded follow-up). Overflow on a rich block honors `visible` and `clip`; `shrink` / `ellipsis` warn `span_overflow_unsupported` and behave like `visible` for now. Long rich flow text paginates like plain text. A text item takes at most 256 spans (`too_many_spans`; extra spans are dropped). ## Shaping (kerning, ligatures) Text is **shaped** with HarfBuzz (harfrust) — you author plain strings and the engine applies the font's OpenType layout automatically: - **Kerning** tightens pairs like `AV` or `To.` (the glyphs move closer; no markup needed). - **Standard ligatures** collapse sequences like `fi`/`ffi` into one glyph. Text extraction (copy-paste / search in the PDF) still yields the original characters — the ligature maps back to its whole cluster. - Setting a non-zero **`letterSpacing`** turns optional ligatures back off (matching CSS), so tracked text stays letter-by-letter. Shaping is invisible in the authoring surface (there is nothing to turn on) and identical across the PDF and PNG backends — the engine decides every glyph and advance once, and both renderers draw the same result. Measurement and drawing share the shaped result, so a line's reserved width always equals its drawn width. ## Wrapping & line breaking Text wraps greedily at the content-box width (border-box `w` minus horizontal padding), honoring `letterSpacing` in measurement so reserved and drawn widths cannot disagree. `lineBreak` (a CSS `line-break` subset, inherited) selects the kinsoku strictness: | Value | Held off a line start (line-start kinsoku) | | --- | --- | | `normal` (default) | closing brackets, closing quotes `’”`, commas/full stops `、。,.`, centered punctuation `・:;!?`, inseparables `‥…`, iteration marks `々` — but small kana (`っ`, `ゃ` …), `ー`, and `〜` **may** start a line | | `strict` | everything `normal` holds, **plus** small kana, `ー`, and the CJK hyphens `〜゠` | | `loose` | only closing brackets, closing quotes, and commas/full stops; centered punctuation, inseparables, iteration marks, small kana, and `ー` may all start a line | | `anywhere` | no kinsoku — break between any two characters | Line-end kinsoku (an opening bracket or opening quote never ends a line) applies the same way in `normal`/`strict`/`loose`; only `anywhere` drops it. Prohibited characters are pushed off line edges by push-out (moving the preceding character down). ### The character sets Japanese and Chinese share one set — the classification is per character, not per language. | Class | Characters | Held | | --- | --- | --- | | Closing brackets | `)]}〕〉》」』】」` `〗〙〛` `〞〟` | off a line start, every mode | | Closing quotes | `’ ”` | off a line start, every mode | | Commas / full stops | `、。,.。、` | off a line start, every mode | | Centered punctuation, inseparables | `・:;・!?‼⁇⁈⁉` `‥…` | off a line start in `normal`/`strict` | | Iteration marks | `々ゝゞヽヾ` | off a line start in `normal`/`strict` | | Small kana, prolonged sound mark | `ぁぃぅぇぉっゃゅょゎ` `ァィゥェォッャュョヮ` `ー` | off a line start in `strict` only | | CJK hyphens | `〜゠` | off a line start in `strict` only | | Opening brackets | `([{〔〈《「『【「` `〖〘〚` `〝` | off a line **end**, every mode | | Opening quotes | `‘ “` | off a line **end**, every mode | Which end a character belongs to comes from its Unicode category. Most of the set is unambiguous: `Ps` opens and `Pe` closes, which is why `〝〞〟` sit with the brackets — they are named "quotation mark" but categorized `Ps`/`Pe`, so they behave as structural open/close forms. `‘’“”` are the exception. Unicode files them under one line-break class (QU) that does not say which end of a quotation they sit at, so the split follows their category instead — `Pi` (initial) opens, `Pf` (final) closes. That is what Chinese practice asks for: a closing quote never heads a line, an opening quote never ends one. **Deliberately not classified**, so a break stays legal on both sides: | Character | Why | | --- | --- | | `·` U+00B7 (interpunct) | Unicode class AI — it means different things in different languages, and Latin text uses it as a field separator (`address · tel · web`), where holding it back would drag a letter off the previous line. | | `‧` U+2027 | Class BA — a break *opportunity*, not a prohibition. | | `—` U+2014 | Class B2 — a break opportunity on both sides. A doubled `——` (and `……`) already stays whole anyway: a run of non-CJK characters wraps as one unit unless a segmenter finds boundaries inside it, and none of these is Thai (§ Thai below). | | `‐ –` U+2010, U+2013 | Class HH (hyphens), whose rule is conditional on the *preceding* character's class — context a per-character test cannot see. | | `% ¥` | The PO/PR classes are unmodelled for the same context-dependence. | | `﹁﹂﹃﹄` U+FE41–FE44 | Nothing authors these: the engine maps `「」『』` onto them at shape time, *after* wrapping, so the wrapper only ever sees the canonical forms — which are classified above. | > **Migration.** `normal` follows CSS: small kana and `ー` may begin a > line. Earlier engines held them back under `normal`; to keep that > behavior, set `lineBreak: strict` (once, on `defaults.style` for the > whole document). ### Thai Thai writes without spaces between words, so there is nothing for a greedy wrapper to break on: an entire Thai paragraph arrives as one unbreakable run and would be split per character at whatever position the line width happened to reach — inside words, and between a character and the vowel or tone mark that belongs to it. Thai runs are therefore segmented into words first (ICU4X's line segmenter, the same Unicode data a browser uses), and those word boundaries become the break opportunities a space would otherwise provide. Three consequences worth knowing: - **A script change is not itself a break opportunity.** `abcไทย` stays one unit — UAX #14 puts a Latin letter and the Thai word glued to it in the same class. A Latin prefix does not suppress the Thai word boundaries further along, though: `abcภาษาไทย` still breaks between `ภาษา` and `ไทย`. - **`lineBreak` does not switch it off.** Segmentation creates break *opportunities*; `lineBreak` governs the kinsoku *prohibitions* applied afterwards. Thai wraps at word boundaries under every value, exactly as a Latin sentence keeps wrapping at its spaces. - **A cluster is not cut on either side.** Where a single Thai word is wider than the whole line and has to be broken per character, the break is held back so a non-spacing mark never opens a line, and a leading vowel (เ แ โ ใ ไ, written before the consonant it is pronounced after) never ends one. The vowels written IN LINE — `ะ`, `า`, `ำ` — take a break on either side like any other letter, because they are ordinary advancing characters. This guard is the last-resort path only; a break the segmenter itself offers is already at a word boundary. Nothing else changes: text in any other script tokenizes exactly as before, so no existing document's line breaking moves. A `\n` in the text splits it into **paragraphs**: each paragraph wraps independently and starts on a new line (YAML block scalars `|`/`|-` are the natural way to author multi-line text). Wrapping trims spaces at line edges, so leading ASCII spaces do not survive as indentation — for hard indentation (e.g. code samples), use no-break spaces (U+00A0) instead; and remember `{{` when the sample text itself contains a literal `{key}`-shaped string ([data-binding.md](data-binding.md)). `examples/dev/layout-showcase`'s code panels use both. Characters no face in the resolved fallback chain can map draw as the `.notdef` box and warn `missing_glyph` once per block (a rich block's spans share one bounded warning). ## JP micro-typography (`textSpacingTrim`, `hangingPunctuation`) Two inherited knobs refine Japanese punctuation spacing. Both default to a no-op, so existing documents are unaffected; set them on `defaults.style` to apply document-wide. **Half-width punctuation — `textSpacingTrim`** trims the internal space a fullwidth punctuation glyph carries in half its em box: | value | effect | | --- | --- | | `space_all` (default) | no trimming — every punctuation keeps its full em | | `normal` | trims the space **between two adjacent** fullwidth punctuation glyphs (`」「` → the pair tightens to one em; `、」` drops the comma's trailing half) | | `trim_start` | everything `normal` does, **plus** a fullwidth opening bracket at a line head is pulled to the margin | It is **engine-synthesized after shaping** — no bundled face carries the OpenType `chws` feature, so the trim is computed from the glyph advances and is deterministic across faces. The measured line width shrinks accordingly, so alignment and the `inspect` line metrics stay honest. v1 is a subset: trimming happens only between two adjacent punctuation (and at a line head for `trim_start`); punctuation-before-ideograph spacing, line-end trimming, and per-font `chws` tables are not modelled. It applies wherever text is laid out through the shared text block — plain **text** items, **table cells**, and header/footer **bands** — and to **rich** (`spans`) blocks; it does **not** apply to `list` entries or `char_grid` cells. **Hanging punctuation — `hangingPunctuation`** lets a line-terminating comma or full stop (`、。,.`) hang past the end edge instead of wrapping: | value | effect | | --- | --- | | `none` (default) | no hanging — a trailing comma wraps (and kinsoku push-out applies) | | `allow_end` | a comma that would wrap instead hangs on the line, keeping the line count down | | `force_end` | also excludes a *fitting* trailing comma from the alignment width, so it hangs into the margin under center / right alignment | The hung character is **excluded from the alignment width but kept in the reported (inked) line width**, so a GUI overlay measured from `inspect` does not lie. A line hangs **at most one character** (standard hanging), and a comma may hang only when removing it leaves a legal line start — a comma glued to a closing bracket (the `…。」` closing-quote pattern) is pushed out whole by kinsoku instead, so hanging never exposes a new line-start violation. When hanging is active, kinsoku leaves exactly those hangable commas for the hang pass to pull up (each pass runs once, so they always terminate). Hanging applies to plain text blocks (text items, table cells, bands); horizontal rich (`spans`) blocks are trimmed but not hung in v1 — vertical columns hang on both paths ([vertical_text.md](vertical_text.md)) — and `ellipsis` clamping drops any hang. ## Overflow (`textOverflow`, definite `h` only) Auto-height boxes grow to fit; the policies act only when the box has a **definite `h`** and the wrapped text overflows. With a policy set, the block reserves the *authored* height, so flow siblings stay put. | Value | Behavior | | --- | --- | | `visible` (default) | Draw everything, warn `text_overflow`, grow the reserved block. | | `shrink` | Bisect the font size down (24 fixed steps, 4 pt floor) until the wrapped text fits; `lineHeight` scales along. At the floor the warning stays and the block grows like `visible`. Thinreports `fit`. | | `ellipsis` | Clamp to the fitting lines and end the last with `…`, measured with the same face/size/letterSpacing, never after a line-end-kinsoku character; degrades to a bare `…` when nothing fits. Thinreports `truncate`. | | `clip` | Keep every line, reserve exactly the authored height, and cut the drawn text at the border-box edge — a partial line stays partially visible. Suppresses the warning. | ## Ruby (`ruby: [{ base, text }]`) Template-authored readings drawn **above** their base runs — the horizontal counterpart of [vertical ruby](vertical_text.md) (same matching, same caps, same shrink rules): ```yaml - type: text text: "吾輩は猫である" box: { w: 200 } rubySize: 6 # optional; default = half the font size ruby: - { base: 吾輩, text: わがはい } style: { lineHeight: 1.8 } ``` - Entries apply **in listed order, non-overlapping** against the DRAWN text; an unmatched base warns `ruby_base_not_found` and later entries still apply. - Each reading is centered over its base run's shaped extent, its bottom touching the run's **em band top**, shrunk linearly to the run's width with a 4pt readability floor (`ruby_overflow` past it), and split proportionally when the base wraps across lines. On a `spans` block the reading sits above ITS base run's own em band (a small span's band starts below the shared baseline's top). - **The line box never grows** (the engine's fixed-leading model, unlike CSS ruby): author `lineHeight` ≳ 1.5 so the reading band has room between lines — the FIRST line's reading extends above the block's border box (give the item a top margin or padding when that matters). - A ruby'd auto-height flow item **paginates with its readings**: each fragment carries the readings of its own lines. - Entry caps and skip rules are the vertical ones (256 entries; empty / over-64-chars skipped with `empty_ruby_entry` / `ruby_entry_too_long`). ## Pagination of long text An **auto-height** text directly in a flow that is taller than the whole flow region splits at line boundaries like table rows: it fills the space left on the current page, then continues page by page. Decoration and vertical padding/margins are **cloned** onto every fragment (CSS `box-decoration-break: clone`) — the whole box, so per-side `borderWidth`s, `borderStyle: double` and dashed sides each redraw complete at the fragment's own height; an `id` yields one box-index placement per fragment. Definite-`h` text never splits (that is `textOverflow`'s domain). A `minHeight` taller than the text reserves space that [`verticalAlign`](style.md) distributes, and the fragments carry that reservation between them: the slack above the content leads the FIRST fragment (so `verticalAlign: bottom` keeps pushing the text down), the slack below it trails the LAST. The fragment heights therefore still sum to the reserved height. With no `minHeight` there is no slack and every fragment is exactly its lines. ## Limitations - Exactly one of `text`/`data`/`spans`. None set warns (`empty_text_item`), and `spans` beside the others wins (`span_content_conflict`). - `textOverflow: shrink`/`ellipsis` needs a definite `h`, and does NOT work on a rich `spans` block — it falls back to visible (`span_overflow_unsupported`). Past the 4 pt shrink floor the text overflows and warns (`text_overflow`). - 256 spans (`too_many_spans`) and 256 ruby entries (`too_many_ruby_entries`) per item. A ruby `base` that never occurs in the drawn text is skipped (`ruby_base_not_found`), an entry over 64 characters is skipped (`ruby_entry_too_long`), and a reading that overflows its base even at the 4 pt floor warns (`ruby_overflow`). - Block-level style keys on a span are inert (`ignored_span_style`). - No hyphenation, and no justified alignment: `textAlign` is `left`, `center` or `right`. ## Diagnostics | Code | Meaning | | --- | --- | | `empty_text_item` | neither `text` nor `data` (nor `spans`) set | | `text_overflow` | wrapped text exceeds a definite `h` (policy `visible`, or `shrink` at its floor) | | `missing_glyph` | characters no chain face can map (tofu); deduped and bounded per block | | `span_content_conflict` | `spans` beside `text`/`data` (spans win), or a span with both `text` and `data` (data wins) | | `empty_span` | a span with neither `text` nor `data` (renders nothing) | | `too_many_spans` | over the 256-span cap; extras dropped | | `ignored_span_style` | span-inert keys on a span's inline `style` | | `span_overflow_unsupported` | `shrink`/`ellipsis` on a rich block (falls back to `visible`) | | `ruby_base_not_found` / `ruby_overflow` | ruby matching / shrink-floor problems — see § Ruby | | `missing_data` / `unknown_data_key` / `format_error` | binding problems — see [data-binding.md](data-binding.md) | Capability keys: `text`, `text.spans`, `style.lineBreak.strict_loose` (the `strict`/`loose` values **and** the CSS-aligned `normal`), `style.textSpacingTrim`, `style.hangingPunctuation` — overflow: `style.textOverflow`, `style.textOverflow.clip`. ## See also - [link.md](link.md) — `link: { url }` on the item or per span - [style.md](style.md) — the full property table - [list.md](list.md) — one line per array entry with an overflow clamp - [data-binding.md](data-binding.md) — `{key:format}` semantics --- ## docs/engine/vertical_text.md — Vertical writing: characters fill a column top-to-bottom, columns lay out right-to-left. # Vertical writing `writingMode: vertical_rl` turns a `type: text` item into a **vertical block**: characters fill a column top-to-bottom, and columns lay out **right-to-left** from the box's right edge — the standard Japanese the vertical direction (certificates, envelopes, gift labels, vertical receipts). Both `writingMode` and `textOrientation` are ordinary inherited [style](style.md) properties, so a `container` can set the mode once and its text children inherit it. The mode is honored on **every text surface**, not just a plain text item: rich [`spans`](text.md) (each span's runs stack down the column in its own font/size/color), [`list`](list.md) (each entry becomes a right-to-left column), [table](table.md) text cells (the cell fills the row rectangle; an auto row is as tall as its longest column), and [`page_number`](page_number.md). A text `mark:` (the circled-text overlay) is the one warned fallback — see the scope section below. This is the free-flowing vertical text counterpart to [`char_grid`](char_grid.md) (the fixed manuscript-paper grid): here the engine wraps proportionally and paginates the box, rather than filling numbered cells. ## Syntax ```yaml - type: text text: "吾輩は猫である。名前はまだ無い。" box: { w: 40, h: 260 } # h is the column length to wrap against style: writingMode: vertical_rl # horizontal_tb (default) | vertical_rl textOrientation: mixed # mixed (default) | upright fontSize: 18 lineHeight: 1.6 # the column WIDTH (cross-axis spacing) textAlign: left # along-column: left→top, center, right→bottom ``` - **`box.h`** is the column length characters wrap against; a column that fills it starts a new column to its left. With no `h`, the block wraps against the containing region's height. - **`box.w`** bounds how many columns fit; more columns than fit warn `vertical_text_overflow` (they still draw, extending left). - **`lineHeight`** is the em multiplier for the **column width** (the gap between columns), mirroring how it sets line spacing in horizontal text. - **`textAlign`** distributes a short column *along* its length: `left` → top, `center` → centered, `right` → bottom. ## Character orientation (`textOrientation`) Inside a vertical block, `mixed` (the default) follows the full Unicode **UAX#50 Vertical_Orientation property**: CJK, kana, Hangul, and fullwidth forms stay **upright**, while Latin letters, digits, and other horizontal scripts rotate **90° clockwise** so a run like `2026` reads down the column (halfwidth katakana rotate too, per the property). `upright` keeps every character upright (useful for short labels where rotated Latin reads awkwardly). Upright runs are shaped with the font's **GSUB `vert` feature** and real `vmtx` advances — the long vowel mark `ー`, dashes, and brackets (`「」()—` …) rotate as the font's vertical alternates, and clause punctuation (`、。`) sits where the font's vert glyphs place it (the top-right of the cell in any CJK font). Rotated Latin runs are shaped horizontally, so **kerning and ligatures** apply exactly as in horizontal text. A font the shaper cannot parse degrades to a per-char fallback: a closed vertical presentation-form table plus engine-synthesized `、。`/small-kana nudges. ## Surfaces and per-surface notes - **Rich `spans`.** Each span's runs stack down the column in its own font / size / color; the uniform column width is the largest span size × `lineHeight` (like the horizontal rich line grid). Kinsoku breaks columns across span boundaries; a span `textDecoration` draws its own side band (see the block knobs below). - **`list`.** Each array entry is one column, laid right-to-left (the first entry rightmost). `box.w` caps how many columns fit — excess entries collapse into a leftmost `+{count}` (`overflowText`) column, the axis-swapped analog of the horizontal list reserving a line; a definite `box.h` clamps an over-long entry's down-extent with a trailing `…`. - **Table text cells.** A cell whose column style is `vertical_rl` fills the row rectangle with columns. A definite `row.height` wraps columns against it; an auto row is as tall as its longest column. (The table's own default `verticalAlign: Middle` is not treated as an authored knob, so it does not warn.) - **`page_number`.** The `{page}/{pages}` string stacks down a column, repeating per page. ## Block knobs (axes swapped) Every block-level [style](style.md) knob applies on a vertical block, reading along the swapped axes. Kinsoku (`lineBreak`, every mode) is honored throughout — a comma never starts a column, an opening bracket never ends one. - **`textOverflow`** runs against the box **width** (the vertical overflow axis — columns stack right-to-left): `clip` reserves the authored box and cuts at its edge; `shrink` bisects the font size (column width scales along, 4 pt floor); `ellipsis` keeps the columns that fit and ends the last with `…` (line-end-kinsoku-aware). `visible` (the default) warns `vertical_text_overflow` — except as a direct flow item, where it **paginates** (below). Rich `spans` keep the horizontal parity: `clip` is honored, `shrink`/`ellipsis` warn `span_overflow_unsupported` and behave like `visible`. - **`verticalAlign`** maps CSS-logically to the column-stack shift: `top` → the right edge (the default), `middle` → centered, `bottom` → the left edge. (A table's own injected `verticalAlign: middle` cell default stays neutral — an *authored* cell value is also neutralized in v1.) - **`hangingPunctuation`** hangs a column-terminating comma / full stop past the column **bottom** — excluded from the alignment basis, kept in the inked `width`, at most one per column, exactly the horizontal rules. Vertical columns hang on both the plain and `spans` paths (horizontal rich blocks remain un-hung in v1). - **`textDecoration`** draws a **side band** per column (per run for `spans`): `underline` just right of the em cell — the JLREQ side-line convention; CSS leaves `text-underline-position: auto` UA-defined in vertical modes — and `line_through` on the column axis. Thickness comes from the same font tables as the horizontal line. - **`textSpacingTrim`** (half-width punctuation) trims fullwidth punctuation cells to half-em down the column: `normal` between two adjacent fullwidth punctuation cells (a closing form keeps its ink at the cell top, an opening form slides up), `trim_start` also at a column-head opening bracket. Engine-synthesized like the horizontal pass (same class tables), so it works on every face. ## Tate-chu-yoko (`textCombineUpright`) Short digit runs — a day "31", a year "2026" — read badly rotated down a column. `textCombineUpright` (CSS `text-combine-upright` subset, an inherited [style](style.md) property) turns runs of up to N consecutive ASCII digits into ONE upright cell: ```yaml - type: text text: "第12話は2026年1月31日" box: { w: 40, h: 260 } style: writingMode: vertical_rl textCombineUpright: { digits: 2 } # none (default) | { digits: 2..=4 } ``` - A run of 2..=N digits shares one 1em cell, shaped horizontally and compressed (never stretched) to fit; a run LONGER than N is not combined at all (the CSS `digits` rule — no suffix of it re-combines). - **`all`** (CSS `text-combine-upright: all`) combines the WHOLE styled scope into one upright cell — meant for a short `spans` entry (a date span combined as one cell); a whole `all` span is atomic (it never wraps mid-span) and compress-only, like `digits`. Honored literally on a plain vertical block too (the entire content becomes one cell — use it on short content). - `digits` outside 2..=4 and unknown keywords are parse errors; `none` turns an inherited value off. - The same digit-run vocabulary drives [`char_grid`](char_grid.md) cells: a vertical grid groups a matching digit run into one cell (`all` does not apply to a grid of cells). - Surfaces: plain vertical blocks, rich `spans` (the span cascade carries it — a span-level value overrides the block's), vertical [`list`](list.md) entries (the definite-`h` `…` clamp keeps a combined group whole — kept or dropped, never split), and vertical `char_grid` (digits only). Horizontal text ignores it (CSS: vertical modes only). Capability keys: `style.textCombineUpright` (digit runs), `style.textCombineUpright.all` (the `all` keyword, the span/list surfaces, and ruby on every surface + ruby-aware pagination). ## Ruby (`ruby: [{ base, text }]`) Template-authored readings — the structured counterpart of char_grid's opt-in aozora markup (bound user data is never interpreted; `base`/`text` are verbatim template strings, never interpolated). Honored on **every text surface**: vertical plain and `spans` blocks (readings right of the base runs, this section), and horizontal plain and `spans` blocks (readings above the base runs — see [text.md](text.md) § Ruby): ```yaml - type: text text: "吾輩は猫である" box: { w: 60, h: 260 } rubySize: 6 # optional; default = half the font size ruby: - { base: 吾輩, text: わがはい } - { base: 猫, text: ねこ } style: { writingMode: vertical_rl, lineHeight: 1.8 } ``` - Entries apply **in listed order, non-overlapping**: each base matches the first occurrence in the DRAWN text after the previous match (what the reader sees is what is annotated — an ellipsized tail cannot match). An unmatched base warns `ruby_base_not_found` and later entries still apply. - Each reading is a small upright column immediately **right of its base run's em cell** (the JLREQ convention), shrunk linearly to the run's extent with a 4pt readability floor (`ruby_overflow` past it), and split proportionally when the base wraps across columns. Give the block `lineHeight` ≳ 1.5 (or a smaller `rubySize`) so readings have a clear band between columns. - On a `spans` block, bases are located through the per-run arrangements (a base may cross a span boundary within a column); the reading sits right of ITS base run's em cell and draws in the block style at the shared preferred size. - A ruby'd item placed directly in a flow region **paginates with its readings**: each fragment carries the readings of its own columns, re-anchored beside them (a base run spanning the page break splits its reading proportionally, like any wrapped base). - Entries are bounded (256; `too_many_ruby_entries`); an empty `base`/`text` warns `empty_ruby_entry` and one longer than 64 characters warns `ruby_entry_too_long` — both skipped. ## Column pagination (flow) A vertical text item placed **directly in a flow region** whose columns need more width than the box holds continues on the **next page**: whole columns, right-to-left reading order preserved, each following page re-starting at its own right edge (the `textOverflow: visible` default; a policy that resolved the overflow never paginates). The `vertical_text_overflow` warning is replaced by the pagination — it still fires when not even one column fits, and in bounded contexts (containers, bands, absolute bodies, cells), which keep the place-as-one-unit behavior. ## Inspect metrics (per column) A vertical text item's `inspect` placement carries `text: { columns: [...] }` — the axis-swapped analog of the horizontal `lines`: per column `y`/`height` (the drawn extent), `baseline` (the column-axis x glyph cells center on), and `emLeft`/`emRight` (the em band). Capability key: `inspect.text_metrics.vertical`; the knob + pagination behaviors advertise as `style.writingMode.block_styles`. ## v1 scope and limitations Each remaining limit degrades loudly. - **A text `mark:` (circled text) is not vertical.** Its glyph-band oval is a horizontal overlay, so on a vertical block it is skipped and warns `vertical_text_unsupported`. - **Rich `spans` overflow**: `shrink`/`ellipsis` warn `span_overflow_unsupported`, matching the horizontal rich block. - [`char_grid`](char_grid.md)'s vertical cells now shape through the same GSUB `vert` arrangement as this page's blocks (its item-level `writingMode` remains its own key). ## Limitations - A text `mark:` (the circled-text overlay) is horizontal-only and is skipped in a vertical block (`vertical_text_unsupported`). - `textOverflow: shrink`/`ellipsis` on a rich (`spans:`) block falls back to visible (`span_overflow_unsupported`). - A block needing more columns than its box is wide warns (`vertical_text_overflow`); the flow paginator reads that as the signal to move it. - Ruby carries the same caps as horizontal text (`too_many_ruby_entries`, `ruby_entry_too_long`, `empty_ruby_entry`, `ruby_base_not_found`). - `writingMode` is an ITEM-level key on a `char_grid`; as a style property on one it parses and silently stays horizontal. ## Diagnostics | Code | When | | --- | --- | | `vertical_text_unsupported` | a vertical writing mode reached a text `mark:` (the circled-text overlay) | | `span_overflow_unsupported` | `textOverflow: shrink`/`ellipsis` on a vertical `spans` block (overflowing like `visible`) | | `vertical_text_overflow` | more columns than the box width holds, where pagination cannot take over (bounded contexts; not even one column fits; a `shrink` still overflowing at its 4 pt floor) | | `ruby_base_not_found` | a `ruby` entry's `base` never occurs in the drawn text (reading skipped) | | `ruby_overflow` | a reading longer than its base run even at the 4 pt floor | | `empty_ruby_entry` | a `ruby` entry with an empty `base` or `text` (validation) | | `too_many_ruby_entries` | more than 256 `ruby` entries (only the first 256 apply) | | `ruby_entry_too_long` | a `ruby` entry's `base` or `text` over 64 characters (entry skipped) | --- ## docs/engine/visible.md — Show an item only for some data: the form-mark presence predicate on any item, reserving its box or removing it from layout. # `visible:` — show an item only for some data Every item takes a `visible:` key. When the predicate holds the item draws exactly as it always did; when it does not, the item is not shown. ```yaml - type: image box: { x: 400, y: 40, w: 80, h: 80 } src: assets/approved-stamp.svg visible: { key: status, equals: approved } ``` This is the presence binding form marks already use, generalized to the whole item vocabulary — the same keys, the same truth table, the same diagnostics about a literal that can never match. What it adds is a choice about the SPACE the item leaves behind. ## The predicate | Key | Meaning | | --- | --- | | `key` | the params field to read (dotted paths allowed) — **required** | | `equals` | a string, number or boolean the value must equal. Omitted: the value is read as a boolean and the item shows when it is `true` | | `scope` | `element` (default) or `document` — which data the key resolves against inside a `repeat` cell | | `collapse` | `true` removes the item from layout instead of reserving its box | Equality is **type-strict**: `equals: "2"` never matches a numeric `2`. An **array** value is a multi-select — the item shows when the array *contains* `equals`, which is what a checkbox group's params look like. A key that is missing from params, or whose value simply does not match, hides the item **silently** — a blank form draws nothing and says nothing. Only a value the predicate *cannot use* warns (see [diagnostics](#diagnostics)). Inside a `repeat` cell the key resolves against the bound element, so each element decides for itself. `scope: document` is the escape a page-global flag needs — the same escape a text [binding](data-binding.md) takes. ## The two semantics **Default — the box is reserved, nothing is painted.** Everything around the item stays exactly where it was. This is the form-mark posture: a blank↔filled params pair never shifts the layout by a point, so a form prints with the same geometry whether or not a field is filled in. **`collapse: true` — the item generates no box at all.** Its siblings close up over it, and its gap goes with it: in a flow the next item moves up by the hidden item's height *plus* the gap that separated them; in a flex row the remaining children divide the full width; in a grid the vacated cell is reused rather than left empty. ```yaml # A clause that belongs only to some contracts, with the paragraphs # after it closing up when it does not apply. - type: text text: "{clause.warranty}" visible: { key: contract.kind, equals: extended, collapse: true } ``` These are the two CSS behaviours, and the key name is CSS's own: `collapse` is what [CSS 2.1 §11.2](https://www.w3.org/TR/CSS21/visufx.html#visibility) calls removing the box, while the default matches the same clause's `visibility: hidden` and `collapse: true` produces the effect [CSS Display 3 §2.5](https://www.w3.org/TR/css-display-3/#box-generation) gives `display: none`. **Hiding an item hides everything inside it.** A hidden `container` paints no child, and a collapsed one takes its whole subtree with it. CSS lets a descendant re-assert `visibility: visible` to escape a hidden ancestor; there is no wire spelling for that here, so the two behave identically for every document this format can express. ## Per item type The key means the same thing on all fifteen item types. Two are worth spelling out: - **`page_break`** paints nothing and reserves no box, so `collapse` makes no difference to it: a break whose predicate fails simply does not happen. This is how a conditional page break is authored. ```yaml - type: page_break visible: { key: order.long_form } ``` - **A band item** (header/footer) and an item in an **absolute** body are positioned outright, so nothing moves either way. The two semantics differ only in whether `inspect` reports a placement for the item — which is what lets an editor show where a hidden item would have been. Table **columns** and **rows** are not items and take no `visible:`. A row already chooses its style by predicate through [`conditionalStyles`](table.md); a column's presence is not conditional. ## Stacking alternatives Because the box is reserved by default, several items can share one coordinate and take turns: ```yaml - type: image box: { x: 400, y: 40, w: 80, h: 80 } src: assets/approved.svg visible: { key: status, equals: approved } - type: image box: { x: 400, y: 40, w: 80, h: 80 } src: assets/rejected.svg visible: { key: status, equals: rejected } ``` Every candidate asset is named in the template, so nothing about which file is drawn comes from params — the asset policy is unchanged. ## Limitations - **Table columns and rows take no `visible:`.** They are not items. A row already chooses a style by predicate through `conditionalStyles` (`row_condition_type_mismatch` and friends); a column's presence is fixed. - **A hidden item is still measured.** The default reserves its box, so its content is laid out and then not painted — an item that overflows while hidden still reports the overflow (`child_overflow`, `sheet_overflow`), because the geometry it would occupy is real. - **A value the predicate cannot use hides the item rather than showing it.** Both faults are warnings, not errors, and the item does not draw (`visible_type_mismatch`, `visible_value_not_bool`) — matching what a form mark does with the same fault. A document that renders blank where content was expected is worth checking for those two first. ## Diagnostics | Code | When | | --- | --- | | `visible_not_boolean` | the field is declared non-boolean and the binding has no `equals`, so the predicate can never hold | | `visible_equals_not_declared` | the `equals` literal is outside the field's declared `enum` | | `visible_type_mismatch` | the value's type differs from `equals` (also raised at validate against the DECLARED type) | | `visible_value_not_bool` | an `equals`-less binding's value is not a boolean | All four are warnings and all four leave the item **not shown**, matching what a form mark does with the same fault. The literal is never echoed — the key names the field. Capability key: `item.visible`. An engine that predates this key rejects it as an unknown field, so a tool that writes `visible:` should gate on the capability rather than write it hopefully. --- ## docs/quickstart.md # Quickstart From zero to a rendered PDF, then an AI agent driving the engine. Nothing to install but Docker. ## 1. Get the engine The image carries the CLI, the MCP server, the font and locale packs, and every bundled example. It runs on x86-64 and arm64. ```bash docker pull ghcr.io/kengos/shojiku:edge ``` `edge` tracks `main`. It is the only tag that exists — Shojiku is pre-1.0, so there is no `latest` and no version tag yet. The same image is mirrored to Docker Hub as `kengos/shojiku:edge` if you prefer that registry; ghcr is what the rest of this page uses because it does not rate-limit anonymous pulls. Prefer to build it yourself, or want the binaries outside a container? [from-source.md](from-source.md). ## 2. Render a bundled example The image's default command renders the bundled Japanese receipt to stdout, so this needs no arguments and no files of your own: ```bash docker run --rm ghcr.io/kengos/shojiku:edge > receipt.pdf ``` Every bundled example is inside the image under `/opt/shojiku/examples`, and any of them renders the same way: ```bash docker run --rm ghcr.io/kengos/shojiku:edge render \ --templates /opt/shojiku/examples/business/invoice-en/templates.yml \ --params /opt/shojiku/examples/business/invoice-en/params.json \ --definitions /opt/shojiku/examples/business/invoice-en/definitions.yml \ --output - > invoice.pdf ``` ## 3. Start from an example of your own The fastest way to a document you can edit is to lift a bundled one out of the image. The image is distroless and has no shell, so this uses `docker cp` rather than `sh`: ```bash cid=$(docker create ghcr.io/kengos/shojiku:edge) docker cp "$cid:/opt/shojiku/examples/business/invoice-en" ./my-doc docker rm "$cid" ``` **Copy the whole DIRECTORY, not the three files.** A template can reference siblings — `invoice-en` pulls in `assets/logo.svg` — and taking only `templates.yml`, `params.json` and `definitions.yml` gets you `error[invalid_image_asset]` on the first render. To just LOOK at one file without copying anything, send `docker cp` to stdout — it writes a tar stream, so pipe it through `tar -xO`: ```bash cid=$(docker create ghcr.io/kengos/shojiku:edge) docker cp "$cid:/opt/shojiku/examples/business/receipt-ja/templates.yml" - | tar -xO docker rm "$cid" ``` `docker run --entrypoint cat …` will NOT work: the image carries the engine and nothing else, so there is no `cat` and no `sh` in it to run. That is deliberate — a smaller attack surface than a shell-bearing base — and `docker cp` is the way around it, because it reads the container filesystem from the outside rather than executing anything inside. Now edit `my-doc/templates.yml` (the layout) or `my-doc/params.json` (the data), and render by mounting the directory. Fonts and locale packs are already inside the image, so this is the only mount you need: ```bash docker run --rm -v "$PWD/my-doc:/work" ghcr.io/kengos/shojiku:edge render \ --templates /work/templates.yml --params /work/params.json \ --definitions /work/definitions.yml \ --output /work/out.pdf ``` **To SEE whether your edit landed, render a preview instead of a PDF.** `preview` writes one PNG per page, which you can open without a PDF viewer — and unlike grepping the PDF, it actually shows you the result. (Text inside a Shojiku PDF is stored as subsetted font glyph indices, so `strings out.pdf | grep "your name"` finds nothing even when the change is there. That is normal, not a failed render.) ```bash docker run --rm -v "$PWD/my-doc:/work" ghcr.io/kengos/shojiku:edge preview \ --templates /work/templates.yml --params /work/params.json \ --definitions /work/definitions.yml \ --output '/work/page-{page}.png' ``` The `{page}` placeholder is required whenever the document has more than one page; without it the command refuses rather than overwriting page 1 with page 2. Every bundled example under `/opt/shojiku/examples` works as a starting point — the [gallery](../README.md#gallery) shows what each one is. **Do not drop `--definitions`.** It is optional to the CLI, and leaving it out still exits 0 with no diagnostic — but the field TYPES live there, so a currency field renders as a bare `8,360` instead of `¥8,360`, and a quantity loses its counter. The degradation is silent and visible only in the output. ([engine/definitions.md](engine/definitions.md) is the reference for the file.) ## 4. Write your own template The template reference is [engine/](engine/README.md) — one page per feature (items, box model, styles, tables, repeats, …), plus the `validate` / `preview` / `inspect` commands that make the write→check→look loop fast. Or skip hand-writing entirely and use an agent (next section). ## With an AI coding agent (MCP) `shojiku-mcp` is a stdio MCP server exposing `validate` / `render_preview` / `inspect_layout` / `capabilities`, plus `list_examples` / `get_example` over the bundled examples. It ships in the same image, and stdio is exactly what `docker run -i` gives you — for Claude Code: ```bash claude mcp add shojiku -- \ docker run --rm -i --entrypoint shojiku-mcp \ -v "$PWD:/work" -w /work ghcr.io/kengos/shojiku:edge ``` Other clients take the same server as JSON. The command is identical everywhere; what differs is which file it goes in, the top-level key, and — the part that actually bites — how you spell the directory to mount. ```json { "mcpServers": { "shojiku": { "command": "docker", "args": [ "run", "--rm", "-i", "--entrypoint", "shojiku-mcp", "-v", "/absolute/path/to/your/documents:/work", "-w", "/work", "ghcr.io/kengos/shojiku:edge" ] } } } ``` **The path is written out in full on purpose.** `$PWD` is a SHELL expansion — it works in the `claude mcp add` command line above, and it is not something a JSON config loader expands. Each editor has its own variable syntax instead, and using the wrong one passes the literal string to `docker -v`, which silently mounts a directory named `${PWD}`. An absolute path is the one spelling that works everywhere. | Client | File | Top-level key | Workspace variable | | --- | --- | --- | --- | | Claude Code | `.mcp.json` in the project | `mcpServers` | — (use the CLI above) | | Claude Desktop | macOS `~/Library/Application Support/Claude/claude_desktop_config.json`, Windows `%APPDATA%\Claude\claude_desktop_config.json` | `mcpServers` | none — it has no project, so absolute is the only option | | VS Code (Copilot) | `.vscode/mcp.json` in the project | **`servers`** | `${workspaceFolder}` | | Cursor | `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` | `mcpServers` | check its docs; absolute always works | | Windsurf, Cline, Zed, … | that client's own MCP config | `mcpServers` | check its docs; absolute always works | Whatever you mount is the whole of what the agent can reach: it reads your templates from there and writes PDFs back there, and the container sees nothing else on your machine. ### Checking the server yourself Before wiring it into an editor, you can confirm the server runs and see what it offers. This asks it to introduce itself and list its tools: ```bash printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ | docker run --rm -i --entrypoint shojiku-mcp ghcr.io/kengos/shojiku:edge ``` You should get two JSON lines back: the server naming itself `shojiku-mcp` — alongside a short `instructions` string your client hands to the model — then the six tools with their schemas. If the first line never arrives, the usual cause is a missing `-i` — without it the server sees EOF and exits before answering. Then ask for the document you need: > Make an A4 receipt with our logo, a tax-breakdown table, and a QR code > linking to the receipt page. The agent writes `templates.yml` / `definitions.yml`, renders a preview, inspects the layout tree, and iterates on the diagnostics until the output matches. The agent playbooks (AI-only instructions) are: - [skills/shojiku-template-author/](../skills/shojiku-template-author/SKILL.md) — authoring a template from natural-language requirements (its **Engine access** section is the canonical MCP-first / CLI-fallback command table the other two reference). - [skills/shojiku-render-debugger/](../skills/shojiku-render-debugger/SKILL.md) — diagnosing why an existing template renders wrong. - [skills/shojiku-thinreports-migrator/](../skills/shojiku-thinreports-migrator/SKILL.md) — migrating a legacy Thinreports report by visual regeneration. [migration-thinreports.md](migration-thinreports.md) walks one such migration end to end (legacy `.tlf` + Ruby host → the re-authored bundled example), if you want to see the method before pointing an agent at your own report.