perf: the load passes batch their field work and flow a section whole

Cross-references resolve in one batch per pass, the index reads every leader gap
before filling one, and a zone's tabs lay out with the other zones' — and only
where a field asks for the page count or the chapter map.
List marker and list style decorations are rebuilt only for a transaction
reaching into a list; the unsaved dot compares documents structurally, and the
autosave serializes when it writes rather than per keystroke.
The columns flow applies every op it can judge in a pass, so a section pages in
one pass. Two long files that took 33 s and 20 s now settle in 9 s and 2 s.
This commit is contained in:
Steffen Becker committed 2026-09-08 22:31:01 +02:00
1 parent 3af5a00fed
commit da857fcbb8
16 files changed
+478 -230

No files matched your search

+2 -2
View File
@@ -9,6 +9,6 @@ list markers, paragraph boxes, date/time fields and cross-references.
- **`tabStops.ts`** (`TabStops`) — per-paragraph tab stops (Word's `w:tabs`, ODF `style:tab-stops`). Global attr `tabStops` on paragraph/heading, rendered as `data-tab-stops`; canonical string `'<cm><align code>'` per stop, `;`-separated (`'6c;12r;16d'`, codes `l|c|r|d`), parsed only by the exported `parseTabStops`/`formatTabStops`. Positions are **cm from the left text margin** — the origin Word and ODF share, verified by probe (a Word stop at 100mm in a 30mm-indented paragraph stays 100mm in ODF and LibreOffice renders it there), so neither I/O side shifts them by the paragraph indent. **Rendering**: CSS only has the fixed `tab-size` grid, so a rAF measuring plugin (shaped like `pageBreaks.ts`, whose `FORCE_PAGE_RECALC` it both listens to and fires) computes each tab's advance from `coordsAtPos` against `.tiptap`'s content edge and emits `Decoration.inline(tab, tab+1, {style:'tab-size:0;margin-left:Npx'})` — `tab-size:0` collapses the tab's own advance, the inline margin supplies the exact width, so no `display` change and no baseline/line-height side effects. The margin is on the **left** because the gap is the tab's own advance: as `margin-right` a caret placed after the tab rendered before the gap, i.e. at the pre-tab x until the next keystroke. The pen is read from the DOM only at the **first tab of a line** (side −1, the end of the content before it) and then **carried along the line**: stop + the natural width of the segment that follows it. Reading each tab off the DOM instead measures the advance the *previous* pass gave the tab before it, so the layout takes another pass to settle — and on a long document, where pagination keeps moving lines for seconds, it may never get a stable one; the Math Guide's contributor columns landed 18mm out in one run of three. `nextStop` takes the first custom stop right of the tab, else leaves the tab undecorated (past the last custom stop the CSS grid already does the right thing, which is also why custom stops suppress the default ones to their left). A **negative `indentFirst` implies a stop at the block's indent** (the hanging-indent stop). Centre/right/decimal take back the segment up to the next tab, measured by `rangeWidth` as the **extent of each line it covers**, added up — a range crossing an inline element yields a rect for the element's box *and* for the text inside it, and adding those counts the text twice. The scale comes from the editor's **width**: `offsetHeight` is rounded, and a one-line zone is short enough for that to be half a percent. Passes converge (one more tab settles per pass, `MAX_PASSES`), since a stop can rewrap the line. The measured layout is kept as a string key and a pass matching it dispatches nothing — but that key alone is **not** enough to decide the decorations are still applied: replacing the document maps them all away, so the guard also compares the live decoration count, or two documents with the same tab layout (the two DIN 5008 forms, an undo, a reopened file) would leave every tab on the CSS grid. **UI**: `Ruler.svelte`. A **header/footer zone** carries stops too — the left⇥centre⇥right layout rides on them — but an inactive one is static `generateHTML` output no plugin reaches, so `layOutZoneTabs` measures it straight on the DOM (`HeaderFooterLayer.svelte`, `use:layOutTabs`), wrapping each tab in a span of its own. Advances are layout px, so the zoom transform doesn't invalidate them. A decimal stop there behaves as right. Three things that zone needs and the body doesn't: the wrap marker is matched with `hasAttribute` (its value is `''`, which reads as falsy, and every pass nested another span with its own advance), the segment is measured with wrapping off (one already wrapped reads short by the space its break swallowed, which keeps it wrapped), and a stop past the line is drawn at the line's end — LibreOffice's own footer style puts its right stop at 18cm in a 17cm column. The body clamps the same way. A stop may carry a **leader** (`w:tab w:leader` / `style:leader-text`, one of `. - _ ·`), drawn as a `::before` clipped to the measured gap so the fill stays out of the document's text; on the ODF side odf-kit has no leader option and mints one automatic style per option set, so the leader rides `style:type` behind a `LEAD` sentinel that `applyTabLeaders` unpacks — a bare position+type lookup would give two stops that agree on both the same leader. Past the last custom stop the grid is the document's own **default tab interval** (`storage/tabInterval.ts`, 1.25cm for a new document — LibreOffice's; Word's is 1.27): imported from `w:defaultTabStop` / the paragraph default-style's `style:tab-stop-distance`, applied as the `--tab-interval` CSS var, and **always** written back on export. A file that declares none falls back to its format's own value — measured, LibreOffice puts a bare `A⇥X` at **2cm**, not at its UI default — so leaving ours implied would render differently everywhere Line truncated
A **run of tabs wider than the line** continues on the next one with the grid starting over, as LibreOffice lays it out; Chromium instead hangs the leftover tabs and starts the next line at the margin. `runBreaks` walks the run analytically from the pen *before* it — the one thing the fix can't move, so the answer is the same whether or not the break is already in place — and emits a `<br>` widget where the line runs out. Only runs of two or more are considered: reading a block's geometry forces a reflow, and doing it for every tab in a 48-page document slowed pagination enough to skew the parity harness.
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **floated** into that indent (`min-width` the indent, negative `margin-left`): in flow it grew every list line by a pixel, and absolutely positioned a label wider than the indent — `1.1.1.`, `viii.` — printed over the text instead of pushing it. Word and LibreOffice push too, on to the next tab stop; the float stops flush after the label. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs. **Label alignment** (`markerAlign` on the list node, Word's `w:lvlJc`, ODF's `fo:text-align` on the level properties): `right` sets the label's *far* end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. `tests/unit/marker-align.test.ts` round-trips it.
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **floated** into that indent (`min-width` the indent, negative `margin-left`): in flow it grew every list line by a pixel, and absolutely positioned a label wider than the indent — `1.1.1.`, `viii.` — printed over the text instead of pushing it. Word and LibreOffice push too, on to the next tab stop; the float stops flush after the label. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs. Both this set and `listStyle.ts`’s are rebuilt only for a transaction reaching into a list (`touchesList` — a step by its own `from`/`to` or `pos`, since a mark step’s position map is empty, and by what its map moved) or for the stylesheet’s own signal (`SHEET_CHANGED`, dispatched by `Editor.svelte`’s stylesheet effect); anything else maps the old decorations forward, so an edit anywhere in a long document no longer walks it whole. `tests/unit/list-marker-rebuild.test.ts` covers which transactions count. **Label alignment** (`markerAlign` on the list node, Word's `w:lvlJc`, ODF's `fo:text-align` on the level properties): `right` sets the label's *far* end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. `tests/unit/marker-align.test.ts` round-trips it.
- **`dateTimeField.ts`** (`DateTimeField`) — inline atom for an inserted date/time field (Word's "Insert Date and Time"). Attrs: `kind` (`'date'|'time'`), `format` (a catalog key), `fixed`, `value` (ISO local datetime captured at insert). A **fixed** field renders its stored `value`; an **auto** field renders the *current* moment on every load (`renderHTML`/`renderText` via `dateTimeFieldText`) — no plugin, Word/LibreOffice likewise only refresh on open. As an inline atom with no children it can't inherit the surrounding run's font from a sibling span, so it **carries that run's marks** on the node: insert adopts the cursor's stored marks, and both importers attach the field run's resolved marks (else it falls back to the editor default font). Command `insertDateTimeField({kind,format,fixed})`. Formats live in `utils/dateTime.ts` as a **token model** (one `Token[]` per format) so the three consumers stay in agreement: `renderFormat` (locale display via `Intl`), `odfNumberStyle` (`<number:date-style>`/`<number:time-style>` body), `docxPicture` (Word field switch). `DateTimePicker.svelte` (a dropdown next to `SpecialCharPicker` in `ToolbarExpanded.svelte`) lists live samples + an "update automatically" checkbox. Not in the header/footer schema (there a `<text:date>`/DATE field stays plain text). **ODF**: `replaceDateTimeFields`/`applyDateTimeFields` (sentinel U+E00A, mirrors images) emit `<text:date text:date-value text:fixed style:data-style-name>`/`<text:time text:time-value …>` + a minted per-format number style (language from the export doc language; `ensureNumberNamespace` adds `xmlns:number`); import matches the referenced number style back to a format via `parseNumberStyleTokens` + `matchFormat` (unknown ⇒ keep the shown text). **DOCX**: auto → `SimpleField` `DATE`/`TIME \@ "picture"` with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps a `DATE`/`TIME` `fldSimple`/complex field's picture back to a format (`dateTimeFieldFromInstr`).
- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF `<text:bookmark/>`, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Its node view resolves live on each `pm-pagecount` like `TocView` does — the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to `<text:bookmark-start/>`, `<text:bookmark-end/>` and `<text:bookmark-ref text:reference-format>`. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in).
- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF `<text:bookmark/>`, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Every reference of an editor resolves in one batch (`CrossRefBatch`) on each `pm-pagecount` and on a change to any of them — one bookmark scan, every page read before any text is written, one transaction for what changed; the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to `<text:bookmark-start/>`, `<text:bookmark-end/>` and `<text:bookmark-ref text:reference-format>`. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in).
+1 -1
View File
@@ -145,4 +145,4 @@ document of real words, where few squiggles split the text nodes, 47 and 18 ms.
## Multi-column sections (`columns.ts` + `columnsFlow.ts`)
- **`columns.ts`** (`Columns`) + **`columnsFlow.ts`** (`ColumnsFlow`) — multi-column (newspaper) layout with **cross-page flow** and **Word's sequential fill order**: column 1 fills to the page bottom, then column 2, then the next page — via per-fragment node decorations carrying `height:…;column-fill:auto` (full page slot for fragments with a continuation; content height + `OPEN_HEIGHT_SLACK_PX` for the open last fragment of a document-final chain, so typing grows column 1 first without one-frame flashes into column 2). A chain followed by real content keeps its last fragment undecorated → CSS `column-fill: balance` (Word balances the end of a continuous section) — which only works because the paragraph mark's `::after` is `position: static` inside `.columns-node` (`editor.css`): the absolutely-positioned mark made Chromium give up balancing entirely and stack every block into column 1 (probed; a list's absolute `::before` markers do not trigger this). Because the box height is the plugin's own decoration, overflow/join decisions use content-based estimates (`contentHeightPx` = block rects + collapsed margins), not the rendered height. A section is a block region (`content: '(paragraph|heading|bulletList|orderedList)+'`) rendered as CSS multi-column (`column-count`/`column-gap` inline via `renderHTML`; no NodeView, no visual affordance — it must read as plain flowing text, so NOT isolating/selectable: selection, Backspace, and joins cross its boundary). Attrs: `count` (2–3) and `gapCm` (default `DEFAULT_COLUMN_GAP_CM` 0.5, clamp 0–5). Own group like textBox (Document is `'(block | textBox | columns)+'`). **Chain model:** the document may hold a section as several *adjacent equal-attr fragments* — the user-visible section is the maximal run (`columnsChain`); commands, export, and import all treat a chain as one section. **Cross-page flow (`columnsFlow.ts`):** a rAF layout pass (mirroring pageBreaks' geometry via the exported `readVerticalMargins`) keeps the fragmentation in sync with the page grid — an overflowing fragment is **split** at a block boundary so the maximal prefix fills its page (balanced-height estimate = Σ block `getClientRects` heights / count), and a fragment with room **joins** the adjacent continuation back (the split pass then re-splits at the right point). One op per pass, `addToHistory: false` (layout-only; undo of user edits just re-flows) — **but prosemirror-history rebases its own steps over that op**, so an undo reaching back past one can leave a wrap standing that nothing can undo; `tests/monkey/` counts the flow's ops (`columnsFlowTx`) and leaves the history unchecked for such a run rather than assert a ceiling the design carries, composition-guarded, pass-budgeted (`MAX_FLOW_PASSES`, reset per external change). **pageBreaks.ts cooperation:** a `.columns-node` leaf carries `columnsFragment` (block count + first-block fit estimate); on bottom overflow pageBreaks *waits for the flow* when the fragment is splittable and its first block fits the remaining space, pushes it whole when mid-page (even taller-than-page), and at a page top does nothing (`columns-line-split-pending` — pushing a page-tall fragment just moves the problem down forever). Both sides use the shared `COLUMNS_FIT_MARGIN_PX` for the first-block fit test — differing thresholds make split/push/join cycle at the boundary; the flow's join `HYSTERESIS_PX` must exceed the estimate error for the same reason. The **overflow** test carries the same kind of headroom (`SAFETY_PX`): the estimate averages the flow over the columns, but a decorated column ends on a whole line, so a fragment measuring exactly its slot spills once the decoration pins the height — without it the flow reads its own last answer, measuring "fits" undecorated and "overflows" decorated forever. Every split or join therefore also clears the decoration cache key (`dispatchFlow`): ProseMirror drops node decorations whose node was replaced, and a stale key leaves the fragment at its balanced height instead of its page slot. **Page boundaries cut mid-paragraph at line boundaries** (`splitParagraphInBlock`): the flow measures a paragraph's rendered lines (`groupLines` — document-order rects banded by *overlap*: a rect joins the line it overlaps by half the taller of the two, so a raised run like a verse number is no line of its own and no outsized rect swallows the line below it), converts the page's leftover content budget into a line count by summing the **real** top-to-top advances (`linesWithin` — a line carrying a raised run is taller than the rest, so one median advance for all of them runs ~15% short; the median stands in only where the tops reset at a column break), finds the boundary text position by probing **both** edges of the line's first rect and keeping the earlier position (`lineStartPos` — on a right-to-left line the logical start is the right edge, and `left + 1` alone keeps a word too many, which costs oneLine truncated
- **`columns.ts`** (`Columns`) + **`columnsFlow.ts`** (`ColumnsFlow`) — multi-column (newspaper) layout with **cross-page flow** and **Word's sequential fill order**: column 1 fills to the page bottom, then column 2, then the next page — via per-fragment node decorations carrying `height:…;column-fill:auto` (full page slot for fragments with a continuation; content height + `OPEN_HEIGHT_SLACK_PX` for the open last fragment of a document-final chain, so typing grows column 1 first without one-frame flashes into column 2). A chain followed by real content keeps its last fragment undecorated → CSS `column-fill: balance` (Word balances the end of a continuous section) — which only works because the paragraph mark's `::after` is `position: static` inside `.columns-node` (`editor.css`): the absolutely-positioned mark made Chromium give up balancing entirely and stack every block into column 1 (probed; a list's absolute `::before` markers do not trigger this). Because the box height is the plugin's own decoration, overflow/join decisions use content-based estimates (`contentHeightPx` = block rects + collapsed margins), not the rendered height. A section is a block region (`content: '(paragraph|heading|bulletList|orderedList)+'`) rendered as CSS multi-column (`column-count`/`column-gap` inline via `renderHTML`; no NodeView, no visual affordance — it must read as plain flowing text, so NOT isolating/selectable: selection, Backspace, and joins cross its boundary). Attrs: `count` (2–3) and `gapCm` (default `DEFAULT_COLUMN_GAP_CM` 0.5, clamp 0–5). Own group like textBox (Document is `'(block | textBox | columns)+'`). **Chain model:** the document may hold a section as several *adjacent equal-attr fragments* — the user-visible section is the maximal run (`columnsChain`); commands, export, and import all treat a chain as one section. **Cross-page flow (`columnsFlow.ts`):** a rAF layout pass (mirroring pageBreaks' geometry via the exported `readVerticalMargins`) keeps the fragmentation in sync with the page grid — an overflowing fragment is **split** at a block boundary so the maximal prefix fills its page (estimate = the flow poured into one column / count — `flowSpans`, block rects plus collapsed margins, the same measure the overflow test uses: counted by rects alone, one block too many stays and moves down a pass at a time), and a fragment with room **joins** the adjacent continuation back (the split pass then re-splits at the right point). A pass fixes every boundary it can judge, first to last (`MAX_FLOW_OPS`): after an op only the op's own fragment and chained continuations are judged — a continuation lands at a page top, so a full page is its room wherever it renders at that moment — and any other fragment waits for the next pass, so a multi-page section pages in one pass. Every op is `addToHistory: false` (layout-only; undo of user edits just re-flows) — **but prosemirror-history rebases its own steps over that op**, so an undo reaching back past one can leave a wrap standing that nothing can undo; `tests/monkey/` counts the flow's ops (`columnsFlowTx`) and leaves the history unchecked for such a run rather than assert a ceiling the design carries, composition-guarded, pass-budgeted (`MAX_FLOW_PASSES`, reset per external change). **pageBreaks.ts cooperation:** a `.columns-node` leaf carries `columnsFragment` (block count + first-block fit estimate); on bottom overflow pageBreaks *waits for the flow* when the fragment is splittable and its first block fits the remaining space, pushes it whole when mid-page (even taller-than-page), and at a page top does nothing (`columns-line-split-pending` — pushing a page-tall fragment just moves the problem down forever). Both sides use the shared `COLUMNS_FIT_MARGIN_PX` for the first-block fit test — differing thresholds make split/push/join cycle at the boundary; the flow's join `HYSTERESIS_PX` must exceed the estimate error for the same reason. The **overflow** test carries the same kind of headroom (`SAFETY_PX`): the estimate averages the flow over the columns, but a decorated column ends on a whole line, so a fragment measuring exactly its slot spills once the decoration pins the height — without it the flow reads its own last answer, measuring "fits" undecorated and "overflows" decorated forever. Every split or join therefore also clears the decoration cache key (`dispatchFlow`): ProseMirror drops node decorations whose node was replaced, and a stale key leaves the fragment at its balanced height instead of its page slot. **Page boundaries cut mid-paragraph at line boundaries** (`splitParagraphInBlock`): the flow measures a paragraph's rendered lines (`groupLines` — document-order rects banded by *overlap*: a rect joins the line it overlaps by half the taller of the two, so a raised run like a verse number is no line of its own and no outsized rect swallows the line below it), converts the page's leftover content budget into a line couLine truncated