feat(layout): add the layout engine, generators and fill #30

Merged
TJC merged 5 commits from feat/m3-layout-engine into main 2026-08-18 13:42:50 +01:00
Collaborator

Summary

  • bundled Helvetica AFM widths as JSON, measured in pure functions, with the baseline computed once in layout and emitted on the primitive
  • widths carry no kern pairs: pdf-lib kerns widthOfTextAtSize but draws a single Tj string no viewer kerns, so the preview is told the same
  • auto, fixed and grow across stack, row and box, with padding, gap, per-edge borders, alignment and the three box shapes
  • the resolve step the pipeline names - evaluate expressions, expand repeats, flatten widget nodes - plus range, list and timeSequence
  • fill carries its subtree and scope into layout, because its iteration count is a layout result rather than an input to one
  • fill under an auto ancestor is rejected at parse and flagged again at resolve, never measured as zero
  • layout emits the full mark set and both renderers take it; the parity matrix, overflow band, click-to-select and pages -> document stay M4
  • closes #11, closes #12, closes #13

Test plan

  • pnpm test green: 253 tests, 140 of them new across metrics, measure, layout, resolve, sheet, generators and fill
  • every glyph in the bundled table checked against pdf-lib's own AFM data, with the kerning divergence pinned on the AV pair
  • inline snapshots over the sizing and container combinations, and over repeat over, count and fill
  • fill packed end to end through the pipeline: fixed and grow ancestors, $i bound per iteration, packing across a row and ahead of a grow sibling
  • an iteration with no size is flagged rather than repeated to the cap
  • the M2 notes fixture now sizes its chain, which is the new fill rule doing its job rather than a change of intent
  • pnpm lint, pnpm build and prettier green
## Summary - bundled Helvetica AFM widths as JSON, measured in pure functions, with the baseline computed once in layout and emitted on the primitive - widths carry no kern pairs: pdf-lib kerns widthOfTextAtSize but draws a single Tj string no viewer kerns, so the preview is told the same - auto, fixed and grow across stack, row and box, with padding, gap, per-edge borders, alignment and the three box shapes - the resolve step the pipeline names - evaluate expressions, expand repeats, flatten widget nodes - plus range, list and timeSequence - fill carries its subtree and scope into layout, because its iteration count is a layout result rather than an input to one - fill under an auto ancestor is rejected at parse and flagged again at resolve, never measured as zero - layout emits the full mark set and both renderers take it; the parity matrix, overflow band, click-to-select and pages -> document stay M4 - closes #11, closes #12, closes #13 ## Test plan - `pnpm test` green: 253 tests, 140 of them new across metrics, measure, layout, resolve, sheet, generators and fill - every glyph in the bundled table checked against pdf-lib's own AFM data, with the kerning divergence pinned on the AV pair - inline snapshots over the sizing and container combinations, and over repeat over, count and fill - fill packed end to end through the pipeline: fixed and grow ancestors, $i bound per iteration, packing across a row and ahead of a grow sibling - an iteration with no size is flagged rather than repeated to the cap - the M2 notes fixture now sizes its chain, which is the new fill rule doing its job rather than a change of intent - `pnpm lint`, `pnpm build` and prettier green
- widths only, no kern pairs: pdf-lib kerns widthOfTextAtSize but draws
  a single Tj string that no viewer kerns, so the number would be the
  width of nothing that reaches paper
- the preview is told the same thing, or the browser kerns and drifts
- a character outside WinAnsi is flagged on its node here rather than
  left to throw out of pdf-lib at export
- the baseline moves into the metrics module, so layout is the only
  place that computes one

Closes #11
- auto from content, fixed in mm, grow sharing leftover by weight, over
  stack, row and box nested without limit
- align is the cross axis only: spreading along the main axis is what
  grow and a spacer are for, which is how the day circles distribute
- four identical edges collapse to one rect, so the common bordered
  section stays one primitive; anything else is a line per edge
- the full mark set of decision 19 lands, so both renderers take line,
  circle and the rounded and elliptical box shapes
- an intrinsic size is memoised per node: a parent asks twice, and an
  unmeasurable mark must reach the outline once

Closes #12
- the resolve step the pipeline names: evaluate, expand, flatten, and
  hand layout a tree with no spec language left in it
- fill is the exception and has to be: its iteration count is a layout
  result, so a fill node keeps its subtree and the scope to resolve it
  in, and layout asks for one iteration at a time as it packs them
- fill under an auto ancestor is rejected at parse and flagged again at
  resolve; measuring it as zero is the empty notes section decision 5
  exists to prevent
- range, list and timeSequence join the same whitelist the arithmetic
  is in, so the schema and the evaluator cannot drift
- timeSequence is end-exclusive: a row is the slot that starts at its
  label, and an empty range is no rows rather than an error
- a number or boolean in a text slot is written out as it reads - the
  one coercion in a language with no string operations
- the M2 notes fixture now sizes its chain, which is the new rule doing
  its job rather than a change of intent

Closes #13
- isSized claimed every kind but text and circle carries width and
  height, so measuring a container holding a fill threw a TypeError and
  took the whole sheet down with it
- list the sized kinds rather than subtract them, so a kind without a
  size of its own is left out by default
- what a fill packs is the space the container was given, not an
  iteration measured up front, and an auto container has none to give
- latent today: the spec rejects a fill under an auto parent, so the
  figure this drops was always discarded
TJC requested review from TJC 2026-08-18 13:41:13 +01:00
Author
Collaborator

Review of this branch at medium effort, five findings. The two that matter are fixed here.

Fixed

  • isSized claimed every kind but text and circle carried width and height, and ResolvedFill carries neither. Measuring any container holding a fill threw a TypeError out of intrinsicExtent; not a SpecError, so attempt rethrew it and layoutTemplate took the whole sheet down - the white screen decision 6 exists to prevent. Reachable from a parse-valid template: row > stack (auto width, stretched height) > rect { repeat: fill }. Regression test added. (1fd721c)
  • measureContainer added a fill's sample extent along the container's main axis, which is not what a fill contributes there. Latent, since the spec rejects a fill under an auto parent and the figure was always discarded, but it disagreed with expandFills, which already skips fills. (0802818)

Left for a follow-up

  • edgesOf and num (resolve.ts:297, :323) accept negative padding, gap and radius, while theme lengths go through mmSchema (positive()). padding: -50 draws children off the page with an empty errors array.
  • rootOf (sheet.ts:49) reuses template.root.id for the synthetic wrapper stack, so under decision 21 a wrapper-charged cap error is indistinguishable from an iteration's.
  • 'align' in parent ? parent.align : 'stretch' (fill.ts:59) misreads an explicitly-undefined align; parent.align ?? 'stretch' is the robust form. Same shape at fill.ts:45 and resolve.ts:288.

Test plan

  • pnpm test 254 passed, tsc --noEmit clean, eslint . clean
Review of this branch at medium effort, five findings. The two that matter are fixed here. ## Fixed - `isSized` claimed every kind but `text` and `circle` carried `width` and `height`, and `ResolvedFill` carries neither. Measuring any container holding a `fill` threw a `TypeError` out of `intrinsicExtent`; not a `SpecError`, so `attempt` rethrew it and `layoutTemplate` took the whole sheet down - the white screen decision 6 exists to prevent. Reachable from a parse-valid template: `row > stack (auto width, stretched height) > rect { repeat: fill }`. Regression test added. (1fd721c) - `measureContainer` added a fill's sample extent along the container's main axis, which is not what a fill contributes there. Latent, since the spec rejects a fill under an auto parent and the figure was always discarded, but it disagreed with `expandFills`, which already skips fills. (0802818) ## Left for a follow-up - `edgesOf` and `num` (`resolve.ts:297`, `:323`) accept negative padding, gap and radius, while theme lengths go through `mmSchema` (`positive()`). `padding: -50` draws children off the page with an empty `errors` array. - `rootOf` (`sheet.ts:49`) reuses `template.root.id` for the synthetic wrapper stack, so under decision 21 a wrapper-charged cap error is indistinguishable from an iteration's. - `'align' in parent ? parent.align : 'stretch'` (`fill.ts:59`) misreads an explicitly-undefined align; `parent.align ?? 'stretch'` is the robust form. Same shape at `fill.ts:45` and `resolve.ts:288`. ## Test plan - `pnpm test` 254 passed, `tsc --noEmit` clean, `eslint .` clean
TJC approved these changes 2026-08-18 13:42:47 +01:00
TJC merged commit 091c11d4ee into main 2026-08-18 13:42:50 +01:00
TJC deleted branch feat/m3-layout-engine 2026-08-18 13:42:55 +01:00
Sign in to join this conversation.
No reviewers
TJC
No labels
chore
core
render
ui
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TJC/daily-planner-template!30
No description provided.