zttext source

← zttext · API reference · version 0.1.6

4 file(s), 2745 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.

Public items

A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.

ItemDefinedWorked exampleCall sites
zttext_version fn22—100
Piece struct31—37 66 68 129 134 138 … (16 total)
Doc struct37—60 63 69 75 87 98 … (52 total)
Run struct41—202 203 214
empty_doc fn60—41 344 450
doc_from_text fn63—18 27 54 77 104 105 … (59 total)
materialise fn75—443 943 30 31 36 41 … (28 total)
doc_len fn87ZTX-00232 55 61 107 113 152 … (13 total)
locate fn98—131 122 123 124 125 126 … (7 total)
locate_inverse fn109—127 128
insert_text fn121ZTX-001260 28 35 108 114 121 … (25 total)
delete_range fn156ZTX-002261 59 157 161 173 434 … (7 total)
set_style fn180ZTX-001262 78 165 169 197 326 … (10 total)
slice_runs fn202—224 285 294 477 587 601 … (7 total)
materialise_range fn222—701 724 802 845 863 1231 … (10 total)
char_styles fn231—956 957 79 90 70 207 … (7 total)
Op enum243—250 254 259 278 279 313 … (20 total)
Txn struct250—254 267 311 323 1110
txn_of fn254—81 185 202 212 215 439 … (7 total)
apply_op fn259—269 321 189
apply fn267—82 87 186 203 205 213 … (10 total)
inv_op_group fn278—316
invert fn311ZTX-00386 204 213 217
Style struct334—338 344 349 353 474 494 … (32 total)
default_style fn338—349 75 81 224
mono_measure fn344—23 108 112 116 225 227 … (77 total)
default_resolver fn349—23 108 112 116 255 256 … (70 total)
run_height fn353—589 607 623 1586
Rect struct363—366 609 625 836 837 848 … (24 total)
Box struct366—369 385 577 594 609 625 … (32 total)
BoxSet struct369—670 671 696 731 764 775 … (19 total)
LaidLine struct376—385 577 629 636 648 682 … (20 total)
PlacedImage struct380—385 1465 1495 1516 1520 1521 … (26 total)
Layout struct385—634 656 662 715 973 993 … (24 total)
Token struct394—441 442 451 456 460 464 … (11 total)
soft_hyphen fn404—410
encode_shy fn409—595 622 627
decode_shy fn430—597
seg fn441—663 889 1019 1162 1375 1517 … (13 total)
token_width fn474—504 551 569 255 256
Line struct489—494 495 517 529
lines_greedy fn494—263 268
next_line fn540—640 1043 1173 1385 1553 1642
PlacedLine struct577—582 629
place_line fn582—646 1048 1178 1391 1559 1648
layout_tokens_from fn634—663
flow_layout_full fn662—23 671 686 976 419 421 … (26 total)
flow_layout fn670ZTX-004108 116 275 290 327 338 … (14 total)
line_assignment fn682—112 304 314
render_dump fn696—1088 340 348 349 539 618
render_commands fn715—24 937
boxset_height fn731—110 117 295
box_at fn764—797 358 359 360 361 730 … (7 total)
anchor_at fn796—362 363 364 365 366
sel fn821—373 374 375 376 405 410 … (8 total)
sel_rects fn836—896 900 902 903
caret_at fn859—none in this package
word_at fn888—382 383 623
line_boxes fn915—386 387 742 743 744 745 … (7 total)
doc_chars fn941—956 957
dirty fn955—994 504
PatchResult struct973—975 977 993 999 1084
patch fn993—420 422 504 631 633 634 … (8 total)
render_dump_layout fn1088—422 457 458 632 867 868
map_anchor fn1096—1112 465 467 469 471 473 … (7 total)
map_anchor_txn fn1110—482
Page struct1124—1129 1130 1138 1146 1153 1298
paginate fn1129—527 551 554 679 680
seek_page_char fn1153—533
layout_page fn1161—1306 536 539 542 543
apply_bidi fn1196—568
justify fn1254—310 311 585 589 751 756 … (11 total)
materialise_viewport fn1298—682 683
piece_count fn1319—692 696 703 707
compact_step fn1325—695 702
PageGeom struct1351—1359 1365 1374 1582 715 739 … (9 total)
column_width fn1359—1376 1584 716 750 751
column_height fn1365—1377 1585 717
layout_columns fn1374—718 740 751 867 869
FlowImage struct1423—1516 1582 813 830 838 847 … (9 total)
IMG_LEFT const1425—1541 838 840 884
IMG_FULL const1427—1532 1605 1614 830 832
IMG_RIGHT const1429—1537 1617 847 849 920
free_span fn1465—1547 1636 789 792 795 798 … (9 total)
band_bottom fn1495—1549 1638 803
layout_flow_images fn1516—815 831 839 848 922
layout_page_columns fn1582—868 869 876 885

src/main.loft

// A RenderSink harness for lib/zttext — the engine laid out and its paint commands emitted, so the
// SAME layout can be checked on native AND the browser (the --html leg of the M0-M5 parity gate,
// which the DOM RenderSink lands). Lay out a fixed document and emit `render_commands` (the LINE|/
// BOX| sink protocol) via host_output: on native this goes to stderr, on --html to the JS host —
// which either PAINTS it to the DOM (a real viewer's host.js) or, in parity.sh, compares it to the
// native emission. host_output is per-target BY DESIGN, so this compares the engine against itself
// across targets rather than two implementations that ought to agree.
//
// The Measurer here is `mono_measure` (pure loft, deterministic) ON PURPOSE: it isolates the
// ENGINE's flow/justify/float codegen so the --html leg proves the box set is byte-identical in the
// browser. Real glyph metrics (lib/ztfont) are a separately-proven, byte-identical Measurer, so a
// real viewer composes the two; this harness pins the engine half. Run: `loft src/main.loft` with
// any trigger on stdin.
use zttext::*;

fn main() {
  _ = host_input();   // a trigger; the scenario is fixed, so the emission is deterministic
  d = doc_from_text(
    "The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. "
    + "Now is the time for all good folk to come to the aid of the party.\n\n"
    + "A second paragraph, to exercise a para-end line and a fresh baseline in the sink output.",
    0);
  l = flow_layout_full(d, default_resolver, mono_measure, 40.0);
  host_output(render_commands(l, d));
}

src/zttext.loft

// zttext — the text engine as a loft library.
//
// One content substrate (an append-only buffer + a piece sequence) with a pluggable
// per-surface layout behind an API layer of ports (Measurer, StyleResolver, Layout,
// RenderSink, InteractionMap). It is the basis for the document editor now, and the
// future IDE / slides / spreadsheet surfaces later — "switch the surface" means swap the
// layout bundle, the content core is unchanged.
//
// Design: `doc/EDITOR_DESIGN.md` (direction) and `doc/ZTTEXT_NOTATION.md` (the formal
// model, invariants I1–I9, which the comments below cite) in the zero-trust-shared-files
// repository this engine was extracted from.  Everything here is pure Tier-1 loft with
// measurement injected, so it is identical on interpret / native / native-wasm / browser
// by construction (invariant I8, the parity theorem).
//
// Three choices made from step one: offsets are CHARACTER positions, never bytes (the
// multi-byte truncation trap, @PLN110) — the buffer is a vector<character> so every offset
// is a character index natively; the buffer is APPEND-ONLY (deleted text stays, so undo can
// restore it); and a piece is an immutable value, so an edit REBUILDS the piece sequence
// rather than changing a piece in place.

// The version of this source — the `version` in its `loft.toml`, bumped with it.
pub fn zttext_version() -> text { return "0.1.3"; }

// ===================================================================================
// M0 — content core (Layer 1).   Notation §2, §3, §8.   Invariant I1 (fidelity + inverse).
// ===================================================================================

// A piece p = ⟨o, ℓ, s⟩ references the buffer span B[start, start+count) with style-id
// `style`. Pieces are immutable value objects: an edit REBUILDS the piece sequence, it
// never mutates a piece or swaps vector slots in place.
pub struct Piece { pub start: integer, pub count: integer, pub style: integer }

// The content model. `buf` is the append-only buffer B ∈ Σ* (a vector<character>, so
// offsets are characters); `pieces` is the piece sequence D = ⟨p₁…pₙ⟩. The materialised
// text ⌊D⌋ is the concatenation of each piece's buffer span. (Bundling B with D keeps the
// engine's surface small; the buffer is treated as append-only within an edit.)
pub struct Doc { pub buf: vector<character>, pub pieces: vector<Piece> }

// A styled run: a maximal same-style character span. `slice_runs` returns these; they are
// what an inverse Ins/Sty re-creates so undo restores styles, not just text.
pub struct Run { str: text, style: integer }

// -------------------- small helpers --------------------

fn clampi(x: integer, lo: integer, hi: integer) -> integer {
  if x < lo { return lo; }
  if x > hi { return hi; }
  return x;
}

// materialise the buffer span [start, start+count) as text (characters, never bytes).
fn buf_slice_text(buf: vector<character>, start: integer, count: integer) -> text {
  out: text = "";
  for j in start..(start + count) { ch = buf[j]?; out += "{ch}"; }
  return out;
}

// A document with no text.  The starting point every edit sequence builds from,
// and what an undo to the beginning arrives at.
pub fn empty_doc() -> Doc { return Doc { buf: [], pieces: [] }; }

// build a one-piece doc holding `s` at style-id `style`.
pub fn doc_from_text(s: text, style: integer) -> Doc {
  b: vector<character> = [];
  for c in s { b += [c]; }
  p: vector<Piece> = [];
  n = len(s);
  if n > 0 { p += [ Piece { start: 0, count: n, style: style } ]; }
  return Doc { buf: b, pieces: p };
}

// -------------------- S0.2 materialise + length --------------------

// ⌊D⌋ — the logical text of the doc (concatenate every piece's buffer span in order).
pub fn materialise(d: Doc) -> text {
  out: text = "";
  for i in 0..len(d.pieces) {
    p = d.pieces[i]?;
    out += buf_slice_text(d.buf, p.start, p.count);
  }
  return out;
}

// |⌊D⌋| — the character length of the logical text.
// Example: @ZTX-002 — CHARACTERS, which is not the byte length of the
// materialised text.
pub fn doc_len(d: Doc) -> integer {
  n = 0;
  for i in 0..len(d.pieces) { n += (d.pieces[i]?).count; }
  return n;
}

// -------------------- S0.3 locate + inverse --------------------

// locate(D, a) → (piece index, in-piece offset). Anchor `a` ∈ [0,|⌊D⌋|] sits BETWEEN
// characters; piece i covers logical [Cᵢ, Cᵢ+countᵢ). A boundary a = Cᵢ₊₁ lands in piece
// i+1 at offset 0; a = |⌊D⌋| (the end) returns the sentinel (n_pieces, 0).
pub fn locate(d: Doc, a: integer) -> (integer, integer) {
  cum = 0;
  for i in 0..len(d.pieces) {
    p = d.pieces[i]?;
    if a < cum + p.count { return (i, a - cum); }
    cum += p.count;
  }
  return (len(d.pieces), 0);
}

// inverse of locate: (piece index, in-piece offset) → the logical anchor a.
pub fn locate_inverse(d: Doc, pi: integer, ro: integer) -> integer {
  cum = 0;
  for i in 0..pi { cum += (d.pieces[i]?).count; }
  return cum + ro;
}

// -------------------- S0.4 insert --------------------

// insert text `s` (style-id `style`) at anchor `a`. Appends `s`'s characters to a fresh
// buffer (append-only), then rebuilds the piece sequence splitting the piece at `a`.
// Example: @ZTX-001 — this RETURNS a new Doc; the one passed in is unchanged, so
// a discarded result is an edit that silently never happened.
pub fn insert_text(d: Doc, a: integer, s: text, style: integer) -> Doc {
  n = len(s);
  if n == 0 { return d; }
  // fresh buffer = copy of the old buffer, then the new characters appended at `base`.
  nb: vector<character> = [];
  for c0 in d.buf { nb += [c0]; }
  base = len(nb);
  for c1 in s { nb += [c1]; }
  newp = Piece { start: base, count: n, style: style };

  loc = locate(d, a);
  pi = loc.0;
  ro = loc.1;
  np: vector<Piece> = [];
  for i in 0..len(d.pieces) {
    p = d.pieces[i]?;
    if i == pi {
      if ro > 0 { np += [ Piece { start: p.start, count: ro, style: p.style } ]; }
      np += [newp];
      if ro < p.count { np += [ Piece { start: p.start + ro, count: p.count - ro, style: p.style } ]; }
    } else {
      np += [p];
    }
  }
  if pi >= len(d.pieces) { np += [newp]; }   // a == end (or empty doc): no piece was split
  return Doc { buf: nb, pieces: np };
}

// -------------------- S0.5 delete --------------------

// delete the range [a,b). Keeps each piece's portion outside [a,b); the buffer is
// UNCHANGED (deleted characters stay in B so undo can restore them).
// Example: @ZTX-001 — a value, not a mutation: bind the result.
// Example: @ZTX-002 — `a` and `b` are CHARACTER positions, and the append-only
// buffer does not shrink (that is what undo restores from).
pub fn delete_range(d: Doc, a: integer, b: integer) -> Doc {
  if b <= a { return d; }
  np: vector<Piece> = [];
  cum = 0;
  for i in 0..len(d.pieces) {
    p = d.pieces[i]?;
    ps = cum;
    pe = cum + p.count;
    // left kept fragment [ps, min(a,pe))
    lend = if a < pe { a } else { pe };
    if lend > ps { np += [ Piece { start: p.start, count: lend - ps, style: p.style } ]; }
    // right kept fragment [max(b,ps), pe)
    rstart = if b > ps { b } else { ps };
    if pe > rstart { np += [ Piece { start: p.start + (rstart - ps), count: pe - rstart, style: p.style } ]; }
    cum = pe;
  }
  return Doc { buf: d.buf, pieces: np };
}

// -------------------- S0.5b set style (the sty op's engine) --------------------

// set style-id `style` on the range [a,b): split each overlapping piece three ways
// ([ps,a) old · [a,b) new · [b,pe) old) and restyle the middle.
// Example: @ZTX-001 — a value, not a mutation: bind the result.
pub fn set_style(d: Doc, a: integer, b: integer, style: integer) -> Doc {
  if b <= a { return d; }
  np: vector<Piece> = [];
  cum = 0;
  for i in 0..len(d.pieces) {
    p = d.pieces[i]?;
    ps = cum;
    pe = cum + p.count;
    m1 = clampi(a, ps, pe);
    m2 = clampi(b, ps, pe);
    if m1 > ps { np += [ Piece { start: p.start, count: m1 - ps, style: p.style } ]; }
    if m2 > m1 { np += [ Piece { start: p.start + (m1 - ps), count: m2 - m1, style: style } ]; }
    if pe > m2 { np += [ Piece { start: p.start + (m2 - ps), count: pe - m2, style: p.style } ]; }
    cum = pe;
  }
  return Doc { buf: d.buf, pieces: np };
}

// -------------------- styled slices (used by undo) --------------------

// the styled runs of [a,b): one Run per overlapping piece-fragment, carrying its text and
// style. Concatenating the run texts is exactly ⌊D⌋[a,b); the styles are χ_D over [a,b).
pub fn slice_runs(d: Doc, a: integer, b: integer) -> vector<Run> {
  runs: vector<Run> = [];
  if b <= a { return runs; }
  cum = 0;
  for i in 0..len(d.pieces) {
    p = d.pieces[i]?;
    ps = cum;
    pe = cum + p.count;
    lo = clampi(a, ps, pe);
    hi = clampi(b, ps, pe);
    if hi > lo {
      s = buf_slice_text(d.buf, p.start + (lo - ps), hi - lo);
      runs += [ Run { str: s, style: p.style } ];
    }
    cum = pe;
  }
  return runs;
}

// materialise just [a,b) as text.
pub fn materialise_range(d: Doc, a: integer, b: integer) -> text {
  out: text = "";
  runs = slice_runs(d, a, b);
  for i in 0..len(runs) { out += (runs[i]?).str; }
  return out;
}

// per-character style vector over the whole doc (χ_D as a concrete list) — the reference
// against which piece-blindness (I2) and undo are checked.
pub fn char_styles(d: Doc) -> vector<integer> {
  out: vector<integer> = [];
  for i in 0..len(d.pieces) {
    p = d.pieces[i]?;
    for _j in 0..p.count { out += [p.style]; }
  }
  return out;
}

// -------------------- S0.6 transactions --------------------

// The primitive edit operations (notation §8): ins(a,t,σ) · del(a,b) · sty(a,b,σ).
pub enum Op {
  Ins { pub at: integer, pub str: text, pub style: integer },
  Del { pub from: integer, pub to: integer },
  Sty { pub from: integer, pub to: integer, pub style: integer },
}

// A transaction τ — an atomic, ordered composition of primitive ops; the unit of undo.
pub struct Txn { pub ops: vector<Op> }

// Bundle ops into ONE transaction — the unit of undo.  Grouping them here is
// what makes a compound edit undo in a single step rather than in pieces.
pub fn txn_of(ops: vector<Op>) -> Txn { return Txn { ops: ops }; }

// Apply one primitive op and answer the new document.  The dispatch every
// higher-level edit funnels through, so `Ins` / `Del` / `Sty` have exactly one
// meaning each.  An unrecognised op answers the document unchanged.
pub fn apply_op(d: Doc, op: Op) -> Doc {
  if op is Ins { at, str, style } { return insert_text(d, at, str, style); }
  if op is Del { from, to } { return delete_range(d, from, to); }
  if op is Sty { from, to, style } { return set_style(d, from, to, style); }
  return d;
}

// apply a whole transaction, left to right. Only the pre- and post-states are observable.
pub fn apply(d: Doc, t: Txn) -> Doc {
  cur = d;
  for i in 0..len(t.ops) { cur = apply_op(cur, t.ops[i]?); }
  return cur;
}

// -------------------- S0.7 invert / undo --------------------

// the inverse of a single op against its PRE-state `pre`, as an ordered op list (a delete
// or restyle over a multi-style range inverts to one op per style-run, so undo restores
// styles exactly, not just text).
pub fn inv_op_group(pre: Doc, op: Op) -> vector<Op> {
  ops: vector<Op> = [];
  if op is Ins { at, str } {
    ops += [ Del { from: at, to: at + len(str) } ];
    return ops;
  }
  if op is Del { from, to } {
    runs = slice_runs(pre, from, to);
    pos = from;
    for r_del in runs {
      ops += [ Ins { at: pos, str: r_del.str, style: r_del.style } ];
      pos += len(r_del.str);
    }
    return ops;
  }
  if op is Sty { from, to } {
    runs = slice_runs(pre, from, to);
    pos = from;
    for r_sty in runs {
      ops += [ Sty { from: pos, to: pos + len(r_sty.str), style: r_sty.style } ];
      pos += len(r_sty.str);
    }
    return ops;
  }
  return ops;
}

// invert(D, τ) → τ⁻¹ such that apply(apply(D,τ), τ⁻¹) ≈ D (same text + same per-char
// styles). Each op's inverse is computed against its pre-state; the groups are emitted in
// reverse op order (prepend each new group), since τ⁻¹ undoes the last op first.
// Example: @ZTX-003 — `d` must be the state BEFORE the transaction; the inverse of
// a deletion is an insertion of what was there, which only the pre-state knows.
// Restores per-character styles as well as text.
pub fn invert(d: Doc, t: Txn) -> Txn {
  cur = d;
  result: vector<Op> = [];
  for i in 0..len(t.ops) {
    op = t.ops[i]?;
    g = inv_op_group(cur, op);
    tmp: vector<Op> = [];
    tmp += g;
    tmp += result;
    result = tmp;
    cur = apply_op(cur, op);
  }
  return Txn { ops: result };
}

// ===================================================================================
// S0.8 — the Measurer + Style + StyleResolver ports (fake monospace, deterministic).
// ===================================================================================

// A concrete style σ ∈ 𝕊. For the fake monospace measurer only `size` (per-character
// advance) matters; `weight`/`italic`/`dir` (0 = LTR, 1 = RTL) round out the shape the
// real backends will fill in. Layout reads styles ONLY through a StyleResolver, so a
// surface restyles by swapping the resolver.
pub struct Style { pub size: float, pub weight: integer, pub italic: boolean, pub dir: integer }

// Size 1, regular, upright, left-to-right — the style a document starts in and
// the one to adjust from rather than building a `Style` field by field.
pub fn default_style() -> Style { return Style { size: 1.0, weight: 0, italic: false, dir: 0 }; }

// The fake monospace Measurer μ: advance width = character-count × style.size. Pure and
// deterministic, so every layout milestone is headless and parity-testable. The Measurer
// PORT type is `fn(text, Style) -> float`; this is one implementation (a named ref you
// pass where a Measurer is expected).
pub fn mono_measure(s: text, st: Style) -> float { return (len(s) as float) * st.size; }

// A trivial StyleResolver ρ: every id resolves to the default style. The port type is
// `fn(integer) -> Style`; a real surface passes its own (document stylesheet · IDE syntax
// theme · cell format).
pub fn default_resolver(_id: integer) -> Style { return default_style(); }

// The fake box height of a run in style `st`. Real backends give ascent+descent; the fake
// uses square cells (height = size) so line stacking is deterministic and hand-checkable.
pub fn run_height(st: Style) -> float { return st.size; }

// ===================================================================================
// M1 — render a text in a column (Layer 2).   Notation §4, §6.   Invariants I2, I3.
// ===================================================================================

// The render IR (notation §4). A Rect is a plane rectangle; a Box β = ⟨rect, κ, style⟩
// carries the LOGICAL content-ref κ = [ks, ke) (character span in ⌊D⌋) and a single style.
// The rect's position is the VISUAL order; κ is the LOGICAL order — both explicit in every
// box, which is the whole BiDi story (a selection is κ-intersection, geometry-free).
pub struct Rect { pub x: float, pub y: float, pub w: float, pub h: float }
// `hyphen` marks a synthetic line-end hyphen glyph (from a discretionary break, S4.7):
// it renders "-" rather than its κ text, and κ points at the soft-hyphen it stands in for.
pub struct Box  { pub rect: Rect, pub ks: integer, pub ke: integer, pub style: integer, pub hyphen: boolean = false }
// The boxes of a laid-out text: one `Box` per glyph run, in layout order.  What `flow_layout`
// returns.
pub struct BoxSet { pub boxes: vector<Box> }

// A laid-out visual line: the logical char span [cs,ce) its boxes cover, its top y and its
// height. `para_end` is true when this is a paragraph's LAST line (ended at a hard break or
// the doc end) — it is left ragged (justify skips it). `right` is the line's available right
// edge (the column edge, or an image's edge for a line beside one) — the target justify fills
// to. Carried by a Layout so incremental reflow can find the lines an edit touches (M3).
pub struct LaidLine { pub cs: integer, pub ce: integer, pub y: float, pub h: float, pub para_end: boolean = false, pub right: float = 0.0 }

// A placed image on the page: its rectangle + the image reference `id` (a file_record id at
// the app layer). It is BOTH a rendered element AND an exclusion region the text steers around.
pub struct PlacedImage { pub x: float, pub y: float, pub w: float, pub h: float, pub id: integer }

// A full layout result: the text boxes (the render IR), per-line metadata (the seam
// incremental reflow needs), and the placed images (rendered separately; text never overlaps
// them). `flow_layout` returns just the BoxSet; `flow_layout_full` returns this.
pub struct Layout { pub boxes: vector<Box>, pub lines: vector<LaidLine>, pub images: vector<PlacedImage> }

// -------------------- S1.2 segmentation into words and glue --------------------

// A token in the stream: a word (non-space run), glue (a space run), a discretionary break
// (`disc`, a soft hyphen), or a paragraph break (`brk`, a newline run). `word`/`disc`/`brk`
// are the kind flags; all false → glue. Glue is the breakable inter-word space; disc is a
// zero-width in-WORD break (S4.7); brk is a HARD break that ends the line and starts a new
// paragraph (a paragraph's last line is ragged, and gets paragraph spacing after it).
pub struct Token { pub word: boolean, pub ts: integer, pub te: integer, pub disc: boolean = false, pub brk: boolean = false }

fn is_space(c: character) -> boolean { return c == ' '; }
fn is_nl(c: character) -> boolean { return (c as integer) == 10; }   // '\n' — a paragraph break

// -------------------- user-directed hyphenation (S4.7) --------------------

// The canonical discretionary-hyphen character: Unicode SOFT HYPHEN U+00AD (UTF-8 0xC2 0xAD).
// It is the PORTABLE storage form — invisible in any conforming Markdown/HTML pipeline, and
// rendered as a hyphen only when a line breaks at it. The `[-]` authoring notation maps to it.
pub fn soft_hyphen() -> text { return text_from_bytes([0xC2 as u8, 0xAD as u8]); }

fn is_shy(c: character) -> boolean { return (c as integer) == 173; }

// encode the `[-]` authoring notation → the canonical soft hyphen (import / editor-input path).
pub fn encode_shy(s: text) -> text {
  shy = soft_hyphen();
  out: text = "";
  cs: vector<character> = [];
  for c in s { cs += [c]; }
  n = len(cs);
  i = 0;
  while i < n {
    if ((i + 2) < n) && (cs[i]? == '[') && (cs[i + 1]? == '-') && (cs[i + 2]? == ']') {
      out += shy;
      i += 3;
    } else {
      ch = cs[i]?;
      out += "{ch}";
      i += 1;
    }
  }
  return out;
}

// decode the canonical soft hyphen → the `[-]` notation (editor source-view / round-trip).
pub fn decode_shy(s: text) -> text {
  out: text = "";
  for c in s {
    if is_shy(c) { out += "[-]"; } else { out += "{c}"; }
  }
  return out;
}

// seg(⌊D⌋) → the token stream T of words ω, glue δ, and discretionary breaks (soft hyphens).
// A soft hyphen becomes its own zero-width Disc token; words are maximal non-space,
// non-soft-hyphen runs, so every inter-token boundary is a legal break (space or hyphen).
pub fn seg(d: Doc) -> vector<Token> {
  toks: vector<Token> = [];
  s = materialise(d);
  cs: vector<character> = [];
  for c in s { cs += [c]; }
  n = len(cs);
  i = 0;
  while i < n {
    ch = cs[i]?;
    if is_shy(ch) {
      toks += [ Token { word: false, ts: i, te: i + 1, disc: true } ];
      i += 1;
    } else if is_nl(ch) {
      seg_start = i;
      while (i < n) && is_nl(cs[i]?) { i += 1; }   // a run of newlines is ONE paragraph break
      toks += [ Token { word: false, ts: seg_start, te: i, brk: true } ];
    } else if is_space(ch) {
      seg_start = i;
      while (i < n) && is_space(cs[i]?) { i += 1; }
      toks += [ Token { word: false, ts: seg_start, te: i } ];
    } else {
      seg_start = i;
      while (i < n) && (!is_space(cs[i]?)) && (!is_shy(cs[i]?)) && (!is_nl(cs[i]?)) { i += 1; }
      toks += [ Token { word: true, ts: seg_start, te: i } ];
    }
  }
  return toks;
}

// -------------------- S1.3 token width via the Measurer --------------------

// ν(t) — the measured advance of a token: the sum over its style-runs of μ(run, ρ(style)).
// (A token may cross a style boundary; each run measures under its own resolved style.)
pub fn token_width(d: Doc, tok: Token, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> float {
  if tok.disc { return 0.0; }   // a discretionary break has no width unless the line breaks at it
  w = 0.0;
  runs = slice_runs(d, tok.ts, tok.te);
  for i in 0..len(runs) {
    r = runs[i]?;
    w += measure(r.str, resolve(r.style));
  }
  return w;
}

// -------------------- S1.4 greedy line breaking (the brk recurrence) --------------------

// A laid-out line: the half-open token range [first, last_excl). Trailing glue at a break
// is NOT included (notation §6: "trailing glue on a line is dropped").
pub struct Line { pub first: integer, pub last_excl: integer }

// greedy line breaking (notation §6, brk): pack words while they fit in width W; interior
// glue counts toward the width, trailing glue is deferred and dropped at a break. The first
// word on a line always fits (an over-wide word overflows onto its own line, no infinite loop).
pub fn lines_greedy(d: Doc, toks: vector<Token>, w_col: float, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> vector<Line> {
  lns: vector<Line> = [];
  ls = 0;              // current line's start token index
  last_word_end = 0;   // token index just past the last word committed to the current line
  acc = 0.0;           // committed width on the current line (words + interior glue)
  pending = 0.0;       // glue width seen but not yet committed (only counts if a word follows)
  line_has_word = false;
  i = 0;
  while i < len(toks) {
    tok = toks[i]?;
    wtok = token_width(d, tok, resolve, measure);
    if tok.word {
      if !line_has_word {
        acc = wtok;
        last_word_end = i + 1;
        line_has_word = true;
        i += 1;
      } else if (acc + pending + wtok) <= w_col {
        acc += pending + wtok;
        pending = 0.0;
        last_word_end = i + 1;
        i += 1;
      } else {
        lns += [ Line { first: ls, last_excl: last_word_end } ];
        ls = i;
        acc = 0.0;
        pending = 0.0;
        line_has_word = false;
        // do NOT advance i — reprocess this word as the first on the new line
      }
    } else {
      pending += wtok;
      i += 1;
    }
  }
  if last_word_end > ls { lns += [ Line { first: ls, last_excl: last_word_end } ]; }
  return lns;
}

// -------------------- S1.5 flow layout → BoxSet (I3) --------------------

// Break ONE line greedily starting at token `from` → (last_excl, next_tok, hyphen_disc,
// para_break): the line covers tokens [from, last_excl); the next line starts at `next_tok`;
// hyphen_disc is the token index of a soft hyphen the line broke a word at (a trailing hyphen
// glyph), else -1; para_break is true when the line ended at a HARD paragraph break (which is
// consumed — `next_tok` is past it). The per-line step both the full layout and the patch reuse.
pub fn next_line(d: Doc, toks: vector<Token>, from: integer, w_col: float, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> (integer, integer, integer, boolean) {
  i = from;
  acc = 0.0;
  pending = 0.0;
  line_has_word = false;
  last_word_end = from;
  while i < len(toks) {
    tok = toks[i]?;
    if tok.brk {
      return (last_word_end, i + 1, 0 - 1, true);   // hard break ends the line; consume it
    }
    wtok = token_width(d, tok, resolve, measure);
    if tok.word {
      if !line_has_word {
        acc = wtok;
        last_word_end = i + 1;
        line_has_word = true;
        i += 1;
      } else if (acc + pending + wtok) <= w_col {
        acc += pending + wtok;
        pending = 0.0;
        last_word_end = i + 1;
        i += 1;
      } else {
        hy = 0 - 1;
        if (i > 0) && ((toks[i - 1]?).disc) { hy = i - 1; }   // broke a word at a soft hyphen
        return (last_word_end, i, hy, false);
      }
    } else {
      pending += wtok;   // glue or disc; disc adds 0 (see token_width)
      i += 1;
    }
  }
  return (last_word_end, i, 0 - 1, false);
}

// A placed line: its boxes plus its LaidLine metadata.
pub struct PlacedLine { boxes: vector<Box>, line: LaidLine }

// Place tokens [first, last_excl) at top y — one box per style-run, x-advance left to right;
// the shared placement primitive so the full layout and a patched tail are byte-identical. If
// `hyphen_disc >= 0`, append a trailing hyphen glyph box for that soft hyphen (S4.7).
pub fn place_line(d: Doc, toks: vector<Token>, first: integer, last_excl: integer, hyphen_disc: integer, para_end: boolean, y: float, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> PlacedLine {
  lh = 0.0;
  for ti in first..last_excl {
    tok = toks[ti]?;
    if !tok.disc {   // a discretionary break is zero-width and boxless unless broken at
      hruns = slice_runs(d, tok.ts, tok.te);
      for ri in 0..len(hruns) {
        hh = run_height(resolve((hruns[ri]?).style));
        if hh > lh { lh = hh; }
      }
    }
  }
  boxes: vector<Box> = [];
  x = 0.0;
  line_cs = 0 - 1;
  line_ce = 0 - 1;
  for tj in first..last_excl {
    tok2 = toks[tj]?;
    if !tok2.disc {
      pruns = slice_runs(d, tok2.ts, tok2.te);
      roff = tok2.ts;
      for rj in 0..len(pruns) {
        r = pruns[rj]?;
        st = resolve(r.style);
        rw = measure(r.str, st);
        rh = run_height(st);
        if line_cs < 0 { line_cs = roff; }
        boxes += [ Box { rect: Rect { x: x, y: y, w: rw, h: rh }, ks: roff, ke: roff + len(r.str), style: r.style } ];
        line_ce = roff + len(r.str);
        x += rw;
        roff += len(r.str);
      }
    }
  }
  // trailing hyphen from a discretionary break: renders "-", κ points at the soft hyphen
  if hyphen_disc >= 0 {
    disc = toks[hyphen_disc]?;
    druns = slice_runs(d, disc.ts, disc.te);
    hstyle = if len(druns) > 0 { (druns[0]?).style } else { 0 };
    hst = resolve(hstyle);
    hw = measure("-", hst);
    hh2 = run_height(hst);
    if hh2 > lh { lh = hh2; }
    boxes += [ Box { rect: Rect { x: x, y: y, w: hw, h: hh2 }, ks: disc.ts, ke: disc.te, style: hstyle, hyphen: true } ];
    line_ce = disc.te;
    x += hw;
  }
  return PlacedLine { boxes: boxes, line: LaidLine { cs: line_cs, ce: line_ce, y: y, h: lh, para_end: para_end } };
}

// Lay out a TOKEN LIST (absolute char spans) from top y0 — the whole-doc layout, built as a
// sequence of next_line + place_line steps (the exact steps an incremental patch reuses).
pub fn layout_tokens_from(d: Doc, toks: vector<Token>, y0: float, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, w_col: float, para_gap: float = 0.0) -> Layout {
  boxes: vector<Box> = [];
  lines: vector<LaidLine> = [];
  y = y0;
  cur = 0;
  while cur < len(toks) {
    nl = next_line(d, toks, cur, w_col, resolve, measure);
    last_excl = nl.0;
    next_tok = nl.1;
    para = nl.3;
    if last_excl > cur {
      pe = para || (next_tok >= len(toks));   // last line of a paragraph or the doc — ragged
      pl = place_line(d, toks, cur, last_excl, nl.2, pe, y, resolve, measure);
      for bi in 0..len(pl.boxes) { boxes += [ pl.boxes[bi]? ]; }
      lines += [ LaidLine { cs: pl.line.cs, ce: pl.line.ce, y: pl.line.y, h: pl.line.h, para_end: pl.line.para_end, right: w_col } ];
      y += pl.line.h;
      if para { y += para_gap; }
    } else if para {
      y += para_gap;   // a standalone/leading break contributes a blank paragraph gap
    }
    cur = next_tok;
  }
  return Layout { boxes: boxes, lines: lines };
}

// L for the flow surface: content + resolver + measurer + column width → a full Layout.
// `para_gap` is the extra vertical space after each paragraph (0 = paragraphs still break onto
// new lines, just with no gap).
pub fn flow_layout_full(d: Doc, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, w_col: float, para_gap: float = 0.0) -> Layout {
  return layout_tokens_from(d, seg(d), 0.0, resolve, measure, w_col, para_gap);
}

// The BoxSet-only view of a flow layout (the M1/M2 surface).
// Example: @ZTX-004 — measurement is an ARGUMENT, which is why the same document
// and ports lay out identically on every target; `default_resolver` + `mono_measure`
// are the deterministic pair to test against.
pub fn flow_layout(d: Doc, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, w_col: float) -> BoxSet {
  return BoxSet { boxes: flow_layout_full(d, resolve, measure, w_col).boxes };
}

// D1 (EDITOR_DESIGN step D) — the LINE ASSIGNMENT: the per-line logical char spans [cs,ce) a
// document breaks into (each with its y/height, its justify edge `right`, and whether it is a
// paragraph's ragged last line). It is a PURE function of (content, styles, metrics, column), and
// it is the layer the determinism budget lives at (§8.22): common word widths ⇒ identical breaks ⇒
// identical assignment on every platform. This is what a screen RenderSink paints one element PER
// (step D2), and what the loft-native PDF emitter (step E) paginates from — both downstream of, and
// agreeing because of, this one assignment. Materialise a line's text with
// `materialise_range(d, cs, ce)`.
pub fn line_assignment(d: Doc, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, w_col: float) -> vector<LaidLine> {
  // Bind the whole Layout to a LOCAL before returning its `.lines`: `return f().lines` would hand
  // back a heap vector aliasing an unbound temporary, which a later store-allocating call in the
  // caller (e.g. materialise_range) transiently clobbers to length 0 (LOFT_WORRIES footgun).
  layout = flow_layout_full(d, resolve, measure, w_col);
  return layout.lines;
}

// -------------------- S1.6 headless RenderSink (deterministic dump) --------------------

// A headless RenderSink R: a deterministic textual dump of the BoxSet, one line per box —
// `[x,y,w,h] ks..ke s=style 'text'`. Golden-string comparable; the eyeball/regression view
// that stands in for a real DOM/Canvas/PDF sink (which is a Tier-2 backend, swapped at the
// browser edge). Coordinates are printed to one decimal so the string is backend-stable.
pub fn render_dump(bs: BoxSet, d: Doc) -> text {
  out: text = "";
  for i in 0..len(bs.boxes) {
    b = bs.boxes[i]?;
    r = b.rect;
    txt = if b.hyphen { "-" } else { materialise_range(d, b.ks, b.ke) };
    out += "[{r.x:0.1},{r.y:0.1},{r.w:0.1},{r.h:0.1}] {b.ks}..{b.ke} s={b.style} '{txt}'\n";
  }
  return out;
}

// The DOM/Canvas RenderSink R : 𝔅 → paint commands — the reusable engine-level sink PROTOCOL. A
// thin per-target host (browser host.js / native / PDF) paints these; loft owns every coordinate
// (§8.22), so the geometry is identical on every reader and the host only positions boxes it is
// handed. One `LINE|y|h|right|paraEnd` per laid line (the justified line assignment, D2) then one
// `BOX|x|y|w|h|style|text` per glyph-box (CSS px, 2 decimals so the string is backend-stable). This
// is CONTENT-ONLY: caret/selection overlays are a consumer concern layered on top (cf. ztedit's
// `ed_render`, which adds SCALE/DOC/CARET/SEL around this same LINE/BOX core), so any read surface —
// viewer, editor, print preview — reuses it unchanged. Takes a full Layout (lines + boxes).
pub fn render_commands(l: Layout, d: Doc) -> text {
  out: text = "";
  for i in 0..len(l.lines) {
    ln = l.lines[i]?;
    pe = if ln.para_end { 1 } else { 0 };
    out += "LINE|{ln.y:0.2}|{ln.h:0.2}|{ln.right:0.2}|{pe}\n";
  }
  for i in 0..len(l.boxes) {
    b = l.boxes[i]?; r = b.rect;
    t = if b.hyphen { "-" } else { materialise_range(d, b.ks, b.ke) };
    out += "BOX|{r.x:0.2}|{r.y:0.2}|{r.w:0.2}|{r.h:0.2}|{b.style}|{t}\n";
  }
  return out;
}

// The total laid-out height (sum of line heights) — the column's content height.
pub fn boxset_height(bs: BoxSet) -> float {
  h = 0.0;
  for i in 0..len(bs.boxes) {
    b = bs.boxes[i]?;
    bot = b.rect.y + b.rect.h;
    if bot > h { h = bot; }
  }
  return h;
}

// ===================================================================================
// M2 — select words / lines.   Notation §7.   Invariant I4 (selection = κ-intersection).
// ===================================================================================

fn fabs(v: float) -> float { if v < 0.0 { return 0.0 - v; } return v; }

// the first k characters of s (character-safe prefix, for hit-test refinement).
fn text_take(s: text, k: integer) -> text {
  if k <= 0 { return ""; }
  out: text = "";
  taken = 0;
  for c in s {
    if taken >= k { return out; }
    out += "{c}";
    taken += 1;
  }
  return out;
}

// -------------------- S2.1 hit-test: point → box --------------------

// box_at(𝔅, x, y) → the index of the box CONTAINING (x,y) (half-open rect), or -1 if the
// point is in no box (a margin / gap — anchor_at then falls back to the nearest box).
pub fn box_at(bs: BoxSet, x: float, y: float) -> integer {
  for i in 0..len(bs.boxes) {
    b = bs.boxes[i]?;
    r = b.rect;
    if (x >= r.x) && (x < r.x + r.w) && (y >= r.y) && (y < r.y + r.h) { return i; }
  }
  return 0 - 1;
}

// the index of the box nearest (x,y): vertical distance dominates (so a click in a margin
// snaps to the right LINE first), then horizontal. Used when the point is in no box.
fn nearest_box(bs: BoxSet, x: float, y: float) -> integer {
  best = 0 - 1;
  best_d = 0.0;
  for i in 0..len(bs.boxes) {
    b = bs.boxes[i]?;
    r = b.rect;
    cx = if x < r.x { r.x } else { if x > r.x + r.w { r.x + r.w } else { x } };
    cy = if y < r.y { r.y } else { if y > r.y + r.h { r.y + r.h } else { y } };
    dd = fabs(y - cy) * 1000000.0 + fabs(x - cx);
    if (best < 0) || (dd < best_d) { best = i; best_d = dd; }
  }
  return best;
}

// -------------------- S2.2 hit-test: point → logical anchor (I4) --------------------

// H(q, 𝔅) — the logical anchor for a point: pick the box (containing, else nearest), then
// refine x WITHIN the box by finding the character split whose prefix width is closest to
// the local x (via μ, so it is correct for proportional fonts, not just monospace).
// (RTL refinement, where the prefix mapping reverses, is completed with BiDi placement in
// S4.5; this LTR refinement is exact for the flow surface.)
pub fn anchor_at(bs: BoxSet, d: Doc, x: float, y: float, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> integer {
  bi = box_at(bs, x, y);
  if bi < 0 { bi = nearest_box(bs, x, y); }
  if bi < 0 { return 0; }
  b = bs.boxes[bi]?;
  local = x - b.rect.x;
  runtext = materialise_range(d, b.ks, b.ke);
  st = resolve(b.style);
  nlen = b.ke - b.ks;
  best_k = 0;
  best_d = fabs(local);
  for k in 0..=nlen {
    pw = measure(text_take(runtext, k), st);
    dd = fabs(local - pw);
    if dd < best_d { best_d = dd; best_k = k; }
  }
  return b.ks + best_k;
}

// -------------------- S2.3 selection = κ-intersection (I4) --------------------

// Sel(a,b) = { β : κ(β) ∩ [a,b) ≠ ∅ } — the boxes a logical selection [a,b) covers, returned
// as box indices. Defined PURELY by κ-intersection: it never mentions geometry, so a range
// crossing a direction boundary yields the correct (possibly visually-split) box set with no
// special-case code. THIS is the whole BiDi selection story.
pub fn sel(bs: BoxSet, a: integer, b: integer) -> vector<integer> {
  out: vector<integer> = [];
  if b <= a { return out; }   // a zero-width (caret) selection covers no boxes
  for i in 0..len(bs.boxes) {
    box = bs.boxes[i]?;
    if (box.ks < b) && (a < box.ke) { out += [i]; }
  }
  return out;
}

// Sel(a,b) as precise highlight RECTANGLES — character-granular. Each box the range covers is
// CLIPPED to its selected sub-span [max(a,ks), min(b,ke)), the endpoints mapped to x within the
// box via μ. So a partially-selected word highlights only the selected characters, not the whole
// word (`sel` returns whole boxes; this returns the sub-rects to paint). A fully-selected box
// yields its whole rect; the boundary boxes are the fine ones.
pub fn sel_rects(bs: BoxSet, d: Doc, a: integer, b: integer, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> vector<Rect> {
  out: vector<Rect> = [];
  if b <= a { return out; }
  for i in 0..len(bs.boxes) {
    box = bs.boxes[i]?;
    if (box.ks < b) && (a < box.ke) {
      lo = if a > box.ks { a } else { box.ks };
      hi = if b < box.ke { b } else { box.ke };
      st = resolve(box.style);
      runtext = materialise_range(d, box.ks, box.ke);
      x_lo = box.rect.x + measure(text_take(runtext, lo - box.ks), st);
      x_hi = box.rect.x + measure(text_take(runtext, hi - box.ks), st);
      out += [ Rect { x: x_lo, y: box.rect.y, w: x_hi - x_lo, h: box.rect.h } ];
    }
  }
  return out;
}

// -------------------- S2.4 word / line semantics --------------------

// caret_at(a) — the caret rectangle (zero width) for logical anchor a: the x is refined within
// the box holding a (a==ke → the box's right edge; a==ks → its left), y/h from that box. The
// dual of anchor_at (point → anchor); this is anchor → point, for drawing the cursor.
pub fn caret_at(bs: BoxSet, d: Doc, a: integer, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> Rect {
  for i in 0..len(bs.boxes) {
    box = bs.boxes[i]?;
    if (box.ks <= a) && (a < box.ke) {
      runtext = materialise_range(d, box.ks, box.ke);
      cx = box.rect.x + measure(text_take(runtext, a - box.ks), resolve(box.style));
      return Rect { x: cx, y: box.rect.y, w: 0.0, h: box.rect.h };
    }
  }
  // a at a box's trailing edge (end of doc, or before a dropped space)
  found = false;
  fx = 0.0;
  fy = 0.0;
  fh = 1.0;
  for i in 0..len(bs.boxes) {
    box = bs.boxes[i]?;
    if box.ke == a { fx = box.rect.x + box.rect.w; fy = box.rect.y; fh = box.rect.h; found = true; }
  }
  if found { return Rect { x: fx, y: fy, w: 0.0, h: fh }; }
  if len(bs.boxes) > 0 {
    lb = bs.boxes[len(bs.boxes) - 1]?;
    return Rect { x: lb.rect.x + lb.rect.w, y: lb.rect.y, w: 0.0, h: lb.rect.h };
  }
  return Rect { x: 0.0, y: 0.0, w: 0.0, h: 1.0 };
}

// word(a) — the [ts,te) span of the word containing anchor a (what a double-click selects). A
// discretionary break does NOT end a word, so a hyphenated word selects whole: expand across
// adjacent word/disc tokens, stopping at glue. a in whitespace returns that glue run.
pub fn word_at(d: Doc, a: integer) -> (integer, integer) {
  toks = seg(d);
  idx = 0 - 1;
  for i in 0..len(toks) {
    t = toks[i]?;
    if (a >= t.ts) && (a < t.te) { idx = i; }
  }
  if idx < 0 {
    if len(toks) > 0 { last = toks[len(toks) - 1]?; return (last.ts, last.te); }
    return (a, a);
  }
  hit = toks[idx]?;
  if (!hit.word) && (!hit.disc) { return (hit.ts, hit.te); }   // glue → that run
  lo_i = idx;
  while (lo_i > 0) && (((toks[lo_i - 1]?).word) || ((toks[lo_i - 1]?).disc)) { lo_i -= 1; }
  hi_i = idx;
  while ((hi_i + 1) < len(toks)) && (((toks[hi_i + 1]?).word) || ((toks[hi_i + 1]?).disc)) { hi_i += 1; }
  return ((toks[lo_i]?).ts, (toks[hi_i]?).te);
}

// line(a) — the box indices on the VISUAL line containing anchor a. A visual line is the
// maximal run of boxes sharing box@a's y that is CONTIGUOUS in reading order — not every box
// at that y. This matters for multi-column pages: column 1's line 3 and column 2's line 3
// have the same absolute y, but they are laid out in reading order (all of column 1, then
// column 2), so a same-y run stays within one column. (A degenerate page with columns only
// one line tall would put two columns' first lines adjacent at the same y; real pages are
// taller, and layout_columns emits boxes column-major, so the run boundary is exact.)
pub fn line_boxes(bs: BoxSet, a: integer) -> vector<integer> {
  out: vector<integer> = [];
  m = len(bs.boxes);
  if m == 0 { return out; }
  hit = 0 - 1;
  for i in 0..m {
    b = bs.boxes[i]?;
    if (b.ks <= a) && (a < b.ke) { hit = i; }
  }
  if hit < 0 { hit = m - 1; }
  yline = (bs.boxes[hit]?).rect.y;
  lo = hit;
  while (lo > 0) && ((bs.boxes[lo - 1]?).rect.y == yline) { lo -= 1; }
  hi = hit;
  while ((hi + 1) < m) && ((bs.boxes[hi + 1]?).rect.y == yline) { hi += 1; }
  for j in lo..(hi + 1) { out += [j]; }
  return out;
}

// ===================================================================================
// M3 — diverse edits → incremental reflow.   Notation §8.   Invariant I5 (incremental==full).
// ===================================================================================

fn imin(a: integer, b: integer) -> integer { if a < b { return a; } return b; }

// the doc's logical text as a character vector (for the styled-string diff).
pub fn doc_chars(d: Doc) -> vector<character> {
  out: vector<character> = [];
  s = materialise(d);
  for c in s { out += [c]; }
  return out;
}

// -------------------- S3.2 dirty range --------------------

// dirty(D, D′) → (lo, hi_old, hi_new): the minimal changed span, computed by diffing the
// STYLED string (char AND style per position) — common prefix `lo`, common suffix trimmed.
// old[lo,hi_old) was replaced by new[lo,hi_new); the net char change is hi_new - hi_old.
// Diffing the observable styled string (not τ's op structure) gives the tightest range and
// makes patch independent of HOW the edit was expressed.
pub fn dirty(old_d: Doc, new_d: Doc) -> (integer, integer, integer) {
  oc = doc_chars(old_d);   ostyle = char_styles(old_d);
  nc = doc_chars(new_d);   nstyle = char_styles(new_d);
  lo_len = len(oc);
  hi_len = len(nc);
  mn = imin(lo_len, hi_len);
  lo = 0;
  while (lo < mn) && (oc[lo]? == nc[lo]?) && ((ostyle[lo]?) == (nstyle[lo]?)) { lo += 1; }
  su = 0;
  while (su < (mn - lo)) && (oc[lo_len - 1 - su]? == nc[hi_len - 1 - su]?) && ((ostyle[lo_len - 1 - su]?) == (nstyle[hi_len - 1 - su]?)) { su += 1; }
  return (lo, lo_len - su, hi_len - su);
}

// -------------------- S3.3 incremental patch (I5) --------------------

// The result of an incremental relayout: the new Layout, plus how many boxes were RECOMPUTED
// (the tail) vs REUSED from the old layout (the prefix). The counts are the bounded-work
// evidence M5 asserts; the layout is what M3's differential checks against a full relayout.
pub struct PatchResult { pub layout: Layout, pub recomputed: integer, pub reused: integer }

fn full_patch(new_d: Doc, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, w_col: float) -> PatchResult {
  fl = flow_layout_full(new_d, resolve, measure, w_col);
  return PatchResult { layout: fl, recomputed: len(fl.boxes), reused: 0 };
}

// patch(L(D), D, D′) → a Layout EQUAL to L(D′) (the differential law I5), computed
// incrementally and with BOUNDED re-measurement (I7): reuse every line strictly before the
// edit verbatim, re-lay lines from there only until the break structure RECONVERGES with the
// old layout, then TRANSLATE the untouched tail (a cheap char+y shift, no re-measure).
//
// Two correctness hinges:
//  · start one line EARLIER than the line containing the edit — that earlier line's content
//    is entirely before the edit, so the break INTO it is fixed; re-laying from there catches
//    a "backward join" (a shrunk word rising onto the previous line);
//  · reconvergence — once a re-laid line ends at a new-doc char `nsc` past the dirty region
//    and some OLD line starts at `nsc − delta`, the token streams from there are identical
//    (shifted by delta), so the greedy produces identical lines: reuse the old tail, shifted.
// First-line / before-all edits fall back to a full relayout (still trivially I5-correct).
pub fn patch(old_layout: Layout, old_doc: Doc, new_doc: Doc, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, w_col: float) -> PatchResult {
  dd = dirty(old_doc, new_doc);
  lo = dd.0;
  hi_old = dd.1;
  hi_new = dd.2;
  if (lo == hi_old) && (lo == hi_new) {
    return PatchResult { layout: old_layout, recomputed: 0, reused: len(old_layout.boxes) };
  }
  if len(old_layout.lines) == 0 { return full_patch(new_doc, resolve, measure, w_col); }
  delta = hi_new - hi_old;

  // C = last old line starting at or before the edit; start one line earlier.
  cix = 0 - 1;
  for i in 0..len(old_layout.lines) {
    ln = old_layout.lines[i]?;
    if (ln.cs >= 0) && (ln.cs <= lo) { cix = i; }
  }
  if cix < 0 { return full_patch(new_doc, resolve, measure, w_col); }
  lstart = cix - 1;
  if lstart < 0 { lstart = 0; }
  sline = old_layout.lines[lstart]?;
  start_char = sline.cs;
  start_y = sline.y;
  if start_char <= 0 { return full_patch(new_doc, resolve, measure, w_col); }

  // start_char must be exactly a token (word) boundary in the unchanged prefix.
  ntoks = seg(new_doc);
  ti0 = 0 - 1;
  for i in 0..len(ntoks) {
    if (ntoks[i]?).ts >= start_char { ti0 = i; break; }
  }
  if (ti0 < 0) || ((ntoks[ti0]?).ts != start_char) { return full_patch(new_doc, resolve, measure, w_col); }

  // reused prefix: old boxes/lines strictly before start_char
  out_boxes: vector<Box> = [];
  for i in 0..len(old_layout.boxes) {
    b = old_layout.boxes[i]?;
    if b.ke <= start_char { out_boxes += [b]; }
  }
  out_lines: vector<LaidLine> = [];
  for i in 0..lstart { out_lines += [ old_layout.lines[i]? ]; }

  // re-lay lines from ti0, stopping at reconvergence
  recomputed = 0;
  cur = ti0;
  y = start_y;
  converged = false;
  conv_old_line = 0 - 1;
  conv_y = 0.0;
  while cur < len(ntoks) {
    nl = next_line(new_doc, ntoks, cur, w_col, resolve, measure);
    last_excl = nl.0;
    next_tok = nl.1;
    if last_excl > cur {
      pe = nl.3 || (next_tok >= len(ntoks));
      pl = place_line(new_doc, ntoks, cur, last_excl, nl.2, pe, y, resolve, measure);
      for bi in 0..len(pl.boxes) { out_boxes += [ pl.boxes[bi]? ]; recomputed += 1; }
      out_lines += [ LaidLine { cs: pl.line.cs, ce: pl.line.ce, y: pl.line.y, h: pl.line.h, para_end: pl.line.para_end, right: w_col } ];
      y += pl.line.h;
    }
    cur = next_tok;
    if cur < len(ntoks) {
      nsc = (ntoks[cur]?).ts;
      if nsc > hi_new {
        for k in 0..len(old_layout.lines) {
          ol = old_layout.lines[k]?;
          if ol.cs == (nsc - delta) { conv_old_line = k; }
        }
        if conv_old_line >= 0 { converged = true; conv_y = y; }
      }
    }
    if converged { break; }
  }

  // translate the untouched old tail (boxes/lines from the reconvergence line), shifted by
  // (delta chars, conv_y − old_y) — a pure shift, NOT a re-measure.
  if converged {
    olj = old_layout.lines[conv_old_line]?;
    ydelta = conv_y - olj.y;
    for i in 0..len(old_layout.boxes) {
      b = old_layout.boxes[i]?;
      if b.ks >= olj.cs {
        out_boxes += [ Box { rect: Rect { x: b.rect.x, y: b.rect.y + ydelta, w: b.rect.w, h: b.rect.h }, ks: b.ks + delta, ke: b.ke + delta, style: b.style, hyphen: b.hyphen } ];
      }
    }
    for k in conv_old_line..len(old_layout.lines) {
      ol = old_layout.lines[k]?;
      out_lines += [ LaidLine { cs: ol.cs + delta, ce: ol.ce + delta, y: ol.y + ydelta, h: ol.h, para_end: ol.para_end, right: ol.right } ];
    }
  }

  return PatchResult { layout: Layout { boxes: out_boxes, lines: out_lines }, recomputed: recomputed, reused: len(out_boxes) - recomputed };
}

// The BoxSet dump of a Layout (for golden/differential comparison).
pub fn render_dump_layout(l: Layout, d: Doc) -> text { return render_dump(BoxSet { boxes: l.boxes }, d); }

// -------------------- S3.4 caret maps through a transaction --------------------

// map_anchor(a, op) — where a caret at logical anchor a lands after op. Insert strictly
// before a shifts it right (a stays attached to the same following text); insert at/after a
// leaves it (typed text lands after the caret). Delete before a shifts it left; delete after
// a leaves it; delete spanning a collapses it to the deletion start. Style ops don't move it.
pub fn map_anchor(a: integer, op: Op) -> integer {
  if op is Ins { at, str } {
    if at < a { return a + len(str); }
    return a;
  }
  if op is Del { from, to } {
    if a <= from { return a; }
    if a >= to { return a - (to - from); }
    return from;
  }
  return a;
}

// map a caret through a whole transaction (op by op, left to right).
pub fn map_anchor_txn(a: integer, t: Txn) -> integer {
  cur = a;
  for i in 0..len(t.ops) { cur = map_anchor(cur, t.ops[i]?); }
  return cur;
}

// ===================================================================================
// M4 — make it flow correctly.   Notation §6.   Invariant I6 (seek via π, not O(N)).
// ===================================================================================

// -------------------- S4.2 pagination + S4.3 the break-point index π --------------------

// A page: the line range [first_line, last_excl) it holds, plus the char and y at which it
// starts (its π entry — enough to lay the page out from scratch without the pages before it).
pub struct Page { pub first_line: integer, pub last_excl: integer, pub first_char: integer, pub top_y: float }

// paginate a laid-out column: lines stack onto a page until the next line would exceed
// page_h, then a new page begins. The first line of a page always fits (even if taller than
// page_h). The page list IS the break-point index π (each Page.first_char = π(n)).
pub fn paginate(layout: Layout, page_h: float) -> vector<Page> {
  pages: vector<Page> = [];
  n = len(layout.lines);
  first_line = 0;
  used = 0.0;
  for i in 0..n {
    ln = layout.lines[i]?;
    if (i > first_line) && ((used + ln.h) > page_h) {
      fl = layout.lines[first_line]?;
      pages += [ Page { first_line: first_line, last_excl: i, first_char: fl.cs, top_y: fl.y } ];
      first_line = i;
      used = 0.0;
    }
    used += ln.h;
  }
  if n > first_line {
    fl2 = layout.lines[first_line]?;
    pages += [ Page { first_line: first_line, last_excl: n, first_char: fl2.cs, top_y: fl2.y } ];
  }
  return pages;
}

// seek(N) — the first CHARACTER of page N, read directly from π in O(1) (no laying out of
// pages 1..N−1). Pairs with layout_page to render page N from scratch (I6).
pub fn seek_page_char(pages: vector<Page>, n: integer) -> integer {
  if (n < 0) || (n >= len(pages)) { return 0 - 1; }
  return (pages[n]?).first_char;
}

// Lay out ONE page directly from a page-start (its first_char + top_y) — the same stop rule
// paginate uses, so it reproduces exactly that page's lines. This is "seek to page N without
// laying out 1..N−1": the work is one page of lines, independent of N (I6).
pub fn layout_page(d: Doc, start_char: integer, y0: float, page_h: float, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, w_col: float) -> Layout {
  toks = seg(d);
  ti0 = 0;
  for i in 0..len(toks) {
    if (toks[i]?).ts >= start_char { ti0 = i; break; }
  }
  boxes: vector<Box> = [];
  lines: vector<LaidLine> = [];
  y = y0;
  used = 0.0;
  cur = ti0;
  while cur < len(toks) {
    nl = next_line(d, toks, cur, w_col, resolve, measure);
    last_excl = nl.0;
    next_tok = nl.1;
    if last_excl > cur {
      pe = nl.3 || (next_tok >= len(toks));
      pl = place_line(d, toks, cur, last_excl, nl.2, pe, y, resolve, measure);
      if (used > 0.0) && ((used + pl.line.h) > page_h) { return Layout { boxes: boxes, lines: lines }; }
      for bi in 0..len(pl.boxes) { boxes += [ pl.boxes[bi]? ]; }
      lines += [ LaidLine { cs: pl.line.cs, ce: pl.line.ce, y: pl.line.y, h: pl.line.h, para_end: pl.line.para_end, right: w_col } ];
      y += pl.line.h;
      used += pl.line.h;
    }
    cur = next_tok;
  }
  return Layout { boxes: boxes, lines: lines };
}

// -------------------- S4.5 BiDi placement — the ψ reorder within a line --------------------

// Apply the UAX-9 L2 reorder (LTR/RTL subset) to a laid-out column: within each visual line,
// each maximal run of RTL boxes (resolved dir == 1) has its VISUAL order reversed — the boxes
// keep their logical κ, only their x changes. So x follows ψ while κ follows logical, and any
// κ-based operation (Sel, hit-test) is unaffected. Base direction is LTR (the flow surface).
pub fn apply_bidi(layout: Layout, resolve: fn(integer) -> Style) -> Layout {
  boxes = layout.boxes;
  m = len(boxes);
  out: vector<Box> = [];
  for i in 0..m { out += [ boxes[i]? ]; }
  gi = 0;
  while gi < m {
    y0 = (boxes[gi]?).rect.y;
    gj = gi;
    while (gj < m) && ((boxes[gj]?).rect.y == y0) { gj += 1; }   // visual line group [gi, gj)
    p = gi;
    while p < gj {
      if resolve((boxes[p]?).style).dir == 1 {
        q = p;
        while (q < gj) && (resolve((boxes[q]?).style).dir == 1) { q += 1; }   // RTL run [p,q)
        cx = (boxes[p]?).rect.x;   // the run's left edge stays fixed; fill it in reverse
        for k in rev(p..q) {
          b = boxes[k]?;
          out[k] = Box { rect: Rect { x: cx, y: b.rect.y, w: b.rect.w, h: b.rect.h }, ks: b.ks, ke: b.ke, style: b.style, hyphen: b.hyphen };
          cx += b.rect.w;
        }
        p = q;
      } else {
        p += 1;
      }
    }
    gi = gj;
  }
  return Layout { boxes: out, lines: layout.lines };
}

// -------------------- S4.6 justification (glue stretch) --------------------

// true if the box covers only spaces (stretchable glue).
fn box_is_glue(d: Doc, b: Box) -> boolean {
  s = materialise_range(d, b.ks, b.ke);
  if len(s) == 0 { return false; }
  for c in s { if c != ' ' { return false; } }
  return true;
}

// the laid-out line whose first box starts at char `cs` (matched by cs so it is robust even
// where two columns share a y). Returns a default LaidLine if not found.
fn group_line(lines: vector<LaidLine>, cs: integer) -> LaidLine {
  for k in 0..len(lines) {
    l = lines[k]?;
    if l.cs == cs { return l; }
  }
  return LaidLine { cs: 0 - 1, ce: 0 - 1, y: 0.0, h: 0.0 };
}

// Justify each visual line by distributing its leftover space equally across its glue boxes
// (widen each, shift the rest right). The target right edge is the line's OWN available right
// (`LaidLine.right`) — the column edge, or an image's edge for a line beside one — so it works
// per column AND beside images (falls back to line_left + w_col for a hand-built layout). A
// paragraph's LAST line stays ragged UNLESS `justify_last` (the old-school "fill every line"
// look). Overfull or glue-less lines are left as-is. (Per-glue stretch/shrink BOUNDS are a
// refinement; this distributes equally.)
pub fn justify(layout: Layout, d: Doc, w_col: float, justify_last: boolean = false) -> Layout {
  boxes = layout.boxes;
  m = len(boxes);
  out: vector<Box> = [];
  for i in 0..m { out += [ boxes[i]? ]; }
  gi = 0;
  while gi < m {
    y0 = (boxes[gi]?).rect.y;
    gj = gi;
    while (gj < m) && ((boxes[gj]?).rect.y == y0) { gj += 1; }   // line group [gi, gj)
    line_left = (boxes[gi]?).rect.x;
    ln = group_line(layout.lines, (boxes[gi]?).ks);
    target = if ln.right > 0.0 { ln.right } else { line_left + w_col };
    last = boxes[gj - 1]?;
    natural = last.rect.x + last.rect.w;
    slack = target - natural;
    gc = 0;
    for k in gi..gj { if box_is_glue(d, boxes[k]?) { gc += 1; } }
    if (slack > 0.0) && (gc > 0) && (justify_last || (!ln.para_end)) {
      add = slack / (gc as float) ?? 0.0;   // gc > 0 here, so never null; the ?? just discharges float?
      shift = 0.0;
      for k in gi..gj {
        b = boxes[k]?;
        is_glue = box_is_glue(d, b);
        neww = if is_glue { b.rect.w + add } else { b.rect.w };
        out[k] = Box { rect: Rect { x: b.rect.x + shift, y: b.rect.y, w: neww, h: b.rect.h }, ks: b.ks, ke: b.ke, style: b.style, hyphen: b.hyphen };
        if is_glue { shift += add; }
      }
    }
    gi = gj;
  }
  return Layout { boxes: out, lines: layout.lines };
}

// ===================================================================================
// M5 — performance / strain.   Notation §8.   Invariant I7 (bounded work ⟂ |D|).
// ===================================================================================

// -------------------- S5.3 viewport virtualisation --------------------

// Materialise only the pages overlapping the vertical window [y_top, y_bottom) — the boxes a
// viewport (plus its page margin) actually shows. Each page is re-laid from its π entry, so
// the materialised box count tracks the VIEWPORT, not the document (I7): a 10× larger doc
// with the same viewport materialises the same boxes.
pub fn materialise_viewport(d: Doc, pages: vector<Page>, page_h: float, y_top: float, y_bottom: float, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, w_col: float) -> Layout {
  boxes: vector<Box> = [];
  lines: vector<LaidLine> = [];
  for i in 0..len(pages) {
    p = pages[i]?;
    p_top = p.top_y;
    p_bot = p.top_y + page_h;
    if (p_top < y_bottom) && (p_bot > y_top) {
      pg = layout_page(d, p.first_char, p.top_y, page_h, resolve, measure, w_col);
      for bi in 0..len(pg.boxes) { boxes += [ pg.boxes[bi]? ]; }
      for li in 0..len(pg.lines) { lines += [ pg.lines[li]? ]; }
    }
  }
  return Layout { boxes: boxes, lines: lines };
}

// -------------------- S5.4 incremental compaction (probe P3) --------------------

// How many pieces the document is fragmented into.  Edits split pieces, so this
// rises with editing and `compact_step` is what brings it back down — which makes it
// the measure to watch when deciding whether compaction is worth running.
pub fn piece_count(d: Doc) -> integer { return len(d.pieces); }

// Coalesce up to `budget` adjacent piece pairs that reference a CONTIGUOUS buffer span with
// the SAME style (edits fragment the piece vector; those fragments often become mergeable
// again). Bounded per call (≤ budget merges) so it is interruptible — run it repeatedly in
// idle time. ⌊D⌋ is unchanged (a pure storage-layer coalesce), which is probe P3.
pub fn compact_step(d: Doc, budget: integer) -> Doc {
  np: vector<Piece> = [];
  merges = 0;
  for i in 0..len(d.pieces) {
    p = d.pieces[i]?;
    merged = false;
    if (len(np) > 0) && (merges < budget) {
      li = len(np) - 1;
      last = np[li]?;
      if ((last.start + last.count) == p.start) && (last.style == p.style) {
        np[li] = Piece { start: last.start, count: last.count + p.count, style: last.style };
        merges += 1;
        merged = true;
      }
    }
    if !merged { np += [ p ]; }
  }
  return Doc { buf: d.buf, pieces: np };
}

// ===================================================================================
// Document page surface — multi-column page flow.   Composes M1–M4 into a real page.
// ===================================================================================

// Page geometry: the physical page, its margins, and its column grid. Column width is
// derived so `columns` equal columns plus `gutter` gaps exactly fill the content box.
pub struct PageGeom {
  pub width: float, pub height: float,
  pub margin_left: float, pub margin_right: float, pub margin_top: float, pub margin_bottom: float,
  pub columns: integer, pub gutter: float,
  pub para_gap: float = 0.0,   // extra vertical space after each paragraph
}

// the width of one column: (content width − the inter-column gutters) / columns.
pub fn column_width(g: PageGeom) -> float {
  content = g.width - g.margin_left - g.margin_right;
  gaps = ((g.columns - 1) as float) * g.gutter;
  return (content - gaps) / (g.columns as float) ?? content;
}
// the usable height of a column (page height minus top/bottom margins).
pub fn column_height(g: PageGeom) -> float { return g.height - g.margin_top - g.margin_bottom; }

// Lay text out over a multi-column page grid in ABSOLUTE canvas coordinates (pages stacked
// downward, columns side by side within a page). Lines are broken at the column width and
// stacked down a column; when a line would overflow the column, flow moves to the next
// column, then to column 0 of the next page. Reuses next_line + place_line, so styles,
// per-run boxes, and discretionary hyphenation all carry over unchanged; the produced boxes
// feed Sel / hit-test / render directly. (Incremental multi-column reflow is a follow-on;
// an edit currently re-runs this full flow, which is fine at document scale.)
pub fn layout_columns(d: Doc, g: PageGeom, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> Layout {
  toks = seg(d);
  cw = column_width(g);
  ch = column_height(g);
  boxes: vector<Box> = [];
  lines: vector<LaidLine> = [];
  page = 0;
  col = 0;
  y_local = 0.0;
  cur = 0;
  while cur < len(toks) {
    nl = next_line(d, toks, cur, cw, resolve, measure);
    last_excl = nl.0;
    next_tok = nl.1;
    para = nl.3;
    if last_excl > cur {
      pe = para || (next_tok >= len(toks));
      pl = place_line(d, toks, cur, last_excl, nl.2, pe, 0.0, resolve, measure);   // column-local y = 0
      lh = pl.line.h;
      // column break: this line overflows the current (non-empty) column
      if (y_local > 0.0) && ((y_local + lh) > ch) {
        col += 1;
        if col >= g.columns { page += 1; col = 0; }
        y_local = 0.0;
      }
      ox = g.margin_left + (col as float) * (cw + g.gutter);
      oy = (page as float) * g.height + g.margin_top + y_local;
      for bi in 0..len(pl.boxes) {
        b = pl.boxes[bi]?;
        boxes += [ Box { rect: Rect { x: b.rect.x + ox, y: b.rect.y + oy, w: b.rect.w, h: b.rect.h }, ks: b.ks, ke: b.ke, style: b.style, hyphen: b.hyphen } ];
      }
      lines += [ LaidLine { cs: pl.line.cs, ce: pl.line.ce, y: pl.line.y + oy, h: lh, para_end: pl.line.para_end, right: ox + cw } ];
      y_local += lh;
      if para { y_local += g.para_gap; }
    } else if para {
      y_local += g.para_gap;
    }
    cur = next_tok;
  }
  return Layout { boxes: boxes, lines: lines };
}

// ===================================================================================
// Fixed images on the page — text steers around exclusion rectangles.
// ===================================================================================

// An in-text (flow) image anchored at content char `at`, with a placement mode and size.
// mode: IMG_LEFT → float left, text wraps right; IMG_FULL → full-width block, text below;
// IMG_RIGHT → float right, text wraps left. Placed at the flow position when reached.
pub struct FlowImage { pub at: integer, pub mode: integer, pub w: float, pub h: float, pub id: integer }
// `FlowImage.mode`: float left, text wraps on the right.
pub const IMG_LEFT = 0;
// `FlowImage.mode`: a full-width block, text resumes below it.
pub const IMG_FULL = 1;
// `FlowImage.mode`: float right, text wraps on the left.
pub const IMG_RIGHT = 2;

// a horizontal interval (internal to the free-span computation).
struct Span { lo: float, hi: float }

fn fmin(a: float, b: float) -> float { if a < b { return a; } return b; }
fn fmax(a: float, b: float) -> float { if a > b { return a; } return b; }

// insertion-sort spans by `lo` into a fresh vector (no in-place swap — loft #338).
fn sort_spans(spans: vector<Span>) -> vector<Span> {
  out: vector<Span> = [];
  for i in 0..len(spans) {
    s = spans[i]?;
    ins: vector<Span> = [];
    placed = false;
    for j in 0..len(out) {
      o = out[j]?;
      if (!placed) && (s.lo < o.lo) { ins += [s]; placed = true; }
      ins += [o];
    }
    if !placed { ins += [s]; }
    out = ins;
  }
  return out;
}

// free_span(left, right, y, h, imgs) → (flo, fhi, blocked): the WIDEST free horizontal interval
// in [left,right] over the vertical band [y, y+h), after removing every image rectangle that
// intersects that band. `blocked` is true when no free width remains (a full-width obstruction
// — the flow must drop below it). This is the one primitive both image kinds route through.
//
// The seam for NON-RECTANGULAR page images (arbitrary shapes — deferred until the page-layout
// designer makes them usable): this function turns exclusions into a per-band covered interval
// [clo,chi]. A shape only has to report ITS covered interval at (y,h) instead of a rectangle's;
// the flow above never changes. So "shapes not just rects" is a change to the coverage query,
// not to the layout.
pub fn free_span(left: float, right: float, y: float, h: float, imgs: vector<PlacedImage>) -> (float, float, boolean) {
  covs: vector<Span> = [];
  for i in 0..len(imgs) {
    r = imgs[i]?;
    if (r.y < (y + h)) && ((r.y + r.h) > y) {            // rectangle intersects the vertical band
      clo = fmax(left, r.x);
      chi = fmin(right, r.x + r.w);
      if chi > clo { covs += [ Span { lo: clo, hi: chi } ]; }
    }
  }
  if len(covs) == 0 { return (left, right, false); }
  sorted_c = sort_spans(covs);
  best_lo = left;
  best_hi = left;
  best_w = 0.0;
  cursor = left;
  for i in 0..len(sorted_c) {
    c = sorted_c[i]?;
    if c.lo > cursor {                                    // a free gap [cursor, c.lo)
      if (c.lo - cursor) > best_w { best_w = c.lo - cursor; best_lo = cursor; best_hi = c.lo; }
    }
    if c.hi > cursor { cursor = c.hi; }
  }
  if (right - cursor) > best_w { best_w = right - cursor; best_lo = cursor; best_hi = right; }
  if best_w <= 0.0 { return (0.0, 0.0, true); }
  return (best_lo, best_hi, false);
}

// the y to drop to when a band is fully blocked: the nearest bottom edge of an obstruction
// intersecting [y, y+h) (so at least one rectangle ends there, opening space). Always > y.
pub fn band_bottom(imgs: vector<PlacedImage>, y: float, h: float, left: float, right: float) -> float {
  best = y + h;
  found = false;
  for i in 0..len(imgs) {
    r = imgs[i]?;
    if (r.y < (y + h)) && ((r.y + r.h) > y) && (r.x < right) && ((r.x + r.w) > left) {
      b = r.y + r.h;
      if (!found) || (b < best) { best = b; found = true; }
    }
  }
  if best <= y { best = y + h; }
  return best;
}

// Lay text into a single flow region [left,right] starting at top y0, steering around images.
// `page_imgs` are FIXED exclusion rectangles (page-layout images). `flow_imgs` are IN-TEXT
// images anchored at a content char, placed when the flow reaches them: IMG_FULL blocks the
// width (text resumes below), IMG_LEFT/IMG_RIGHT float to a side (text wraps in the free gap).
// Each line's width comes from free_span at its band, so text never overlaps an image; a
// fully-blocked band drops below the obstruction. `line_h` is the nominal line height used for
// the band test (exact for a uniform surface). Returns the text boxes + the placed images.
pub fn layout_flow_images(d: Doc, left: float, right: float, y0: float, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float, para_gap: float, line_h: float, page_imgs: vector<PlacedImage>, flow_imgs: vector<FlowImage>) -> Layout {
  toks = seg(d);
  boxes: vector<Box> = [];
  lines: vector<LaidLine> = [];
  placed: vector<PlacedImage> = [];
  excl: vector<PlacedImage> = [];
  for pi in 0..len(page_imgs) { excl += [ page_imgs[pi]? ]; placed += [ page_imgs[pi]? ]; }
  ci = 0;
  y = y0;
  cur = 0;
  while cur < len(toks) {
    // place any in-text images whose anchor the flow has reached (at ≤ this line's start char)
    lsc = (toks[cur]?).ts;
    while (ci < len(flow_imgs)) && ((flow_imgs[ci]?).at <= lsc) {
      img = flow_imgs[ci]?;
      pim = PlacedImage { x: left, y: y, w: img.w, h: img.h, id: img.id };
      if img.mode == IMG_FULL {
        pim = PlacedImage { x: left, y: y, w: right - left, h: img.h, id: img.id };
        placed += [ pim ];
        excl += [ pim ];
        y += img.h;                                       // full block — text resumes below
      } else if img.mode == IMG_RIGHT {
        pim = PlacedImage { x: right - img.w, y: y, w: img.w, h: img.h, id: img.id };
        placed += [ pim ];
        excl += [ pim ];
      } else {                                            // IMG_LEFT
        placed += [ pim ];
        excl += [ pim ];
      }
      ci += 1;
    }
    fs = free_span(left, right, y, line_h, excl);
    if fs.2 {
      y = band_bottom(excl, y, line_h, left, right);       // blocked band → drop below it
    } else {
      flo = fs.0;
      line_w = fs.1 - fs.0;
      nl = next_line(d, toks, cur, line_w, resolve, measure);
      last_excl = nl.0;
      next_tok = nl.1;
      para = nl.3;
      if last_excl > cur {
        pe = para || (next_tok >= len(toks));
        pl = place_line(d, toks, cur, last_excl, nl.2, pe, 0.0, resolve, measure);   // local x from 0
        for bi in 0..len(pl.boxes) {
          b = pl.boxes[bi]?;
          boxes += [ Box { rect: Rect { x: b.rect.x + flo, y: b.rect.y + y, w: b.rect.w, h: b.rect.h }, ks: b.ks, ke: b.ke, style: b.style, hyphen: b.hyphen } ];
        }
        lines += [ LaidLine { cs: pl.line.cs, ce: pl.line.ce, y: y, h: pl.line.h, para_end: pl.line.para_end, right: fs.1 } ];
        y += pl.line.h;
        if para { y += para_gap; }
      } else if para {
        y += para_gap;
      }
      cur = next_tok;
    }
  }
  return Layout { boxes: boxes, lines: lines, images: placed };
}

// The full page: multi-column flow (paragraphs + hyphenation) that steers around BOTH image
// kinds. Page images are fixed absolute rectangles (each column avoids the ones over its
// x-range); in-text images are placed in whichever column the flow reaches their anchor in
// (IMG_LEFT/RIGHT float to that column's side, IMG_FULL blocks the column, breaking to the next
// column if it will not fit). The nominal line height is run_height(resolve(0)) — exact for a
// uniform surface. With no images this is identical to layout_columns; that is asserted.
pub fn layout_page_columns(d: Doc, g: PageGeom, page_imgs: vector<PlacedImage>, flow_imgs: vector<FlowImage>, resolve: fn(integer) -> Style, measure: fn(text, Style) -> float) -> Layout {
  toks = seg(d);
  cw = column_width(g);
  ch = column_height(g);
  line_h = run_height(resolve(0));
  boxes: vector<Box> = [];
  lines: vector<LaidLine> = [];
  placed: vector<PlacedImage> = [];
  excl: vector<PlacedImage> = [];
  for pgi in 0..len(page_imgs) { excl += [ page_imgs[pgi]? ]; placed += [ page_imgs[pgi]? ]; }
  page = 0;
  col = 0;
  y_local = 0.0;
  cur = 0;
  ci = 0;
  while cur < len(toks) {
    col_x = g.margin_left + (col as float) * (cw + g.gutter);
    col_right = col_x + cw;
    region_top = (page as float) * g.height + g.margin_top;
    // place any in-text images the flow has reached, in the current column
    lsc = (toks[cur]?).ts;
    while (ci < len(flow_imgs)) && ((flow_imgs[ci]?).at <= lsc) {
      img = flow_imgs[ci]?;
      if (img.mode == IMG_FULL) && (y_local > 0.0) && ((y_local + img.h) > ch) {
        col += 1;
        if col >= g.columns { page += 1; col = 0; }
        y_local = 0.0;
        col_x = g.margin_left + (col as float) * (cw + g.gutter);
        col_right = col_x + cw;
        region_top = (page as float) * g.height + g.margin_top;
      }
      aby = region_top + y_local;
      if img.mode == IMG_FULL {
        pim = PlacedImage { x: col_x, y: aby, w: cw, h: img.h, id: img.id };
        placed += [ pim ]; excl += [ pim ]; y_local += img.h;
      } else if img.mode == IMG_RIGHT {
        pim = PlacedImage { x: col_right - img.w, y: aby, w: img.w, h: img.h, id: img.id };
        placed += [ pim ]; excl += [ pim ];
      } else {
        pim = PlacedImage { x: col_x, y: aby, w: img.w, h: img.h, id: img.id };
        placed += [ pim ]; excl += [ pim ];
      }
      ci += 1;
    }
    // column break for the next line
    if (y_local > 0.0) && ((y_local + line_h) > ch) {
      col += 1;
      if col >= g.columns { page += 1; col = 0; }
      y_local = 0.0;
      col_x = g.margin_left + (col as float) * (cw + g.gutter);
      col_right = col_x + cw;
      region_top = (page as float) * g.height + g.margin_top;
    }
    aby2 = region_top + y_local;
    fs = free_span(col_x, col_right, aby2, line_h, excl);
    if fs.2 {
      y_local = band_bottom(excl, aby2, line_h, col_x, col_right) - region_top;   // drop below obstruction
    } else {
      flo = fs.0;
      line_w = fs.1 - fs.0;
      nl = next_line(d, toks, cur, line_w, resolve, measure);
      last_excl = nl.0;
      next_tok = nl.1;
      para = nl.3;
      if last_excl > cur {
        pe = para || (next_tok >= len(toks));
        pl = place_line(d, toks, cur, last_excl, nl.2, pe, 0.0, resolve, measure);
        for bi in 0..len(pl.boxes) {
          b = pl.boxes[bi]?;
          boxes += [ Box { rect: Rect { x: b.rect.x + flo, y: b.rect.y + aby2, w: b.rect.w, h: b.rect.h }, ks: b.ks, ke: b.ke, style: b.style, hyphen: b.hyphen } ];
        }
        lines += [ LaidLine { cs: pl.line.cs, ce: pl.line.ce, y: aby2, h: pl.line.h, para_end: pl.line.para_end, right: fs.1 } ];
        y_local += pl.line.h;
        if para { y_local += g.para_gap; }
      } else if para {
        y_local += g.para_gap;
      }
      cur = next_tok;
    }
  }
  return Layout { boxes: boxes, lines: lines, images: placed };
}

tests/worked-examples.loft

// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `zttext` library (@PLN141).  Each `test_*` below is a
// REAL, run-every-CI demonstration of a contract the signature cannot state; the
// function documents itself by CITING the tag with `// Example: @ZTX-00N`, and
// `check_doc_drift.sh examples` fails if a citation goes dangling (no test carries
// the tag).  These are teaching examples, distinct from the per-function assertion
// suite in `zttext.loft`.
//
// zttext is an ENGINE, so what needs demonstrating is not any single call but the
// SHAPE of a correct one: an edit is a value you must keep, an offset is measured
// in the unit the whole engine agrees on, undo is computed against the state
// BEFORE the edit, and layout only works because measurement is handed in.
use zttext::*;

// @ZTX-001 — every edit RETURNS a new Doc and leaves the old one alone.
// `insert_text`, `delete_range` and `set_style` are pure functions over an
// immutable piece sequence, not mutators: the `Doc` you passed in is exactly as it
// was afterwards.  So `insert_text(d, 0, "x", 0);` as a statement compiles, runs,
// allocates the whole new document — and throws it away.  Nothing warns, because
// discarding a return value is legal, and the symptom is an editor where typing
// does nothing.  Bind the result, every time.  What you get for that discipline is
// the reason the engine is built this way: the previous state is still a live
// value, so undo, history and speculative edits are just variables you kept.
fn test_an_edit_is_a_value_you_have_to_keep() {
  base = doc_from_text("hello", 0);
  extended = insert_text(base, 5, " world", 0);

  assert(materialise(extended) == "hello world", "the RETURNED doc has the edit");
  assert(materialise(base) == "hello", "and the one passed in is untouched");
  assert(doc_len(base) == 5 && doc_len(extended) == 11, "two live documents, both valid");

  // The shape that silently does nothing: the result is never bound.
  insert_text(base, 0, "XYZ", 0);
  assert(materialise(base) == "hello",
         "a discarded edit is simply lost — no error, no warning, no change");

  // Because the old state is still a value, keeping history costs nothing extra.
  history = [base, extended];
  assert(materialise(history[0] ?? empty_doc()) == "hello", "the earlier state is still there");
}

// @ZTX-002 — every offset in this engine is a CHARACTER position, never a byte,
// and the buffer behind it is APPEND-ONLY.  The first half is a deliberate design
// choice (the buffer is a `vector<character>`), and it is what makes an offset
// safe on text loft would otherwise let you cut in half: `doc_len` counts
// characters even when the text needs more bytes than that, and a range lands on
// character boundaries by construction.  The second half is why a delete looks
// like it leaked: removing a range rewrites the PIECE SEQUENCE and never touches
// the buffer, so `doc_len` drops while the buffer stays exactly as long.  That is
// not a leak, it is undo: the deleted characters are still there to be restored.
fn test_offsets_are_characters_and_the_buffer_only_grows() {
  d = doc_from_text("héllo wörld", 0);
  assert(doc_len(d) == 11, "eleven CHARACTERS");
  assert(size(materialise(d)) == 13, "thirteen bytes — two characters need two each");

  // A character range cuts where you meant, including across a multi-byte char.
  cut = delete_range(d, 0, 2);
  assert(materialise(cut) == "llo wörld", "offsets 0..2 removed 'h' and 'é', not 'h' and half of 'é'");
  assert(doc_len(cut) == 9, "nine characters left");

  // The buffer did not shrink — the deleted characters are still available.
  assert(len(cut.buf) == len(d.buf), "the append-only buffer is unchanged by a delete");
  assert(len(d.buf) == 11, "it holds every character ever inserted");
}

// @ZTX-003 — undo is computed against the state BEFORE the edit, and it restores
// styles, not just text.  `invert(pre, txn)` needs the pre-edit document because
// the inverse of a deletion is an insertion of what was there — and "what was
// there" only exists in the state before.  Handing it the post-state instead
// compiles perfectly and produces a transaction that reinserts the wrong thing.
// The payoff is the engine's central invariant: applying a transaction and then
// its inverse returns a document equal to the original in text AND in per-character
// style, which is what makes undo of a formatting change work at all.
fn test_undo_is_inverted_against_the_state_before_the_edit() {
  base = doc_from_text("hello world", 1);
  styled = set_style(base, 0, 5, 7);          // "hello" now carries style 7
  assert("{char_styles(styled)}" == "[7,7,7,7,7,1,1,1,1,1,1]", "five styled, six not");

  edit = txn_of([ Del { from: 0, to: 5 } ]);
  after = apply(styled, edit);
  assert(materialise(after) == " world", "the edit removed the styled run");

  // The inverse is taken against `styled` — the PRE state — not against `after`.
  undo = invert(styled, edit);
  restored = apply(after, undo);

  assert(materialise(restored) == materialise(styled), "text is back");
  assert("{char_styles(restored)}" == "{char_styles(styled)}",
         "and so are the per-character styles — undo of a format change works");
}

// @ZTX-004 — layout takes MEASUREMENT as an argument, and that is the whole
// portability story.  `flow_layout` and friends ask for a `resolve` (style id →
// Style) and a `measure` (text + Style → width) rather than calling a font engine,
// so the same document laid out with the same ports produces the same boxes on the
// interpreter, on `--native`, in wasm and in a browser — the parity invariant is a
// consequence of the signature, not a promise.  It also means a caller MUST supply
// both: `default_resolver` and `mono_measure` are the deterministic pair to test
// against, and swapping in a real font measurer changes the layout without changing
// a line of engine code.
fn test_layout_is_portable_because_measurement_is_an_argument() {
  d = doc_from_text("alpha beta gamma delta", 0);

  // mono_measure is width = characters * size, so at column width 12 the greedy
  // line breaker fits "alpha beta" (10) but not "alpha beta gamma".
  boxes = flow_layout(d, default_resolver, mono_measure, 12.0);
  assert(len(boxes.boxes) == 6, "six boxes — one per word plus the spaces between");
  assert(boxset_height(boxes) == 2.0, "two lines at the default size of 1.0");

  lines = line_assignment(d, default_resolver, mono_measure, 12.0);
  assert(len(lines) == 2, "and the line assignment agrees");

  // A different measurer is a different layout, with no change to the document.
  wide = flow_layout(d, default_resolver, mono_measure, 40.0);
  assert(boxset_height(wide) == 1.0, "a wider column fits it all on one line");
  assert(materialise(d) == "alpha beta gamma delta", "the document itself never moved");
}

tests/zttext.loft

// Tests for zttext — the text engine. Run: `loft test` (interpret) and `loft test --native`.
// Each test asserts a concrete invariant from doc/ZTTEXT_NOTATION.md (I1–I9); the parity
// gate (interpret == native == native-wasm) is the definition of done per step.
use zttext::*;

// ============================ reference model (test-only) ============================
// A deliberately naive, obviously-correct model — a flat vector<character> for the text
// and a flat vector<integer> for per-character styles, spliced with ordinary vector ops.
// M0's job is to prove the piece store agrees with THIS after every edit.

fn ref_chars(s: text) -> vector<character> {
  out: vector<character> = [];
  for c in s { out += [c]; }
  return out;
}
fn chars_to_text(cs: vector<character>) -> text {
  out: text = "";
  for i in 0..len(cs) { ch = cs[i]?; out += "{ch}"; }
  return out;
}
fn ref_ins(cs: vector<character>, a: integer, s: text) -> vector<character> {
  out: vector<character> = [];
  left = cs[0..a];
  right = cs[a..len(cs)];
  out += left;
  for c2 in s { out += [c2]; }
  out += right;
  return out;
}
fn ref_del(cs: vector<character>, a: integer, b: integer) -> vector<character> {
  out: vector<character> = [];
  left = cs[0..a];
  right = cs[b..len(cs)];
  out += left;
  out += right;
  return out;
}
fn ref_ins_sty(st: vector<integer>, a: integer, n: integer, style: integer) -> vector<integer> {
  out: vector<integer> = [];
  left = st[0..a];
  right = st[a..len(st)];
  out += left;
  for _k in 0..n { out += [style]; }
  out += right;
  return out;
}
fn ref_del_sty(st: vector<integer>, a: integer, b: integer) -> vector<integer> {
  out: vector<integer> = [];
  left = st[0..a];
  right = st[b..len(st)];
  out += left;
  out += right;
  return out;
}
fn ref_set_sty(st: vector<integer>, a: integer, b: integer, style: integer) -> vector<integer> {
  out: vector<integer> = [];
  for k in 0..len(st) {
    if (k >= a) && (k < b) { out += [style]; } else { out += [st[k]?]; }
  }
  return out;
}
fn ints_eq(a: vector<integer>, b: vector<integer>) -> boolean {
  if len(a) != len(b) { return false; }
  for i in 0..len(a) { if (a[i]?) != (b[i]?) { return false; } }
  return true;
}
// assert doc == reference (text AND per-character styles) — the piece-blind check (I2).
fn check(d: Doc, rc: vector<character>, rs: vector<integer>, label: text) {
  assert(materialise(d) == chars_to_text(rc), "{label}: text matches reference");
  assert(ints_eq(char_styles(d), rs), "{label}: per-char styles match reference");
}
// a named StyleResolver used as a port value (id 1 → size 2.0, else default).
fn demo_resolver(id: integer) -> Style {
  if id == 1 { return Style { size: 2.0, weight: 0, italic: false, dir: 0 }; }
  return default_style();
}
// a resolver where style 1 is RTL (dir 1) but the SAME width (size 1) — isolates BiDi
// reorder from width changes so the hand-computed x-positions stay simple.
fn rtl_resolver(id: integer) -> Style {
  if id == 1 { return Style { size: 1.0, weight: 0, italic: false, dir: 1 }; }
  return default_style();
}
// a fn that consumes a Measurer PORT — proves μ is passable as a first-class value.
fn measure_via(m: fn(text, Style) -> float, s: text, st: Style) -> float { return m(s, st); }

// a mutable work counter for the strain suite (a heap struct a counting-measurer closure
// captures and increments — so μ-calls are tallied across the layout/patch call boundary).
struct Counter { n: integer }

// build a base of `n` fixed-width words ("aaaa "), so line boundaries fall at predictable
// char positions (multiples of the per-line char count) regardless of `n`.
fn repeat_word(n: integer) -> text {
  base: text = "";
  for _k in 0..n { base += "aaaa "; }
  return base;
}

// ============================ S0.1 scaffold ============================
fn test_scaffold() {
  assert(zttext_version() == "0.1.3", "zttext_version answers the manifest's version");
}

// ============================ S0.2 materialise + length ============================
fn test_materialise() {
  d = doc_from_text("hello world", 0);
  assert(materialise(d) == "hello world", "materialise a single piece");
  assert(doc_len(d) == 11, "doc_len = 11 characters");
  d2 = insert_text(d, 5, ",", 0);
  assert(materialise(d2) == "hello, world", "2-piece materialise stitches spans in order");
  assert(len(d2.pieces) == 3, "insert mid-piece splits into 3 pieces (left · new · right)");
  // multi-byte: offsets are CHARACTERS, not bytes (the @PLN110 trap, designed out)
  dm = doc_from_text("héllo", 0);
  assert(doc_len(dm) == 5, "doc_len counts CHARACTERS (héllo = 5, not 6 bytes)");
  dm2 = insert_text(dm, 2, "X", 0);
  assert(materialise(dm2) == "héXllo", "insert at char offset 2 lands after 'é', not mid-byte");
}

// ============================ S0.3 locate + inverse ============================
fn test_locate() {
  d = doc_from_text("abcdef", 0);
  d = insert_text(d, 3, "XY", 0);   // "abcXYdef", pieces [abc][XY][def]
  l0 = locate(d, 0); assert((l0.0 == 0) && (l0.1 == 0), "locate 0 → (0,0)");
  l3 = locate(d, 3); assert((l3.0 == 1) && (l3.1 == 0), "locate 3 → boundary lands at (piece 1, 0)");
  l4 = locate(d, 4); assert((l4.0 == 1) && (l4.1 == 1), "locate 4 → (piece 1, 1)");
  l5 = locate(d, 5); assert((l5.0 == 2) && (l5.1 == 0), "locate 5 → (piece 2, 0)");
  le = locate(d, 8); assert((le.0 == 3) && (le.1 == 0), "locate end → (n_pieces, 0) sentinel");
  assert(locate_inverse(d, l4.0, l4.1) == 4, "locate_inverse round-trips a mid offset");
  assert(locate_inverse(d, le.0, le.1) == 8, "locate_inverse round-trips the end");
}

// ============================ S0.4/S0.5/S0.5b edits vs reference (I2) ============================
fn test_edit_reference() {
  d = doc_from_text("the quick brown fox", 0);
  rc = ref_chars("the quick brown fox");
  rs: vector<integer> = [];
  for _z in 0..19 { rs += [0]; }
  check(d, rc, rs, "start");

  // insert empty = no-op
  d = insert_text(d, 5, "", 0);
  check(d, rc, rs, "insert-empty");

  // insert mid, styled
  d = insert_text(d, 4, "very ", 2);   rc = ref_ins(rc, 4, "very ");   rs = ref_ins_sty(rs, 4, 5, 2);
  check(d, rc, rs, "insert-mid-styled");

  // insert at 0
  d = insert_text(d, 0, ">> ", 1);     rc = ref_ins(rc, 0, ">> ");     rs = ref_ins_sty(rs, 0, 3, 1);
  check(d, rc, rs, "insert-start");

  // insert at end
  e = doc_len(d);
  d = insert_text(d, e, " <<", 1);     rc = ref_ins(rc, e, " <<");     rs = ref_ins_sty(rs, e, 3, 1);
  check(d, rc, rs, "insert-end");

  // delete within one piece
  d = delete_range(d, 3, 6);      rc = ref_del(rc, 3, 6);         rs = ref_del_sty(rs, 3, 6);
  check(d, rc, rs, "delete-within");

  // delete across piece boundaries
  d = delete_range(d, 2, 12);     rc = ref_del(rc, 2, 12);        rs = ref_del_sty(rs, 2, 12);
  check(d, rc, rs, "delete-across");

  // set style within a piece
  d = set_style(d, 1, 4, 5);      rs = ref_set_sty(rs, 1, 4, 5);
  check(d, rc, rs, "setstyle-within");

  // set style across boundaries
  d = set_style(d, 0, doc_len(d), 7);   rs = ref_set_sty(rs, 0, len(rs), 7);
  check(d, rc, rs, "setstyle-all");

  // delete the whole document
  d = delete_range(d, 0, doc_len(d));   rc = ref_del(rc, 0, len(rc)); rs = ref_del_sty(rs, 0, len(rs));
  assert(materialise(d) == "", "delete-whole → empty text");
  assert(doc_len(d) == 0, "delete-whole → length 0");
}

// ============================ S0.6 transactions ============================
fn test_transactions() {
  d = doc_from_text("abcdef", 0);
  ops: vector<Op> = [];
  ops += [ Ins { at: 3, str: "12", style: 0 } ];   // "abc12def"
  ops += [ Ins { at: 0, str: ">", style: 0 } ];    // ">abc12def"
  ops += [ Del { from: 4, to: 6 } ];               // removes "12" → ">abcdef"
  t = txn_of(ops);
  d1 = apply(d, t);
  // sequential-ops equivalence: apply op-by-op must equal the transaction
  step = d;
  for i in 0..len(ops) { step = apply_op(step, ops[i]?); }
  assert(materialise(d1) == materialise(step), "τ equals the sequential composition of its ops");
  assert(materialise(d1) == ">abcdef", "txn [ins,ins,del] result is exactly >abcdef");
}

// ============================ S0.7 invert / undo (I1) ============================
fn test_undo() {
  d0 = doc_from_text("hello world", 0);
  d0 = set_style(d0, 0, 5, 1);   // "hello" style 1, " world" style 0 — a multi-style base
  ops: vector<Op> = [];
  ops += [ Ins { at: 5, str: " BIG", style: 2 } ];
  ops += [ Del { from: 0, to: 3 } ];
  ops += [ Sty { from: 0, to: 4, style: 3 } ];
  t = txn_of(ops);
  d1 = apply(d0, t);
  tinv = invert(d0, t);
  d2 = apply(d1, tinv);
  assert(materialise(d2) == materialise(d0), "τ⁻¹(τ(D)) restores the text (I1)");
  assert(ints_eq(char_styles(d2), char_styles(d0)), "τ⁻¹(τ(D)) restores per-char styles (I1)");

  // each op-type inverts on its own
  da = doc_from_text("abcdef", 0);
  oi: vector<Op> = []; oi += [ Ins { at: 2, str: "ZZ", style: 5 } ];
  ti = txn_of(oi);
  assert(materialise(apply(apply(da, ti), invert(da, ti))) == "abcdef", "single Ins undo");
  od: vector<Op> = []; od += [ Del { from: 1, to: 4 } ];
  td = txn_of(od);
  d_del = apply(da, td);
  d_back = apply(d_del, invert(da, td));
  assert(materialise(d_back) == "abcdef", "single Del undo restores deleted text");
  assert(ints_eq(char_styles(d_back), char_styles(da)), "single Del undo restores styles");
}

// ============================ S0.8 measurer + style + resolver ports ============================
fn test_measurer() {
  st = default_style();
  assert(mono_measure("abc", st) == 3.0, "mono: 3 chars × 1.0 = 3.0");
  big = Style { size: 2.5, weight: 1, italic: false, dir: 0 };
  assert(mono_measure("hello", big) == 12.5, "mono styled: 5 × 2.5 = 12.5");
  // StyleResolver used as a port value
  s1 = demo_resolver(1);
  assert(mono_measure("ab", s1) == 4.0, "resolved style 1 (size 2.0): 2 chars = 4.0");
  s0 = demo_resolver(0);
  assert(mono_measure("ab", s0) == 2.0, "resolved style 0 (default): 2 chars = 2.0");
  // Measurer passed as a first-class port value
  assert(measure_via(mono_measure, "wxyz", st) == 4.0, "μ is passable as a first-class port");
  println("lib: zttext — M0 content core OK (materialise · locate · edits · txn · undo · measurer)");
}

// ============================ S1.2 segmentation ============================
fn test_seg() {
  toks = seg(doc_from_text("hello world", 0));
  assert(len(toks) == 3, "hello world → 3 tokens [W,G,W]");
  t0 = toks[0]?; assert(t0.word && (t0.ts == 0) && (t0.te == 5), "token0 = word 'hello' [0,5)");
  t1 = toks[1]?; assert(!t1.word && (t1.ts == 5) && (t1.te == 6), "token1 = glue ' ' [5,6)");
  t2 = toks[2]?; assert(t2.word && (t2.ts == 6) && (t2.te == 11), "token2 = word 'world' [6,11)");
  // a multi-space run is ONE glue token
  toks2 = seg(doc_from_text("a  b", 0));
  assert(len(toks2) == 3, "a  b → 3 tokens (double space = one glue)");
  g = toks2[1]?; assert(!g.word && (g.ts == 1) && (g.te == 3), "the glue spans both spaces [1,3)");
}

// ============================ S1.3 token width ============================
fn test_token_width() {
  d = doc_from_text("hello world", 0);
  toks = seg(d);
  assert(token_width(d, toks[0]?, default_resolver, mono_measure) == 5.0, "'hello' = 5.0 (monospace)");
  assert(token_width(d, toks[1]?, default_resolver, mono_measure) == 1.0, "' ' = 1.0");
}

// ============================ S1.4 greedy line breaking ============================
fn test_lines_greedy() {
  d = doc_from_text("aa bb cc", 0);   // tokens: aa=0 G=1 bb=2 G=3 cc=4
  toks = seg(d);
  lns = lines_greedy(d, toks, 5.0, default_resolver, mono_measure);
  assert(len(lns) == 2, "at W=5, 'aa bb cc' breaks into 2 lines");
  l0 = lns[0]?; assert((l0.first == 0) && (l0.last_excl == 3), "line0 = tokens [0,3) (aa · glue · bb)");
  l1 = lns[1]?; assert((l1.first == 4) && (l1.last_excl == 5), "line1 = tokens [4,5) (cc); break glue dropped");
  // everything fits on one line at a wide column
  lns_wide = lines_greedy(d, toks, 100.0, default_resolver, mono_measure);
  assert(len(lns_wide) == 1, "at W=100 it is a single line");
}

// ============================ S1.5 flow layout → exact BoxSet (I3) ============================
fn test_flow_layout_exact() {
  d = doc_from_text("hello world", 0);
  bs = flow_layout(d, default_resolver, mono_measure, 100.0);
  assert(len(bs.boxes) == 3, "single line → 3 boxes (hello · space · world)");
  b0 = bs.boxes[0]?;
  assert((b0.rect.x == 0.0) && (b0.rect.y == 0.0) && (b0.rect.w == 5.0) && (b0.rect.h == 1.0), "box0 rect exact");
  assert((b0.ks == 0) && (b0.ke == 5), "box0 κ = [0,5) 'hello'");
  b1 = bs.boxes[1]?;
  assert((b1.rect.x == 5.0) && (b1.rect.w == 1.0) && (b1.ks == 5) && (b1.ke == 6), "box1 space at x=5");
  b2 = bs.boxes[2]?;
  assert((b2.rect.x == 6.0) && (b2.rect.w == 5.0) && (b2.ks == 6) && (b2.ke == 11), "box2 world at x=6");
  // concatenating box content-refs in order reconstructs the source (single line, no drop)
  recon = "";
  for i in 0..len(bs.boxes) { bx = bs.boxes[i]?; recon += materialise_range(d, bx.ks, bx.ke); }
  assert(recon == "hello world", "box content-refs reconstruct the source (single line)");

  // wrapping: "aa bb cc" at W=5 → 4 boxes; cc drops to line 1
  bs2 = flow_layout(doc_from_text("aa bb cc", 0), default_resolver, mono_measure, 5.0);
  assert(len(bs2.boxes) == 4, "wrap → 4 boxes (aa · glue · bb on line0; cc on line1)");
  bcc = bs2.boxes[3]?;
  assert((bcc.rect.x == 0.0) && (bcc.rect.y == 1.0), "cc wraps to line 1 (y=1, x=0)");
  assert((bcc.ks == 6) && (bcc.ke == 8), "cc κ = [6,8)");
  assert(boxset_height(bs2) == 2.0, "two lines → content height 2.0");
}

// ============================ D1 line assignment (golden + pure) ============================
// The per-line char spans a document breaks into — the determinism layer (§8.22) the screen
// renderer (D2) paints one element per and the PDF emitter (E) paginates from. Golden on a known
// paragraph, and PROVEN pure: identical inputs → an identical assignment, field-for-field.
fn test_line_assignment() {
  d = doc_from_text("aa bb cc", 0);   // at W=5 (mono, size 1): "aa bb" | "cc" (break glue dropped)
  la = line_assignment(d, default_resolver, mono_measure, 5.0);
  assert(len(la) == 2, "2-line assignment at W=5");
  l0 = la[0]?; l1 = la[1]?;
  assert(materialise_range(d, l0.cs, l0.ce) == "aa bb", "line 0 span = 'aa bb'");
  assert(materialise_range(d, l1.cs, l1.ce) == "cc", "line 1 span = 'cc'");
  assert((l0.y == 0.0) && (l1.y == 1.0), "lines stack by height (y = 0, 1)");
  assert(l1.para_end, "last line marks the paragraph end (justify leaves it ragged)");
  assert((l0.right == 5.0) && (l1.right == 5.0), "each line's justify edge is the column width");

  // Pure function: recomputing over the same inputs yields a field-identical assignment.
  la2 = line_assignment(d, default_resolver, mono_measure, 5.0);
  assert(len(la2) == len(la), "pure: same line count");
  for i in 0..len(la) {
    a = la[i]?; b = la2[i]?;
    assert((a.cs == b.cs) && (a.ce == b.ce) && (a.y == b.y) && (a.para_end == b.para_end), "pure: line {i} identical");
  }
  println("lib: zttext — D1 line assignment OK (pure per-line spans: 'aa bb' / 'cc')");
}

// ============================ S1.5b box-per-run (multi-style word) ============================
fn test_flow_layout_runs() {
  d = doc_from_text("hello", 0);
  d = set_style(d, 0, 3, 1);   // "hel" style 1, "lo" style 0 — one word, two style-runs
  bs = flow_layout(d, default_resolver, mono_measure, 100.0);
  assert(len(bs.boxes) == 2, "a word crossing a style boundary → one box per run");
  b0 = bs.boxes[0]?;
  assert((b0.ks == 0) && (b0.ke == 3) && (b0.style == 1) && (b0.rect.x == 0.0) && (b0.rect.w == 3.0), "run 'hel' style 1");
  b1 = bs.boxes[1]?;
  assert((b1.ks == 3) && (b1.ke == 5) && (b1.style == 0) && (b1.rect.x == 3.0) && (b1.rect.w == 2.0), "run 'lo' style 0 at x=3");
}

// ============================ S1.6 render dump (golden) + I2 piece-blind ============================
fn test_render_and_piece_blind() {
  d = doc_from_text("ab cd", 0);
  bs = flow_layout(d, default_resolver, mono_measure, 100.0);
  exp = "[0.0,0.0,2.0,1.0] 0..2 s=0 'ab'\n[2.0,0.0,1.0,1.0] 2..3 s=0 ' '\n[3.0,0.0,2.0,1.0] 3..5 s=0 'cd'\n";
  assert(render_dump(bs, d) == exp, "render_dump matches the golden string");

  // I2 (piece-blind / P1): same styled string, different piece structure ⇒ identical layout.
  a = doc_from_text("hello world", 0);                 // one piece
  b = empty_doc();
  b = insert_text(b, 0, "world", 0);
  b = insert_text(b, 0, "hello ", 0);                   // "hello world" via two inserts → different pieces
  assert(materialise(a) == materialise(b), "the two docs denote the same text");
  da = render_dump(flow_layout(a, default_resolver, mono_measure, 7.0), a);
  db = render_dump(flow_layout(b, default_resolver, mono_measure, 7.0), b);
  assert(da == db, "I2: same styled string ⇒ byte-identical layout, regardless of pieces");
  println("lib: zttext — M1 render OK (seg · width · lines · flow layout · render dump · piece-blind)");
}

// ============================ S2.1/S2.2 hit-test → box → anchor (I4) ============================
fn test_hit_test() {
  d = doc_from_text("hello world", 0);
  bs = flow_layout(d, default_resolver, mono_measure, 100.0);   // [0,5) [5,6) [6,11), all y[0,1)
  assert(box_at(bs, 2.5, 0.5) == 0, "point inside 'hello' → box 0");
  assert(box_at(bs, 5.5, 0.5) == 1, "point inside the space → box 1");
  assert(box_at(bs, 8.0, 0.5) == 2, "point inside 'world' → box 2");
  assert(box_at(bs, 50.0, 0.5) == 0 - 1, "point past the text → no box (-1)");
  assert(anchor_at(bs, d, 0.0, 0.5, default_resolver, mono_measure) == 0, "click at x=0 → anchor 0");
  assert(anchor_at(bs, d, 3.0, 0.5, default_resolver, mono_measure) == 3, "click at x=3 → anchor 3");
  assert(anchor_at(bs, d, 5.0, 0.5, default_resolver, mono_measure) == 5, "click at the space edge → anchor 5");
  assert(anchor_at(bs, d, 50.0, 0.5, default_resolver, mono_measure) == 11, "click far right → anchor at end (11)");
  assert(anchor_at(bs, d, 2.5, 5.0, default_resolver, mono_measure) == 2, "click below the line → snaps to it (anchor 2)");
}

// ============================ S2.3 selection = κ-intersection (I4) ============================
fn test_selection() {
  d = doc_from_text("hello world", 0);
  bs = flow_layout(d, default_resolver, mono_measure, 100.0);
  s1 = sel(bs, 0, 5); assert((len(s1) == 1) && (s1[0]? == 0), "Sel [0,5) → box 0 only");
  s2 = sel(bs, 3, 8); assert(len(s2) == 3, "Sel [3,8) spans all three boxes (κ-intersection)");
  s3 = sel(bs, 6, 11); assert((len(s3) == 1) && (s3[0]? == 2), "Sel [6,11) → box 2 only");
  s4 = sel(bs, 4, 4); assert(len(s4) == 0, "empty range → no boxes");
}

// ============================ S2.4 word / line ============================
fn test_word_line() {
  d = doc_from_text("the quick fox", 0);
  w = word_at(d, 5);   assert((w.0 == 4) && (w.1 == 9), "double-click in 'quick' → word [4,9)");
  w2 = word_at(d, 0);  assert((w2.0 == 0) && (w2.1 == 3), "word at the start → 'the' [0,3)");
  d2 = doc_from_text("aa bb cc", 0);
  bs = flow_layout(d2, default_resolver, mono_measure, 5.0);   // line0: aa·glue·bb ; line1: cc
  lb0 = line_boxes(bs, 0); assert(len(lb0) == 3, "line of anchor 0 → the 3 boxes on visual line 0");
  lb1 = line_boxes(bs, 7); assert(len(lb1) == 1, "line of anchor 7 → the 1 box on visual line 1");
}

// ============================ S2.5 BiDi selection — geometry-free Sel (I4) ============================
fn test_bidi_selection() {
  // A hand-placed BiDi line (visual reorder ψ is proven in S4.5; here Sel's geometry-freedom
  // is the point). Logical: "one" [0,3) LTR · an RTL run [3,7) whose 4 chars are placed
  // RIGHT-TO-LEFT (visual x = 6,5,4,3) · "two" [7,10) LTR.
  bx: vector<Box> = [];
  bx += [ Box { rect: Rect { x: 0.0, y: 0.0, w: 3.0, h: 1.0 }, ks: 0, ke: 3, style: 0 } ];   // "one"  x0
  bx += [ Box { rect: Rect { x: 6.0, y: 0.0, w: 1.0, h: 1.0 }, ks: 3, ke: 4, style: 1 } ];   // RTL[3] x6 (rightmost)
  bx += [ Box { rect: Rect { x: 5.0, y: 0.0, w: 1.0, h: 1.0 }, ks: 4, ke: 5, style: 1 } ];   // RTL[4] x5
  bx += [ Box { rect: Rect { x: 4.0, y: 0.0, w: 1.0, h: 1.0 }, ks: 5, ke: 6, style: 1 } ];   // RTL[5] x4
  bx += [ Box { rect: Rect { x: 3.0, y: 0.0, w: 1.0, h: 1.0 }, ks: 6, ke: 7, style: 1 } ];   // RTL[6] x3 (leftmost)
  bx += [ Box { rect: Rect { x: 7.0, y: 0.0, w: 3.0, h: 1.0 }, ks: 7, ke: 10, style: 0 } ];  // "two" x7
  bs = BoxSet { boxes: bx };

  // logically-contiguous RTL range [3,7): the 4 RTL boxes by κ, though visually reversed
  s_rtl = sel(bs, 3, 7);
  assert(len(s_rtl) == 4, "Sel [3,7) → the 4 RTL boxes by κ (visual x reversed)");
  assert((s_rtl[0]? == 1) && (s_rtl[3]? == 4), "…returned in box-index order 1..4");

  // range crossing the LTR→RTL boundary [2,5): ONE logical range, TWO visual clusters
  s_cross = sel(bs, 2, 5);
  assert(len(s_cross) == 3, "Sel [2,5) → 3 boxes (one LTR + two RTL): two visual clusters, no split code");
  assert((s_cross[0]? == 0) && (s_cross[1]? == 1) && (s_cross[2]? == 2), "…boxes chosen by κ, not by x");
  println("lib: zttext — M2 selection OK (hit-test · anchor · Sel by κ · word/line · BiDi selection)");
}

// the differential: patch(L(d0), d0, d1) must be byte-identical to a full relayout of d1.
// Returns the recomputed-box count so callers can also check bounded work.
fn check_diff(d0: Doc, d1: Doc, w: float, label: text) -> integer {
  l0 = flow_layout_full(d0, default_resolver, mono_measure, w);
  pr = patch(l0, d0, d1, default_resolver, mono_measure, w);
  full = flow_layout_full(d1, default_resolver, mono_measure, w);
  assert(render_dump_layout(pr.layout, d1) == render_dump_layout(full, d1), "{label}: patch == full relayout (I5)");
  return pr.recomputed;
}

// ============================ S3.1/S3.3 incremental == full, diverse edits (I5) ============================
fn test_incremental() {
  base = "alpha beta gamma delta epsilon zeta";   // wraps to 5 lines at W=10
  d = doc_from_text(base, 0);
  w = 10.0;
  // insert mid-word on a later line
  check_diff(d, insert_text(d, 19, "X", 0), w, "insert-mid-word-later-line");
  // delete across a line boundary
  check_diff(d, delete_range(d, 15, 18), w, "delete-across-line-boundary");
  // a block move (delete a word here, insert it there) as one transaction
  mv: vector<Op> = [];
  mv += [ Del { from: 11, to: 17 } ];
  mv += [ Ins { at: 18, str: "gamma ", style: 0 } ];
  check_diff(d, apply(d, txn_of(mv)), w, "block-move");
  // a style change on a later range (text unchanged, χ_D changes → still a dirty region)
  check_diff(d, set_style(d, 23, 30, 1), w, "style-change-later");
  // insert at the very end
  check_diff(d, insert_text(d, doc_len(d), " omega", 0), w, "insert-at-end");
  // an edit in the FIRST line (patch falls back to a full relayout — still correct)
  check_diff(d, delete_range(d, 0, 6), w, "delete-in-first-line");
  // a no-op edit reuses everything
  r_noop = check_diff(d, insert_text(d, 5, "", 0), w, "no-op-edit");
  assert(r_noop == 0, "a no-op edit recomputes zero boxes");
  // a width change forces a full rebuild that equals a fresh layout (P1: layout ⟂ pieces)
  fragmented = empty_doc();
  fragmented = insert_text(fragmented, 0, "zeta", 0);
  fragmented = insert_text(fragmented, 0, "epsilon ", 0);
  fragmented = insert_text(fragmented, 0, "delta ", 0);
  fragmented = insert_text(fragmented, 0, "alpha beta gamma ", 0);   // == base, many pieces
  assert(materialise(fragmented) == base, "fragmented doc denotes the same text");
  clean = doc_from_text(base, 0);
  assert(render_dump_layout(flow_layout_full(fragmented, default_resolver, mono_measure, 8.0), fragmented)
      == render_dump_layout(flow_layout_full(clean, default_resolver, mono_measure, 8.0), clean),
      "P1: a heavily-fragmented doc lays out identically to a clean one (layout ⟂ pieces)");
}

// ============================ S3.4 caret maps through τ ============================
fn test_caret_map() {
  ins_before: vector<Op> = []; ins_before += [ Ins { at: 0, str: "AB", style: 0 } ];
  assert(map_anchor(6, (ins_before[0]?)) == 8, "insert before the caret shifts it right (+2)");
  ins_after: vector<Op> = []; ins_after += [ Ins { at: 9, str: "Z", style: 0 } ];
  assert(map_anchor(6, (ins_after[0]?)) == 6, "insert after the caret leaves it");
  ins_at: vector<Op> = []; ins_at += [ Ins { at: 6, str: "Z", style: 0 } ];
  assert(map_anchor(6, (ins_at[0]?)) == 6, "insert at the caret leaves it (typed text lands after)");
  del_before: vector<Op> = []; del_before += [ Del { from: 1, to: 3 } ];
  assert(map_anchor(6, (del_before[0]?)) == 4, "delete before the caret shifts it left (−2)");
  del_after: vector<Op> = []; del_after += [ Del { from: 8, to: 10 } ];
  assert(map_anchor(6, (del_after[0]?)) == 6, "delete after the caret leaves it");
  del_span: vector<Op> = []; del_span += [ Del { from: 4, to: 9 } ];
  assert(map_anchor(6, (del_span[0]?)) == 4, "delete spanning the caret collapses it to the deletion start");

  // the caret stays attached to its logical position across a real edit
  d = doc_from_text("hello world", 0);       // caret 6 = just before "world"
  ops: vector<Op> = []; ops += [ Ins { at: 0, str: "AB", style: 0 } ];
  t = txn_of(ops);
  d2 = apply(d, t);
  c2 = map_anchor_txn(6, t);
  assert(c2 == 8, "through the txn, caret 6 → 8");
  assert(materialise_range(d2, c2, c2 + 5) == "world", "…and still points just before 'world'");
}

// ============================ S3.5 single-letter typing, bounded recompute (I7 preview) ============================
fn test_typing_bounded() {
  // a many-line document; a single-char edit late in it must recompute only a bounded tail
  base = "";
  for k in 0..48 { base += "wd{k} "; }
  d = doc_from_text(base, 0);
  w = 12.0;
  full = flow_layout_full(d, default_resolver, mono_measure, w);
  total = len(full.boxes);
  // type a letter near the END
  d1 = insert_text(d, doc_len(d) - 2, "Z", 0);
  rec_late = check_diff(d, d1, w, "type-late");
  assert(rec_late * 6 < total, "a late keystroke recomputes only a small tail (rec={rec_late} ≪ total={total})");
  // and stop-on-reconvergence bounds an edit in the MIDDLE too (not the whole tail after it)
  d2 = insert_text(d, doc_len(d) / 2, "Q", 0);
  rec_mid = check_diff(d, d2, w, "type-mid");
  assert(rec_mid * 3 < total, "a mid-doc keystroke reconverges quickly (rec={rec_mid} ≪ total={total})");
  println("lib: zttext — M3 incremental reflow OK (dirty · patch==full · caret map · bounded recompute)");
}

// ============================ S4.1 per-line heights, varied-style stacking ============================
fn test_line_heights() {
  d = doc_from_text("x y z", 0);
  d = set_style(d, 2, 3, 1);   // "y" → style 1 (size 2 via demo_resolver: a TALLER line)
  bs = flow_layout_full(d, demo_resolver, mono_measure, 1.0);   // W=1 → one word per line
  assert(len(bs.boxes) == 3, "three single-word lines");
  bx = bs.boxes[0]?;
  assert((bx.ks == 0) && (bx.rect.y == 0.0) && (bx.rect.h == 1.0), "line 0 'x' at y=0, height 1");
  by = bs.boxes[1]?;
  assert((by.ks == 2) && (by.rect.y == 1.0) && (by.rect.h == 2.0), "line 1 'y' at y=1, height 2 (big style)");
  bz = bs.boxes[2]?;
  assert((bz.ks == 4) && (bz.rect.y == 3.0) && (bz.rect.h == 1.0), "line 2 'z' at y=3 (stacked below the tall line)");
}

// ============================ S4.2/S4.3 pagination + break-point index π + seek (I6) ============================
fn test_pagination() {
  d = doc_from_text("alpha beta gamma delta epsilon zeta", 0);   // 5 lines at W=10
  w = 10.0;
  layout = flow_layout_full(d, default_resolver, mono_measure, w);
  assert(len(layout.lines) == 5, "5 laid-out lines");
  pages = paginate(layout, 2.0);   // page height 2 → 3 pages ([0,2) [2,4) [4,5))
  assert(len(pages) == 3, "5 lines at page height 2 → 3 pages");
  p0 = pages[0]?; assert((p0.first_line == 0) && (p0.last_excl == 2), "page 0 = lines [0,2)");
  p2 = pages[2]?; assert((p2.first_line == 4) && (p2.last_excl == 5), "page 2 = line 4 only");

  // seek(2) reads π directly, O(1) — the first char of line 4
  assert(seek_page_char(pages, 2) == (layout.lines[4]?).cs, "seek(2) = π(2) = first char of page 2 (O(1))");

  // lay page 2 from scratch WITHOUT laying pages 0,1 — must equal the full layout's page 2
  pg2 = layout_page(d, p2.first_char, p2.top_y, 2.0, default_resolver, mono_measure, w);
  exp2: vector<Box> = [];
  for i in 0..len(layout.boxes) { b = layout.boxes[i]?; if b.ks >= p2.first_char { exp2 += [b]; } }
  assert(render_dump(BoxSet { boxes: exp2 }, d) == render_dump(BoxSet { boxes: pg2.boxes }, d), "layout_page(2) == full layout's page 2");

  // the work is one page of lines, independent of N (I6): page 0 lays 2 lines, page 2 lays 1
  pg0 = layout_page(d, p0.first_char, p0.top_y, 2.0, default_resolver, mono_measure, w);
  assert((len(pg0.lines) == 2) && (len(pg2.lines) == 1), "each layout_page lays only its own page's lines (⟂ N)");
}

// ============================ S4.4 local π invalidation on edit ============================
fn test_pagination_invalidation() {
  d = doc_from_text("alpha beta gamma delta epsilon zeta", 0);
  w = 10.0;
  ph = 2.0;
  pages = paginate(flow_layout_full(d, default_resolver, mono_measure, w), ph);
  // edit LATE (inside the last word "zeta", char 33)
  d2 = insert_text(d, 33, "X", 0);
  pages2 = paginate(flow_layout_full(d2, default_resolver, mono_measure, w), ph);
  p0 = pages[0]?; q0 = pages2[0]?;
  assert((p0.first_char == q0.first_char) && (p0.top_y == q0.top_y) && (p0.first_line == q0.first_line) && (p0.last_excl == q0.last_excl),
    "a late edit leaves page 0's π entry unchanged");
  p1 = pages[1]?; q1 = pages2[1]?;
  assert((p1.first_char == q1.first_char) && (p1.top_y == q1.top_y), "…and page 1's π entry unchanged (local invalidation)");
}

// ============================ S4.5 BiDi placement — the ψ reorder (completes BiDi, I4) ============================
fn test_bidi_placement() {
  d = doc_from_text("aa bb cc dd", 0);
  d = set_style(d, 3, 8, 1);   // "bb cc" (incl. the space) → dir 1 (RTL run)
  layout = flow_layout_full(d, rtl_resolver, mono_measure, 100.0);
  assert(len(layout.boxes) == 7, "7 logical boxes on one line");
  bidi = apply_bidi(layout, rtl_resolver);
  // the RTL run reverses visually; κ stays logical
  b2 = bidi.boxes[2]?; assert((b2.ks == 3) && (b2.rect.x == 6.0), "RTL 'bb' (κ[3,5)) reorders to visual x=6");
  b4 = bidi.boxes[4]?; assert((b4.ks == 6) && (b4.rect.x == 3.0), "RTL 'cc' (κ[6,8)) reorders to visual x=3");
  b0 = bidi.boxes[0]?; assert(b0.rect.x == 0.0, "LTR 'aa' keeps x=0");
  b6 = bidi.boxes[6]?; assert(b6.rect.x == 9.0, "LTR 'dd' keeps x=9");
  // Sel (S2.3) is STILL correct after the reorder — κ is untouched
  sr = sel(BoxSet { boxes: bidi.boxes }, 3, 8);
  assert((len(sr) == 3) && (sr[0]? == 2) && (sr[2]? == 4), "Sel [3,8) still returns the RTL boxes by κ after ψ");
}

// ============================ S4.6 justification (glue stretch) ============================
fn test_justify() {
  // A paragraph that wraps to two lines: the FIRST line (not last) justifies to W; the last
  // line of the paragraph stays ragged (correct typography).
  d = doc_from_text("aa bb cc dd", 0);
  layout = flow_layout_full(d, default_resolver, mono_measure, 6.0);   // "aa bb" / "cc dd"
  jl = justify(layout, d, 6.0).boxes;   // boxes: [0]aa [1]sp [2]bb (y0) [3]cc [4]sp [5]dd (y1)
  b_bb = jl[2]?; assert((b_bb.rect.x + b_bb.rect.w) == 6.0, "line 0 (not last) justifies to exactly W=6");
  g1 = jl[1]?; assert(g1.rect.w == 2.0, "its glue stretches 1 → 2");
  b_dd = jl[5]?; assert((b_dd.rect.x + b_dd.rect.w) == 5.0, "the paragraph's LAST line stays ragged (natural 5, not justified)");
  println("lib: zttext — M4 flow OK (line heights · pagination · seek π · BiDi placement · justify: last line ragged)");
}

// ============================ S4.7 user-directed hyphenation ([-] → soft hyphen) ============================
fn test_hyphenation() {
  // [-] ↔ U+00AD round-trip; the notation encodes to ONE invisible character
  enc = encode_shy("hy[-]phen");
  assert(len(enc) == 7, "'[-]' encodes to a single soft-hyphen char ('hy[-]phen' 9 → 7)");
  assert(decode_shy(enc) == "hy[-]phen", "decode round-trips the soft hyphen back to [-]");
  mid = 0;
  ci = 0;
  for c in enc { if ci == 2 { mid = c as integer; } ci += 1; }
  assert(mid == 173, "the encoded break is U+00AD (SOFT HYPHEN), the portable form");

  d = doc_from_text(enc, 0);   // "hy<shy>phen" — 7 chars
  assert(len(materialise(d)) == 7, "the soft hyphen IS part of the portable logical text");

  // WIDE column: invisible — no hyphen box, the two fragments sit adjacent (SHY zero-width)
  wide = flow_layout(d, default_resolver, mono_measure, 100.0);
  assert(len(wide.boxes) == 2, "wide column: two fragment boxes, the soft hyphen invisible");
  w0 = wide.boxes[0]?; assert((w0.ks == 0) && (w0.ke == 2) && (w0.rect.x == 0.0) && (w0.rect.w == 2.0) && (!w0.hyphen), "'hy' at x=0");
  w1 = wide.boxes[1]?; assert((w1.ks == 3) && (w1.ke == 7) && (w1.rect.x == 2.0) && (!w1.hyphen), "'phen' adjacent at x=2 (no gap, SHY zero-width)");

  // NARROW column: the line breaks at the soft hyphen → a real hyphen glyph appears
  narrow = flow_layout(d, default_resolver, mono_measure, 4.0);
  assert(len(narrow.boxes) == 3, "narrow column: 'hy' + hyphen glyph + 'phen'");
  n0 = narrow.boxes[0]?; assert((n0.ks == 0) && (n0.ke == 2) && (!n0.hyphen), "'hy' on line 0");
  nh = narrow.boxes[1]?; assert(nh.hyphen && (nh.rect.x == 2.0) && (nh.rect.w == 1.0) && (nh.rect.y == 0.0), "a hyphen glyph at the line-0 break (x=2)");
  n2 = narrow.boxes[2]?; assert((n2.ks == 3) && (n2.ke == 7) && (n2.rect.x == 0.0) && (n2.rect.y == 1.0) && (!n2.hyphen), "'phen' wraps to line 1");
  assert(render_dump(narrow, d) == "[0.0,0.0,2.0,1.0] 0..2 s=0 'hy'\n[2.0,0.0,1.0,1.0] 2..3 s=0 '-'\n[0.0,1.0,4.0,1.0] 3..7 s=0 'phen'\n",
    "the discretionary break renders '-' only when the line breaks there");

  // double-click selects the WHOLE hyphenated word, across the soft hyphen
  dt = doc_from_text(encode_shy("hy[-]phen tail"), 0);
  wsel = word_at(dt, 4);
  assert((wsel.0 == 0) && (wsel.1 == 7), "double-click selects the whole hyphenated word [0,7)");

  // incremental reflow still equals a full relayout with a soft hyphen present (I5)
  dh = doc_from_text(encode_shy("alpha be[-]ta gamma delta epsilon"), 0);
  wc = 8.0;
  lh0 = flow_layout_full(dh, default_resolver, mono_measure, wc);
  eh = insert_text(dh, doc_len(dh) - 3, "X", 0);
  ph = patch(lh0, dh, eh, default_resolver, mono_measure, wc);
  assert(render_dump_layout(ph.layout, eh) == render_dump_layout(flow_layout_full(eh, default_resolver, mono_measure, wc), eh),
    "patch == full relayout with a discretionary hyphen present (I5 holds)");
  println("lib: zttext — S4.7 user-directed hyphenation OK ([-] ↔ U+00AD · invisible normally · hyphen on break · word/patch)");
}

// ============================ S5.1 work counters ============================
fn test_work_counters() {
  d = doc_from_text("alpha beta gamma", 0);
  c = Counter { n: 0 };
  cm = fn(s: text, st: Style) -> float { c.n += 1; return mono_measure(s, st); };
  layout = flow_layout_full(d, default_resolver, cm, 100.0);
  assert(len(layout.boxes) == 5, "layout produced 5 boxes (3 words + 2 glue)");
  assert(c.n > 0, "the counting measurer recorded μ-calls (work counter live)");
}

// ============================ S5.2 keystroke cost ⟂ document size (I7) ============================
fn test_keystroke_independent() {
  w = 12.0;
  tail = "the quick brown fox jumps";
  small_base = repeat_word(20);    // 100 chars, ends on a line boundary
  big_base = repeat_word(200);     // 1000 chars, same kind of boundary
  ds = doc_from_text(small_base + tail, 0);
  db = doc_from_text(big_base + tail, 0);
  ls = flow_layout_full(ds, default_resolver, mono_measure, w);
  lb = flow_layout_full(db, default_resolver, mono_measure, w);
  // type a letter inside "quick" (identical tail, identical distance from the end)
  ps = len(small_base) + 6;
  pb = len(big_base) + 6;
  cs = Counter { n: 0 };
  ms = fn(s: text, st: Style) -> float { cs.n += 1; return mono_measure(s, st); };
  cb = Counter { n: 0 };
  mb = fn(s: text, st: Style) -> float { cb.n += 1; return mono_measure(s, st); };
  prs = patch(ls, ds, insert_text(ds, ps, "Z", 0), default_resolver, ms, w);
  prb = patch(lb, db, insert_text(db, pb, "Z", 0), default_resolver, mb, w);
  assert(prs.recomputed == prb.recomputed, "keystroke reflow ⟂ doc size — same recomputed at 20 and 200 words (I7)");
  assert(cs.n == cb.n, "keystroke μ-calls ⟂ doc size");
  assert(prb.recomputed * 8 < len(lb.boxes), "…and the reflow is a small fraction of the big doc ({prb.recomputed} vs {len(lb.boxes)})");
}

// ============================ S5.3 viewport virtualisation ============================
fn test_viewport() {
  w = 12.0;
  ph = 5.0;
  ds = doc_from_text(repeat_word(40), 0);    // 20 lines
  db = doc_from_text(repeat_word(400), 0);   // 200 lines
  ls = flow_layout_full(ds, default_resolver, mono_measure, w);
  lb = flow_layout_full(db, default_resolver, mono_measure, w);
  ps = paginate(ls, ph);
  pb = paginate(lb, ph);
  // the same y-window [10,20) in both docs
  vs = materialise_viewport(ds, ps, ph, 10.0, 20.0, default_resolver, mono_measure, w);
  vb = materialise_viewport(db, pb, ph, 10.0, 20.0, default_resolver, mono_measure, w);
  assert(len(vs.boxes) == len(vb.boxes), "a fixed viewport materialises the same box count regardless of doc size");
  assert(len(vb.boxes) * 4 < len(lb.boxes), "…far fewer boxes than the whole big doc ({len(vb.boxes)} vs {len(lb.boxes)})");
}

// ============================ S5.4 incremental compaction (probe P3) ============================
fn test_compaction() {
  d = doc_from_text("abcdefghij", 0);
  for k in 0..9 { d = set_style(d, k, k + 1, 0); }   // fragment into contiguous same-style pieces
  before = piece_count(d);
  assert(before > 3, "the doc is fragmented into many pieces ({before})");
  // a bounded step merges at most `budget` and never changes the text
  one = compact_step(d, 3);
  assert(piece_count(one) >= before - 3, "one step merges at most `budget` pieces (bounded work)");
  assert(materialise(one) == "abcdefghij", "a compaction step preserves ⌊D⌋");
  // run to completion — coalesces to a single piece, text unchanged (probe P3)
  cur = d;
  guard = 0;
  while guard < 100 {
    nxt = compact_step(cur, 3);
    if piece_count(nxt) == piece_count(cur) { cur = nxt; break; }
    cur = nxt;
    guard += 1;
  }
  assert(piece_count(cur) == 1, "full compaction coalesces the contiguous same-style pieces to 1");
  assert(materialise(cur) == "abcdefghij", "…with ⌊D⌋ unchanged throughout (P3)");
  println("lib: zttext — M5 strain OK (work counters · keystroke ⟂ size · viewport virtualisation · compaction)");
}

// ============================ Document page surface — multi-column flow ============================
fn test_multicolumn() {
  d = doc_from_text("a b c d e f", 0);   // 6 one-char words at char 0,2,4,6,8,10
  g = PageGeom { width: 3.0, height: 2.0, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, columns: 2, gutter: 1.0 };
  assert(column_width(g) == 1.0, "2 columns + 1 gutter in a width-3 content box → column width 1");
  assert(column_height(g) == 2.0, "column height = page height 2");
  layout = layout_columns(d, g, default_resolver, mono_measure);
  assert(len(layout.boxes) == 6, "6 word boxes");
  ba = layout.boxes[0]?; assert((ba.ks == 0) && (ba.rect.x == 0.0) && (ba.rect.y == 0.0), "'a' → col 0, top");
  bb = layout.boxes[1]?; assert((bb.ks == 2) && (bb.rect.x == 0.0) && (bb.rect.y == 1.0), "'b' → col 0, line 2");
  bc = layout.boxes[2]?; assert((bc.ks == 4) && (bc.rect.x == 2.0) && (bc.rect.y == 0.0), "'c' overflows to col 1 (x=2, y=0)");
  bd = layout.boxes[3]?; assert((bd.ks == 6) && (bd.rect.x == 2.0) && (bd.rect.y == 1.0), "'d' → col 1, line 2");
  be = layout.boxes[4]?; assert((be.ks == 8) && (be.rect.x == 0.0) && (be.rect.y == 2.0), "'e' overflows to page 2, col 0 (y=2)");
  bf = layout.boxes[5]?; assert((bf.ks == 10) && (bf.rect.x == 0.0) && (bf.rect.y == 3.0), "'f' → page 2, line 2");
  // Sel is geometry-free — works across columns unchanged (κ-intersection)
  s = sel(BoxSet { boxes: layout.boxes }, 4, 7);
  assert((len(s) == 2) && (s[0]? == 2) && (s[1]? == 3), "Sel [4,7) → 'c' and 'd', across the column boundary");
  // hit-test finds boxes in the 2nd column and on the 2nd page
  assert(box_at(BoxSet { boxes: layout.boxes }, 2.5, 0.5) == 2, "a point in column 1 hits 'c'");
  assert(box_at(BoxSet { boxes: layout.boxes }, 0.5, 2.5) == 4, "a point on page 2 hits 'e'");
  println("lib: zttext — multi-column page OK (column-major flow · absolute coords · Sel/hit-test across columns)");
}

// ============================ #2 fix — line/justify respect columns, not absolute y ============================
fn test_multicolumn_lines() {
  // Two columns' k-th lines share a y; line_boxes must not merge them.
  d = doc_from_text("a b c d e f", 0);
  g = PageGeom { width: 3.0, height: 2.0, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, columns: 2, gutter: 1.0 };
  bs = BoxSet { boxes: layout_columns(d, g, default_resolver, mono_measure).boxes };
  // boxes: [0]a(x0,y0) [1]b(x0,y1) [2]c(x2,y0) [3]d(x2,y1) [4]e(x0,y2) [5]f(x0,y3)
  lb = line_boxes(bs, 0);   // anchor in 'a' (col 0, line 0, y=0)
  assert((len(lb) == 1) && (lb[0]? == 0), "line_boxes('a') → only col-0's line, NOT the same-y 'c' in col 1");
  lc = line_boxes(bs, 4);   // anchor in 'c' (col 1, line 0, same y=0)
  assert((len(lc) == 1) && (lc[0]? == 2), "line_boxes('c') → only col-1's line (box 2), by reading-order run");

  // justify per column: each non-last line fills to its OWN left + column_width, not x=0.
  d2 = doc_from_text("aa bb cc dd ee ff gg hh", 0);   // col0: aa bb / cc dd ; col1: ee ff / gg hh
  g2 = PageGeom { width: 14.0, height: 2.0, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, columns: 2, gutter: 2.0 };
  assert(column_width(g2) == 6.0, "column width 6");
  j = justify(layout_columns(d2, g2, default_resolver, mono_measure), d2, column_width(g2));
  // boxes: [0]aa [1]sp [2]bb (col0 y0) … [6]ee [7]sp [8]ff (col1 y0) … [9]gg [10]sp [11]hh (col1 y1, LAST)
  bb = j.boxes[2]?; assert((bb.rect.x + bb.rect.w) == 6.0, "col-0 line 0 justifies to right edge 6 (0 + width)");
  ff = j.boxes[8]?; assert((ff.rect.x + ff.rect.w) == 14.0, "col-1 line 0 justifies to right edge 14 (its own left 8 + width), not against x=0");
  hh = j.boxes[11]?; assert((hh.rect.x + hh.rect.w) == 13.0, "col-1's LAST line stays ragged (natural 13, not justified)");
  println("lib: zttext — #2 fix OK (line_boxes + justify respect columns, not absolute y)");
}

// ============================ paragraphs — newline breaks, spacing, ragged last line ============================
fn test_paragraphs() {
  d = doc_from_text("one two\nthree four", 0);
  // the newline segments as its OWN paragraph-break token, not glued into a word
  toks = seg(d);
  assert(len(toks) == 7, "one·sp·two·BREAK·three·sp·four = 7 tokens");
  brk = toks[3]?; assert(brk.brk && (brk.ts == 7) && (brk.te == 8), "token 3 is the paragraph break [7,8)");

  layout = flow_layout_full(d, default_resolver, mono_measure, 100.0, 2.0);   // para_gap = 2
  assert(len(layout.lines) == 2, "two paragraphs → two lines");
  l0 = layout.lines[0]?; assert((l0.y == 0.0) && l0.para_end, "paragraph 1 at y=0, marked paragraph-end (ragged)");
  l1 = layout.lines[1]?; assert((l1.y == 3.0) && l1.para_end, "paragraph 2 at y=3 (line height 1 + para_gap 2)");
  // the newline draws NO box; 'three' begins a fresh line
  assert(len(layout.boxes) == 6, "6 boxes (3 per line); the newline is not drawn");
  three = 0 - 1;
  for i in 0..len(layout.boxes) { if (layout.boxes[i]?).ks == 8 { three = i; } }
  bt = layout.boxes[three]?;
  assert((bt.rect.x == 0.0) && (bt.rect.y == 3.0), "'three' begins paragraph 2 at (x=0, y=3)");
  // the newline is still part of the portable logical text
  assert(len(materialise(d)) == 18, "the newline is in ⌊D⌋ (portable content), just not drawn");

  // no para_gap ⇒ paragraphs still break onto new lines, just with no extra gap
  tight = flow_layout_full(d, default_resolver, mono_measure, 100.0);
  assert((tight.lines[1]?).y == 1.0, "with para_gap 0, paragraph 2 sits at y=1 (still a fresh line)");
  println("lib: zttext — paragraphs OK (newline → break · spacing · fresh line · ⌊D⌋ keeps the newline)");
}

// ============================ free_span — the steer-around-images primitive ============================
fn test_free_span() {
  none: vector<PlacedImage> = [];
  f0 = free_span(0.0, 10.0, 0.0, 1.0, none);
  assert((f0.0 == 0.0) && (f0.1 == 10.0) && (!f0.2), "no images → full width [0,10]");
  li: vector<PlacedImage> = []; li += [ PlacedImage { x: 0.0, y: 0.0, w: 3.0, h: 2.0, id: 1 } ];
  fl = free_span(0.0, 10.0, 0.0, 1.0, li);
  assert((fl.0 == 3.0) && (fl.1 == 10.0) && (!fl.2), "left image → text free on [3,10]");
  ri: vector<PlacedImage> = []; ri += [ PlacedImage { x: 7.0, y: 0.0, w: 3.0, h: 2.0, id: 1 } ];
  fr = free_span(0.0, 10.0, 0.0, 1.0, ri);
  assert((fr.0 == 0.0) && (fr.1 == 7.0) && (!fr.2), "right image → text free on [0,7]");
  bi: vector<PlacedImage> = []; bi += [ PlacedImage { x: 0.0, y: 5.0, w: 3.0, h: 2.0, id: 1 } ];
  fb = free_span(0.0, 10.0, 0.0, 1.0, bi);
  assert((fb.0 == 0.0) && (fb.1 == 10.0) && (!fb.2), "image below the line's band → full width");
  fi: vector<PlacedImage> = []; fi += [ PlacedImage { x: 0.0, y: 0.0, w: 10.0, h: 2.0, id: 1 } ];
  ff = free_span(0.0, 10.0, 0.0, 1.0, fi);
  assert(ff.2, "full-width image → blocked (flow drops below)");
  assert(band_bottom(fi, 0.0, 1.0, 0.0, 10.0) == 2.0, "band_bottom drops to the obstruction's bottom (2)");
  ti: vector<PlacedImage> = []; ti += [ PlacedImage { x: 0.0, y: 0.0, w: 2.0, h: 2.0, id: 1 } ]; ti += [ PlacedImage { x: 8.0, y: 0.0, w: 2.0, h: 2.0, id: 2 } ];
  ft = free_span(0.0, 10.0, 0.0, 1.0, ti);
  assert((ft.0 == 2.0) && (ft.1 == 8.0) && (!ft.2), "left+right images → text in the middle gap [2,8]");
  println("lib: zttext — free_span OK (steer around left/right/full/two-sided image rects)");
}

// ============================ page image — text steers around a fixed rect ============================
fn test_page_image() {
  pg: vector<PlacedImage> = []; pg += [ PlacedImage { x: 0.0, y: 0.0, w: 4.0, h: 2.0, id: 7 } ];
  nf: vector<FlowImage> = [];
  d = doc_from_text("aa bb cc dd ee ff gg hh", 0);
  L = layout_flow_images(d, 0.0, 10.0, 0.0, default_resolver, mono_measure, 0.0, 1.0, pg, nf);
  assert((len(L.images) == 1) && ((L.images[0]?).id == 7), "the fixed page image is placed");
  a0 = L.boxes[0]?;
  assert((a0.ks == 0) && (a0.rect.x == 4.0) && (a0.rect.y == 0.0), "text steers right of the top-left image ('aa' at x=4)");
  ee = 0 - 1;
  for i in 0..len(L.boxes) { if (L.boxes[i]?).ks == 12 { ee = i; } }
  be = L.boxes[ee]?;
  assert((be.rect.x == 0.0) && (be.rect.y == 2.0), "below the image the text runs full width ('ee' at x=0, y=2)");
  println("lib: zttext — page image OK (fixed rect; text steers around it, full width below)");
}

// ============================ in-text images — left-flow / full / right-flow ============================
fn test_intext_images() {
  nf: vector<PlacedImage> = [];
  // FULL: a full-width block; text resumes below (narrow region so 'cc' starts the next line)
  full: vector<FlowImage> = []; full += [ FlowImage { at: 6, mode: IMG_FULL, w: 0.0, h: 2.0, id: 1 } ];
  lf = layout_flow_images(doc_from_text("aa bb cc dd", 0), 0.0, 6.0, 0.0, default_resolver, mono_measure, 0.0, 1.0, nf, full);
  assert((len(lf.images) == 1) && ((lf.images[0]?).y == 1.0) && ((lf.images[0]?).w == 6.0), "IMG_FULL: full-width block placed at y=1");
  cf = 0 - 1;
  for i in 0..len(lf.boxes) { if (lf.boxes[i]?).ks == 6 { cf = i; } }
  assert((lf.boxes[cf]?).rect.y == 3.0, "text resumes below the full block ('cc' at y=3)");

  // LEFT: float left, text wraps to the right
  lft: vector<FlowImage> = []; lft += [ FlowImage { at: 0, mode: IMG_LEFT, w: 3.0, h: 2.0, id: 2 } ];
  ll = layout_flow_images(doc_from_text("aa bb cc dd ee ff", 0), 0.0, 10.0, 0.0, default_resolver, mono_measure, 0.0, 1.0, nf, lft);
  assert(((ll.images[0]?).x == 0.0) && ((ll.images[0]?).w == 3.0), "IMG_LEFT: image floated at the left");
  assert((ll.boxes[0]?).rect.x == 3.0, "text wraps to the right of a left-float ('aa' at x=3)");
  el = 0 - 1;
  for i in 0..len(ll.boxes) { if (ll.boxes[i]?).ks == 12 { el = i; } }
  assert(((ll.boxes[el]?).rect.x == 0.0) && ((ll.boxes[el]?).rect.y == 2.0), "below the float text runs full width ('ee' at x=0, y=2)");

  // RIGHT: float right, text stays on the left
  rgt: vector<FlowImage> = []; rgt += [ FlowImage { at: 0, mode: IMG_RIGHT, w: 3.0, h: 2.0, id: 3 } ];
  lr = layout_flow_images(doc_from_text("aa bb cc dd ee ff", 0), 0.0, 10.0, 0.0, default_resolver, mono_measure, 0.0, 1.0, nf, rgt);
  assert((lr.images[0]?).x == 7.0, "IMG_RIGHT: image floated at the right (x=7)");
  assert((lr.boxes[0]?).rect.x == 0.0, "text stays on the left of a right-float ('aa' at x=0)");
  println("lib: zttext — in-text images OK (left-flow · full · right-flow)");
}

// find the text box with a given κ start (test helper).
fn box_by_ks(boxes: vector<Box>, ks: integer) -> Box {
  for i in 0..len(boxes) { b = boxes[i]?; if b.ks == ks { return b; } }
  return Box { rect: Rect { x: 0.0 - 1.0, y: 0.0 - 1.0, w: 0.0, h: 0.0 }, ks: 0 - 1, ke: 0 - 1, style: 0 };
}

// ============================ multi-column page + images ============================
fn test_page_columns() {
  // 1. regression — with no images, layout_page_columns == layout_columns exactly
  dr = doc_from_text("alpha beta gamma delta epsilon zeta eta theta", 0);
  gr = PageGeom { width: 24.0, height: 8.0, margin_left: 2.0, margin_right: 2.0, margin_top: 2.0, margin_bottom: 2.0, columns: 2, gutter: 2.0 };
  ni: vector<PlacedImage> = [];
  nf: vector<FlowImage> = [];
  assert(render_dump_layout(layout_columns(dr, gr, default_resolver, mono_measure), dr)
      == render_dump_layout(layout_page_columns(dr, gr, ni, nf, default_resolver, mono_measure), dr),
      "no-image layout_page_columns == layout_columns (no regression)");

  g = PageGeom { width: 14.0, height: 4.0, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, columns: 2, gutter: 2.0 };

  // 2. a PAGE image over column 0's top: column 0 steers around it; column 1 is unaffected
  d = doc_from_text("aa bb cc dd ee ff gg hh ii jj kk ll", 0);
  pg: vector<PlacedImage> = []; pg += [ PlacedImage { x: 0.0, y: 0.0, w: 3.0, h: 2.0, id: 5 } ];
  lay = layout_page_columns(d, g, pg, nf, default_resolver, mono_measure);
  assert((len(lay.images) == 1) && ((lay.images[0]?).id == 5), "the page image is placed");
  aa = box_by_ks(lay.boxes, 0);  assert((aa.rect.x == 3.0) && (aa.rect.y == 0.0), "'aa' steers right of the column-0 page image (x=3)");
  cc = box_by_ks(lay.boxes, 6);  assert((cc.rect.x == 0.0) && (cc.rect.y == 2.0), "below the image, column 0 runs full width ('cc' at 0,2)");
  gg = box_by_ks(lay.boxes, 18); assert((gg.rect.x == 8.0) && (gg.rect.y == 0.0), "column 1 is unaffected by the column-0 image ('gg' at 8,0)");

  // 3. an IN-TEXT left-float in column 0: column-0 text wraps; column 1 unaffected
  d2 = doc_from_text("aa bb cc dd ee ff gg hh", 0);
  fl: vector<FlowImage> = []; fl += [ FlowImage { at: 0, mode: IMG_LEFT, w: 3.0, h: 2.0, id: 9 } ];
  lay2 = layout_page_columns(d2, g, ni, fl, default_resolver, mono_measure);
  assert(((lay2.images[0]?).x == 0.0) && ((lay2.images[0]?).y == 0.0), "the in-text left-float lands in column 0");
  a2 = box_by_ks(lay2.boxes, 0);  assert(a2.rect.x == 3.0, "column-0 text wraps to the right of the float");
  g2 = box_by_ks(lay2.boxes, 18); assert((g2.rect.x == 8.0) && (g2.rect.y == 0.0), "column 1 is unaffected by the column-0 float");
  println("lib: zttext — multi-column page + images OK (per-column steering; page image + in-text float)");
}

// ============================ sub-word selection (character-precise rects) ============================
fn test_sel_rects() {
  d = doc_from_text("hello world", 0);
  bs = flow_layout(d, default_resolver, mono_measure, 100.0);   // hello[0,5]x0 · sp[5,6]x5 · world[6,11]x6
  rr = sel_rects(bs, d, 2, 8, default_resolver, mono_measure);   // select mid-'hello' → mid-'world'
  assert(len(rr) == 3, "a range spanning 3 boxes → 3 highlight rects");
  r0 = rr[0]?; assert((r0.x == 2.0) && (r0.w == 3.0), "'hello' clipped to its selected chars [2,5): x=2 w=3, not the whole word");
  r2 = rr[2]?; assert((r2.x == 6.0) && (r2.w == 2.0), "'world' clipped to [6,8): x=6 w=2");
  rf = sel_rects(bs, d, 0, 5, default_resolver, mono_measure);
  assert((len(rf) == 1) && ((rf[0]?).x == 0.0) && ((rf[0]?).w == 5.0), "a fully-selected word → its whole box rect");
  assert(len(sel_rects(bs, d, 3, 3, default_resolver, mono_measure)) == 0, "an empty selection → no rects");
  println("lib: zttext — sub-word selection OK (sel_rects clips partial boxes to the exact chars)");
}

// ============================ full-column justify (old-school last-line fill) ============================
fn test_justify_last() {
  d = doc_from_text("aa bb cc", 0);   // one line, natural width 8, in a 10-wide column
  layout = flow_layout_full(d, default_resolver, mono_measure, 10.0);
  jr = justify(layout, d, 10.0).boxes;              // default: last line ragged
  lr = jr[len(jr) - 1]?; assert((lr.rect.x + lr.rect.w) == 8.0, "default: a paragraph's last line stays ragged (natural 8)");
  jl = justify(layout, d, 10.0, true).boxes;        // justify_last: fill the full column
  ll = jl[len(jl) - 1]?; assert((ll.rect.x + ll.rect.w) == 10.0, "justify_last (old-school): the last line fills the full column (W=10)");
  println("lib: zttext — full-column justify OK (justify_last stretches even the last line)");
}

// ============================ justify respects an image edge (line.right) ============================
fn test_justify_beside_image() {
  d = doc_from_text("aa bb cc dd ee ff", 0);
  rgt: vector<FlowImage> = []; rgt += [ FlowImage { at: 0, mode: IMG_RIGHT, w: 3.0, h: 2.0, id: 1 } ];
  np: vector<PlacedImage> = [];
  lay = layout_flow_images(d, 0.0, 10.0, 0.0, default_resolver, mono_measure, 0.0, 1.0, np, rgt);   // right-float at x=7
  j = justify(lay, d, 10.0).boxes;   // line 0 is beside the image → its right edge is 7, not 10
  bb = j[2]?;   // 'bb', last word on line 0 (y=0)
  assert((bb.rect.x + bb.rect.w) == 7.0, "a line beside a right-float justifies to the image edge (7), not the full column (10)");
  println("lib: zttext — justify-beside-image OK (fills to the free width, not into the image)");
}

// ============================ RenderSink: render_commands (golden, D2) ============================
// The reusable engine sink protocol — LINE| per laid line, BOX| per glyph-box — must be exact and
// backend-stable (it is what the browser DOM sink and the native/PDF sinks all paint from).
fn test_render_commands_golden() {
  d = doc_from_text("the quick brown fox\njumps over", 0);
  l = flow_layout_full(d, default_resolver, mono_measure, 8.0);
  exp = "LINE|0.00|1.00|8.00|0\nLINE|1.00|1.00|8.00|0\nLINE|2.00|1.00|8.00|0\nLINE|3.00|1.00|8.00|1\nLINE|4.00|1.00|8.00|0\nLINE|5.00|1.00|8.00|1\n"
      + "BOX|0.00|0.00|3.00|1.00|0|the\nBOX|0.00|1.00|5.00|1.00|0|quick\nBOX|0.00|2.00|5.00|1.00|0|brown\nBOX|0.00|3.00|3.00|1.00|0|fox\nBOX|0.00|4.00|5.00|1.00|0|jumps\nBOX|0.00|5.00|4.00|1.00|0|over\n";
  assert(render_commands(l, d) == exp, "render_commands matches the golden paint-command string (LINE + BOX)");
}