fix: a table split across pages closes its box on both

Collapsed borders paint a shared edge once, so the spacer <tr> bridging
the gap took the row separator with it and left one fragment open —
visible in a fixture as tables missing their frame on a page. The spacer now
draws the line LibreOffice draws there: the separator the break falls on,
or the table's own box where the rows carry none. Absolutely positioned,
since a collapsed border on the spacer shifts every row below by half.
This commit is contained in:
Steffen Becker committed 2026-08-11 20:24:56 +00:00
1 parent 40d58463c7
commit 2803e6196e
2 files changed
+52 -8

No files matched your search

+2
View File
@@ -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 `<tr>` 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.
+50 -8
View File
@@ -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<PageBreakState>('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 <td>.
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 <tbody>).
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 <tr> 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 });
}