diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md
index c0673af..05a9de8 100644
--- a/docs/architecture/pagination.md
+++ b/docs/architecture/pagination.md
@@ -79,6 +79,8 @@ page takes the "rest" pair. Not carried: the ruler, the header/footer layer and
**Tables across page breaks:** when a single continuous table box crosses a page boundary, the plugin reports `TableBreakBand`s (doc-px geometry). `Editor.svelte` renders an overlay (`.band-layer` inside `.paper`) that masks the table borders bleeding through the page margins and paints the dark page gap as one seam-free stripe.
+A break *between rows* instead closes the table on both sides of the gap: collapsed borders paint a shared edge only once, so the spacer `
` would leave one fragment open. `splitLines` (`pageBreaks.ts`) resolves what LibreOffice draws there — the row separator the break falls on, or, where the rows carry none, the table's own box (probed: its **top** border closes the fragment, its **bottom** border opens the continuation) — and the spacer cell renders it as two absolutely positioned lines. Out of flow deliberately: a collapsed border on the spacer itself moves every row below it down by half its width.
+
**Layout constants** (must stay in sync between `pageBreaks.ts`, `Editor.svelte`, and `editor.css`):
- `PAGE_HEIGHT = 1123px` (A4 portrait), `PAGE_GAP = 20px`, `CYCLE = PAGE_HEIGHT + PAGE_GAP = 1143px`.
- Page height/width and margins are read **live** from CSS custom properties (`--user-page-height`, `--user-page-width`, `--user-margin-*`) so orientation/margin changes don't require new constants. `getCycle()` in `Editor.svelte` reads `--user-page-height` at runtime.
diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts
index 2714a5d..f00afd9 100644
--- a/src/lib/editor/extensions/pageBreaks.ts
+++ b/src/lib/editor/extensions/pageBreaks.ts
@@ -97,9 +97,9 @@ export type TableBreakBand = {
key: number;
closeY: number; // content-end of the closing page (band/mask top)
height: number; // bandSpan = marginBottom + gap + marginTop
- // True when the break falls between whole table rows: the rows' own cell borders
- // already cap the table at the gap, so only the gap stripe is painted (no mask /
- // close-open lines, which would double the real borders). False for in-cell splits.
+ // True when the break falls between whole table rows: the spacer row draws the table's
+ // own close/open lines there, so only the gap stripe is painted (a mask would double
+ // them). False for in-cell splits.
rowBreak: boolean;
left: number; // content-area left (mask left)
width: number; // content-area width (mask width)
@@ -201,6 +201,11 @@ export const pageBreakKey = new PluginKey('pageBreaks');
// pagination recompute even though the document content is unchanged.
export const FORCE_PAGE_RECALC = 'forcePageBreakRecalc';
+// A table-row spacer: the colspan bridging the row, plus the lines that close the table
+// at the break — LibreOffice draws the row separator the break falls on, or, where the
+// rows carry none, the table's own box (its top border closes, its bottom border opens).
+type RowSpacer = { columns: number; close: string; open: string };
+
type Leaf = {
el: HTMLElement;
kind: 'atomic' | 'splittable';
@@ -416,9 +421,33 @@ export const PageBreaks = Extension.create({
return docPosBeforeElement(target);
}
+ // What a cell edge draws, as a border shorthand ('' where it draws nothing).
+ function edgeLine(cell: Element | null, side: 'Top' | 'Bottom'): string {
+ if (!cell) return '';
+ const cs = getComputedStyle(cell);
+ const width = side === 'Top' ? cs.borderTopWidth : cs.borderBottomWidth;
+ const style = side === 'Top' ? cs.borderTopStyle : cs.borderBottomStyle;
+ const color = side === 'Top' ? cs.borderTopColor : cs.borderBottomColor;
+ return parseFloat(width) > 0 && style !== 'none' ? `${width} ${style} ${color}` : '';
+ }
+
+ // The lines closing the table where a break falls before row `tr` (see RowSpacer).
+ // The first cell speaks for the row: a spacer bridges every column with one | .
+ function splitLines(tr: HTMLElement): { close: string; open: string } {
+ const cell = (r: Element | null | undefined) => r?.firstElementChild ?? null;
+ const sep = edgeLine(cell(tr), 'Top') || edgeLine(cell(previousNonSpacerSibling(tr)), 'Bottom');
+ if (sep) return { close: sep, open: sep };
+ const rows = Array.from(tr.parentElement?.children ?? [])
+ .filter((r) => r.tagName === 'TR' && !(r as HTMLElement).dataset?.pageBreakSpacer);
+ return {
+ close: edgeLine(cell(rows[0]), 'Top'),
+ open: edgeLine(cell(rows[rows.length - 1]), 'Bottom'),
+ };
+ }
+
// Where to place a leaf's page-break spacer, and whether it must be a
// table row (so the widget is valid markup inside a | ).
- function leafSpacer(leaf: Leaf): { docPos: number | null; row: { columns: number } | null } {
+ function leafSpacer(leaf: Leaf): { docPos: number | null; row: RowSpacer | null } {
if (leaf.tableRow) {
// First row crossing the boundary → push the whole table with a block
// spacer before the wrapper (otherwise the table's top would be
@@ -428,7 +457,10 @@ export const PageBreaks = Extension.create({
}
// Later rows → a spacer before the row pushes it (and the rows
// after it) to the next page while the table stays one element.
- return { docPos: docPosBeforeElement(leaf.el), row: { columns: leaf.tableRow.columns } };
+ return {
+ docPos: docPosBeforeElement(leaf.el),
+ row: { columns: leaf.tableRow.columns, ...splitLines(leaf.el) },
+ };
}
return { docPos: preLeafDocPos(leaf.el), row: null };
}
@@ -958,7 +990,7 @@ export const PageBreaks = Extension.create({
const placements: {
docPos: number;
height: number;
- row: { columns: number } | null;
+ row: RowSpacer | null;
}[] = [];
// Node-decoration range collapsing the trailing empty paragraph after a
// document-final columns chain when it sits past the page content area.
@@ -1013,7 +1045,7 @@ export const PageBreaks = Extension.create({
const breaks: {
height: number;
docPos: number | null;
- row: { columns: number } | null;
+ row: RowSpacer | null;
bandOpenY: number | null;
reason: string;
}[] = [];
@@ -1343,7 +1375,7 @@ export const PageBreaks = Extension.create({
// previous pass. Most keystrokes don't change pagination, and re-dispatching
// recreates the spacer DOM nodes (no `key` on the widgets) for no gain.
const placementsKey =
- placements.map((p) => `${p.docPos}:${p.height}:${p.row ? p.row.columns : 'b'}`).join('|') +
+ placements.map((p) => `${p.docPos}:${p.height}:${p.row ? `${p.row.columns}${p.row.close}${p.row.open}` : 'b'}`).join('|') +
(collapsedTrailing ? `|c${collapsedTrailing.from}` : '') +
pageTopBlocks.map((b) => `|t${b.from}`).join('') +
Array.from(sectionInsets, ([f, s]) => `|i${f}:${s.left}:${s.right}`).join('');
@@ -1365,6 +1397,16 @@ export const PageBreaks = Extension.create({
tdEl.dataset.pageBreakSpacerCell = 'true';
tdEl.setAttribute('colspan', String(p.row.columns));
tdEl.style.height = `${p.height}px`;
+ // Collapsed borders paint a shared edge once, so without these lines the
+ // gap keeps it and one of the two fragments ends unclosed. Out of flow:
+ // a collapsed border on the spacer itself moves every row below by half.
+ tdEl.style.position = 'relative';
+ for (const [border, side] of [[p.row.close, 'top'], [p.row.open, 'bottom']] as const) {
+ if (!border) continue;
+ const line = document.createElement('div');
+ line.style.cssText = `position:absolute;left:0;right:0;${side}:0;border-${side}:${border}`;
+ tdEl.appendChild(line);
+ }
trEl.appendChild(tdEl);
return Decoration.widget(p.docPos, trEl, { side: -1 });
}