From 4aef4d76415b22b2b0f56e450fe5c188744c6319 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sun, 30 Aug 2026 08:02:42 +0000 Subject: [PATCH] fix: keep rows a cell spans on one page, and clear floats before an in-flow box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A break between spanned rows stretched the cell across the gap: the merged table drew over the page gap and the next page's header. A group too tall for a page still breaks per row. An in-flow text box took the same treatment for floats — CSS lays a block box over one instead of beside it, which had the vertical frame covering its neighbour. Both match LibreOffice's render of the same document. --- docs/architecture/frames.md | 2 +- docs/architecture/pagination.md | 2 +- src/lib/editor/extensions/pageBreaks.ts | 50 ++++++++++++++++++++++++- src/lib/editor/extensions/textBox.ts | 6 ++- tests/unit/table-rowspan-break.test.ts | 41 ++++++++++++++++++++ 5 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 tests/unit/table-rowspan-break.test.ts diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index a6c7f16..466e4f3 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -4,7 +4,7 @@ Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, res handles, wrap modes, frame offsets — and their ODF/DOCX legs. - **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an `` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. The **block after the anchor paragraph clears the band** (an `editor.css` `:has` rule): a float only pushes lines that intersect it, so a short block (a heading) would slot into the clearance gap a prior side float opens above the band — both word processors start it below; a page-break spacer and the block holding a band-sharing partner frame are exempt. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. -- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **Spacing:** a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolved `fo:margin-top`/`-bottom` as `spaceBefore`/`spaceAfter` (`ParagraphSpacing` covers `textBox` for that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node is `content-box` and the rotor, centred in the padding box, gets half of it back. +- **`textBox.ts`** (`TextBox`) — block-level text box / basic shape with editable block content (`content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`). Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`; `inline` renders in-flow, floats via CSS `float` like `ImageView.applyWrap` — an in-flow box also `clear`s, since a block box is laid *over* a float rather than beside it, and both word processors put it on the next free line), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). The node has its **own group** (`group: 'textBox'`) so only the document admits it (`extensions.ts` widens Document to `'(block | textBox)+'`) — never in table cells, lists, header/footer, or other boxes; imports found there are flattened with a warning. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native block drag moves it without hijacking text selection. Commands: `insertTextBox()` (after the current top-level block, cursor inside), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`). `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. `trailingNode.ts` appends a paragraph after a trailing box; `pageBreaks.ts` paginates `.textbox-node` atomically. **Spacing:** a box lifted out of an anchor paragraph that held nothing else stands in for it, so it takes that paragraph's resolved `fo:margin-top`/`-bottom` as `spaceBefore`/`spaceAfter` (`ParagraphSpacing` covers `textBox` for that). Space above is padding — it adds to the block above rather than collapsing against it — which means the node is `content-box` and the rotor, centred in the padding box, gets half of it back. ## Clicking a float diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 9c4bbb8..ba3d0db 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -30,7 +30,7 @@ ping-pong and keeps the current layout; only an edit or a forced recalc forgets rewrites its page numbers on every `pm-pagecount`, so resetting on any document change would leave nothing to catch). -**Keeping blocks together** (`pageBreak.ts` attrs, both read from the direct property *and* the paragraph style): `keepNext` (`w:keepNext`, `fo:keep-with-next`) moves a block down when the successor's first `MIN_KEPT_LINES` no longer fit below it — one line is not enough, because widow-orphan control would carry the second one over and strand the block anyway (measured on the Math Guide: a heading left alone at the foot of page 8) — headings do that in Word and LibreOffice anyway, so the attr only marks the others. `keepLines` (`w:keepLines`, `fo:keep-together`) makes an otherwise splittable block paginate atomically, unless it is taller than the page slot, where Word splits it too. A table's `keepRows` (ODF `style:may-break-between-rows="false"`, no Word equivalent) is the same rule for a table: pageBreaks emits one atomic leaf for the whole wrapper instead of one per row, so a table with no room left moves whole (probed: LibreOffice leaves four lines of the page empty rather than break it, and splits the identical table when the attribute says `true`). +**Keeping blocks together** (`pageBreak.ts` attrs, both read from the direct property *and* the paragraph style): `keepNext` (`w:keepNext`, `fo:keep-with-next`) moves a block down when the successor's first `MIN_KEPT_LINES` no longer fit below it — one line is not enough, because widow-orphan control would carry the second one over and strand the block anyway (measured on the Math Guide: a heading left alone at the foot of page 8) — headings do that in Word and LibreOffice anyway, so the attr only marks the others. `keepLines` (`w:keepLines`, `fo:keep-together`) makes an otherwise splittable block paginate atomically, unless it is taller than the page slot, where Word splits it too. A table's `keepRows` (ODF `style:may-break-between-rows="false"`, no Word equivalent) is the same rule for a table: pageBreaks emits one atomic leaf for the whole wrapper instead of one per row, so a table with no room left moves whole (probed: LibreOffice leaves four lines of the page empty rather than break it, and splits the identical table when the attribute says `true`). Rows a **cell spans** are grouped the same way (`rowSpanGroups`): the spanning cell is one DOM box, so a break between them stretches it across the gap instead of splitting it — LibreOffice keeps such rows on one page too (probed). A group taller than the page cannot be kept whole and breaks per row as before. **Line breaking:** `editor.css` overrides prosemirror-view's `white-space: break-spaces` back to `pre-wrap` on `.paper .tiptap`. Under `break-spaces` a line-end space may not hang into the margin and counts toward the line width, so lines break one word earlier than in Word/LibreOffice whenever a line reaches within a space width of the margin. diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 3742f1f..2632f9b 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -163,6 +163,28 @@ export function readVerticalMargins(dom: HTMLElement): VMargins { }; } +// Rows a cell spans move as one: that cell is a single box, so a break between them +// would stretch it across the gap instead of splitting it. Returns each row's group +// leader; a group too tall for a page cannot be kept whole and leads itself. +export function rowSpanGroups( + rows: { spans: number[]; top: number; height: number }[], contentHeight: number, +): number[] { + const leader = rows.map((_, i) => i); + for (let i = 0; i < rows.length; ) { + let end = i; + for (let j = i; j <= end; j++) { + for (const span of rows[j].spans) { + if (span > 1) end = Math.min(rows.length - 1, Math.max(end, j + span - 1)); + } + } + if (end > i && rows[end].top + rows[end].height - rows[i].top <= contentHeight) { + for (let j = i; j <= end; j++) leader[j] = i; + } + i = end + 1; + } + return leader; +} + // "fromPage|height,…" — the runs the last pass reported (Editor.svelte publishes them). // A document whose sections share one paper writes none, and the grid is uniform. export function gridFromRuns(raw: string, pageHeight: number): PageGrid { @@ -848,7 +870,20 @@ export const PageBreaks = Extension.create({ } const colgroup = tableEl?.querySelector('colgroup'); const columns = colgroup?.children.length || realRows[0].children.length || 1; + const leaderIdx = rowSpanGroups(realRows.map((r) => ({ + spans: (Array.from(r.children) as HTMLElement[]) + .map((c) => (c as HTMLTableCellElement).rowSpan || 1), + top: naturalTopOf(r), + height: r.offsetHeight, + })), contentHeight); + const groupLeader = new Map(); + leaderIdx.forEach((li, i) => { + if (li === i) return; + groupLeader.set(realRows[i], realRows[li]); + groupLeader.set(realRows[li], realRows[li]); + }); let seenRealRow = false; + let openGroup: Leaf | null = null; for (const tr of rowEls) { if (tr.dataset?.pageBreakSpacer) { cumulativeSpacerHeight += tr.offsetHeight; @@ -856,15 +891,26 @@ export const PageBreaks = Extension.create({ } if (tr.tagName !== 'TR') continue; const rowHeight = tr.offsetHeight; + const leader = groupLeader.get(tr); + if (openGroup && leader && leader !== tr) { + // A row its leader's cell spans into: it grows that leaf rather than + // opening a break candidate of its own. + openGroup.naturalHeight = naturalTopOf(tr) + rowHeight - openGroup.naturalTop; + seenRealRow = true; + continue; + } + openGroup = null; if (rowHeight <= contentHeight) { // Row fits on a page → atomic leaf; the table breaks between rows. - leaves.push({ + const rowLeaf: Leaf = { el: tr, kind: 'atomic', naturalTop: naturalTopOf(tr), naturalHeight: rowHeight, tableRow: { columns, wrapperEl, isFirstRow: !seenRealRow }, - }); + }; + leaves.push(rowLeaf); + if (leader === tr) openGroup = rowLeaf; } else { // Row taller than a page: each cell is an independent flow from the row // top, breaking in its own column. Walk every cell, resetting the spacer diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index dab31bb..e510333 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -527,11 +527,13 @@ class TextBoxView { if (a.wrap === 'left' || a.wrap === 'right') { d.style.float = a.wrap; d.style.margin = frameMargins(a.wrap, a.wrapOffset, this.wrapperWidth(), null, a.wrapDist); - } else if (a.wrap === 'topBottom') { - d.style.clear = 'both'; } else if (a.wrap === 'through') { // Behind the text, which is what a shape with no run-through of its own exports as. applyRunThrough(d, a.wrapOffset, a.wrapOffsetY, false); + } else { + // In flow: the frame is a block box, and CSS lays one over a float rather than + // beside it (only line boxes shorten), so it takes the next free line instead. + d.style.clear = 'both'; } // The anchor paragraph's spacing, which a lifted box stands in for: space above as // padding so it adds to the block above (editor.css), space below as the margin the diff --git a/tests/unit/table-rowspan-break.test.ts b/tests/unit/table-rowspan-break.test.ts new file mode 100644 index 0000000..a418f78 --- /dev/null +++ b/tests/unit/table-rowspan-break.test.ts @@ -0,0 +1,41 @@ +// A page break may not fall between rows a cell spans: the cell is one DOM box and +// would stretch across the gap. LibreOffice keeps such rows together too (probed). +import { describe, it, expect } from 'vitest'; +import { rowSpanGroups } from '../../src/lib/editor/extensions/pageBreaks'; + +const ROW = (spans: number[], i: number, height = 20) => ({ spans, top: i * height, height }); +const rows = (spansPerRow: number[][], height = 20) => + spansPerRow.map((spans, i) => ROW(spans, i, height)); + +describe('rowSpanGroups', () => { + it('leaves plain rows to break on their own', () => { + expect(rowSpanGroups(rows([[1, 1], [1, 1], [1, 1]]), 1000)).toEqual([0, 1, 2]); + }); + + it('joins the rows a cell spans to their leader', () => { + expect(rowSpanGroups(rows([[1, 2], [1], [1, 1]]), 1000)).toEqual([0, 0, 2]); + }); + + it('follows a span that reaches past the group it started', () => { + // Row 1's own span extends the group row 0 opened. + expect(rowSpanGroups(rows([[1, 2], [3], [1], [1], [1]]), 1000)).toEqual([0, 0, 0, 0, 4]); + }); + + it('never runs past the last row', () => { + expect(rowSpanGroups(rows([[1, 9]]), 1000)).toEqual([0]); + }); + + it('gives up on a group taller than the page: it breaks per row', () => { + // Three 20px rows = 60px of group against a 50px page. + expect(rowSpanGroups(rows([[3], [1], [1]]), 50)).toEqual([0, 1, 2]); + expect(rowSpanGroups(rows([[3], [1], [1]]), 60)).toEqual([0, 0, 0]); + }); + + it('keeps a later group when an earlier one is too tall', () => { + const r = [ + { spans: [2], top: 0, height: 40 }, { spans: [1], top: 40, height: 40 }, + { spans: [2], top: 80, height: 10 }, { spans: [1], top: 90, height: 10 }, + ]; + expect(rowSpanGroups(r, 50)).toEqual([0, 1, 2, 2]); + }); +});