← 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.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
zttext_version fn | 22 | — | 100 |
Piece struct | 31 | — | 37 66 68 129 134 138 … (16 total) |
Doc struct | 37 | — | 60 63 69 75 87 98 … (52 total) |
Run struct | 41 | — | 202 203 214 |
empty_doc fn | 60 | — | 41 344 450 |
doc_from_text fn | 63 | — | 18 27 54 77 104 105 … (59 total) |
materialise fn | 75 | — | 443 943 30 31 36 41 … (28 total) |
doc_len fn | 87 | ZTX-002 | 32 55 61 107 113 152 … (13 total) |
locate fn | 98 | — | 131 122 123 124 125 126 … (7 total) |
locate_inverse fn | 109 | — | 127 128 |
insert_text fn | 121 | ZTX-001 | 260 28 35 108 114 121 … (25 total) |
delete_range fn | 156 | ZTX-002 | 261 59 157 161 173 434 … (7 total) |
set_style fn | 180 | ZTX-001 | 262 78 165 169 197 326 … (10 total) |
slice_runs fn | 202 | — | 224 285 294 477 587 601 … (7 total) |
materialise_range fn | 222 | — | 701 724 802 845 863 1231 … (10 total) |
char_styles fn | 231 | — | 956 957 79 90 70 207 … (7 total) |
Op enum | 243 | — | 250 254 259 278 279 313 … (20 total) |
Txn struct | 250 | — | 254 267 311 323 1110 |
txn_of fn | 254 | — | 81 185 202 212 215 439 … (7 total) |
apply_op fn | 259 | — | 269 321 189 |
apply fn | 267 | — | 82 87 186 203 205 213 … (10 total) |
inv_op_group fn | 278 | — | 316 |
invert fn | 311 | ZTX-003 | 86 204 213 217 |
Style struct | 334 | — | 338 344 349 353 474 494 … (32 total) |
default_style fn | 338 | — | 349 75 81 224 |
mono_measure fn | 344 | — | 23 108 112 116 225 227 … (77 total) |
default_resolver fn | 349 | — | 23 108 112 116 255 256 … (70 total) |
run_height fn | 353 | — | 589 607 623 1586 |
Rect struct | 363 | — | 366 609 625 836 837 848 … (24 total) |
Box struct | 366 | — | 369 385 577 594 609 625 … (32 total) |
BoxSet struct | 369 | — | 670 671 696 731 764 775 … (19 total) |
LaidLine struct | 376 | — | 385 577 629 636 648 682 … (20 total) |
PlacedImage struct | 380 | — | 385 1465 1495 1516 1520 1521 … (26 total) |
Layout struct | 385 | — | 634 656 662 715 973 993 … (24 total) |
Token struct | 394 | — | 441 442 451 456 460 464 … (11 total) |
soft_hyphen fn | 404 | — | 410 |
encode_shy fn | 409 | — | 595 622 627 |
decode_shy fn | 430 | — | 597 |
seg fn | 441 | — | 663 889 1019 1162 1375 1517 … (13 total) |
token_width fn | 474 | — | 504 551 569 255 256 |
Line struct | 489 | — | 494 495 517 529 |
lines_greedy fn | 494 | — | 263 268 |
next_line fn | 540 | — | 640 1043 1173 1385 1553 1642 |
PlacedLine struct | 577 | — | 582 629 |
place_line fn | 582 | — | 646 1048 1178 1391 1559 1648 |
layout_tokens_from fn | 634 | — | 663 |
flow_layout_full fn | 662 | — | 23 671 686 976 419 421 … (26 total) |
flow_layout fn | 670 | ZTX-004 | 108 116 275 290 327 338 … (14 total) |
line_assignment fn | 682 | — | 112 304 314 |
render_dump fn | 696 | — | 1088 340 348 349 539 618 |
render_commands fn | 715 | — | 24 937 |
boxset_height fn | 731 | — | 110 117 295 |
box_at fn | 764 | — | 797 358 359 360 361 730 … (7 total) |
anchor_at fn | 796 | — | 362 363 364 365 366 |
sel fn | 821 | — | 373 374 375 376 405 410 … (8 total) |
sel_rects fn | 836 | — | 896 900 902 903 |
caret_at fn | 859 | — | none in this package |
word_at fn | 888 | — | 382 383 623 |
line_boxes fn | 915 | — | 386 387 742 743 744 745 … (7 total) |
doc_chars fn | 941 | — | 956 957 |
dirty fn | 955 | — | 994 504 |
PatchResult struct | 973 | — | 975 977 993 999 1084 |
patch fn | 993 | — | 420 422 504 631 633 634 … (8 total) |
render_dump_layout fn | 1088 | — | 422 457 458 632 867 868 |
map_anchor fn | 1096 | — | 1112 465 467 469 471 473 … (7 total) |
map_anchor_txn fn | 1110 | — | 482 |
Page struct | 1124 | — | 1129 1130 1138 1146 1153 1298 |
paginate fn | 1129 | — | 527 551 554 679 680 |
seek_page_char fn | 1153 | — | 533 |
layout_page fn | 1161 | — | 1306 536 539 542 543 |
apply_bidi fn | 1196 | — | 568 |
justify fn | 1254 | — | 310 311 585 589 751 756 … (11 total) |
materialise_viewport fn | 1298 | — | 682 683 |
piece_count fn | 1319 | — | 692 696 703 707 |
compact_step fn | 1325 | — | 695 702 |
PageGeom struct | 1351 | — | 1359 1365 1374 1582 715 739 … (9 total) |
column_width fn | 1359 | — | 1376 1584 716 750 751 |
column_height fn | 1365 | — | 1377 1585 717 |
layout_columns fn | 1374 | — | 718 740 751 867 869 |
FlowImage struct | 1423 | — | 1516 1582 813 830 838 847 … (9 total) |
IMG_LEFT const | 1425 | — | 1541 838 840 884 |
IMG_FULL const | 1427 | — | 1532 1605 1614 830 832 |
IMG_RIGHT const | 1429 | — | 1537 1617 847 849 920 |
free_span fn | 1465 | — | 1547 1636 789 792 795 798 … (9 total) |
band_bottom fn | 1495 | — | 1549 1638 803 |
layout_flow_images fn | 1516 | — | 815 831 839 848 922 |
layout_page_columns fn | 1582 | — | 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)");
}