diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index a836025..821022d 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -28,7 +28,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. +**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`). **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/pageBreak.ts b/src/lib/editor/extensions/pageBreak.ts index 434c8fb..a2305ec 100644 --- a/src/lib/editor/extensions/pageBreak.ts +++ b/src/lib/editor/extensions/pageBreak.ts @@ -85,6 +85,21 @@ export const PageBreak = Extension.create({ }, }, }, + { + types: ['table'], + attributes: { + // ODF style:may-break-between-rows="false": no page break falls between two + // of the table's rows, so one too tall for the space left moves whole. A + // table taller than a page still breaks — the rule is then unsatisfiable. + keepRows: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-keep-rows') === 'true' ? true : null, + renderHTML: (attributes: Record) => + attributes.keepRows === true ? { 'data-keep-rows': 'true' } : {}, + }, + }, + }, ]; }, diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index b32e000..263c5e0 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -652,13 +652,23 @@ export const PageBreaks = Extension.create({ ? (Array.from(tbody.children) as HTMLElement[]) : []; const realRows = rowEls.filter(r => r.tagName === 'TR' && !r.dataset?.pageBreakSpacer); + const spacerHeight = rowEls + .filter(r => r.dataset?.pageBreakSpacer) + .reduce((sum, r) => sum + r.offsetHeight, 0); + // No break may fall between the rows (ODF style:may-break-between-rows), so + // the table moves whole — unless it is taller than a page, where the rule + // cannot be met and LibreOffice breaks it anyway. + const keepRows = tableEl?.dataset?.keepRows === 'true' + && wrapperEl.offsetHeight - spacerHeight <= contentHeight; // Fallback: no rows found — measure the whole wrapper as one atomic leaf. - if (realRows.length === 0) { + if (realRows.length === 0 || keepRows) { + const top = naturalTopOf(wrapperEl); + cumulativeSpacerHeight += spacerHeight; leaves.push({ el: wrapperEl, kind: 'atomic', - naturalTop: naturalTopOf(wrapperEl), - naturalHeight: wrapperEl.offsetHeight, + naturalTop: top, + naturalHeight: wrapperEl.offsetHeight - spacerHeight, }); return; } diff --git a/src/lib/editor/extensions/tableView.ts b/src/lib/editor/extensions/tableView.ts index 70a8038..16ea817 100644 --- a/src/lib/editor/extensions/tableView.ts +++ b/src/lib/editor/extensions/tableView.ts @@ -62,6 +62,9 @@ function applyTableStyleAttr(node: PMNode, table: HTMLElement): void { const look = node.attrs.tableLook as string | null; if (look != null) table.dataset.tableLook = look; else delete table.dataset.tableLook; + // pageBreaks.ts reads this to keep the table whole (ODF style:may-break-between-rows). + if (node.attrs.keepRows === true) table.dataset.keepRows = 'true'; + else delete table.dataset.keepRows; } // Also bypassed by the node view: the cell margins editor.css reads off the table. diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index c95a13b..4aef8ff 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -814,7 +814,7 @@ type CellBlock = | { kind: 'heading'; level: number; style: ParaStyle } | CellListBlock; // A table's own margins in cm (0/0 tables are recorded as null). -type TableMargins = { ml: number; mr: number; mt: number; mb: number }; +type TableProps = { ml: number; mr: number; mt: number; mb: number; keepRows: boolean }; // Collect the alignment + paragraph spacing of each listItem's first paragraph, // in DFS order — matching the order that odf-kit emits elements @@ -923,9 +923,10 @@ function expandCellPadding(odtBytes: Uint8Array): Uint8Array { } // A dragged table edge → the table style's fo:margin-left/-right + style:width (with -// odf-kit's table:align="margins" the table fills exactly what's left). odf-kit names -// table styles Table1, Table2, … in document order, matching the descriptor list. -function applyTableMargins(odtBytes: Uint8Array, margins: (TableMargins | null)[], contentWidthCm: number): Uint8Array { +// odf-kit's table:align="margins" the table fills exactly what's left), and keep-rows +// → style:may-break-between-rows. odf-kit names table styles Table1, Table2, … in +// document order, matching the descriptor list. +function applyTableProps(odtBytes: Uint8Array, margins: (TableProps | null)[], contentWidthCm: number): Uint8Array { if (margins.every(m => m === null)) return odtBytes; const files = unzipSync(odtBytes); @@ -939,9 +940,10 @@ function applyTableMargins(odtBytes: Uint8Array, margins: (TableMargins | null)[ const horiz = m.ml || m.mr ? ` style:width="${width}cm" fo:margin-left="${m.ml}cm" fo:margin-right="${m.mr}cm"` : ''; const vert = `${m.mt ? ` fo:margin-top="${m.mt}cm"` : ''}${m.mb ? ` fo:margin-bottom="${m.mb}cm"` : ''}`; + const keep = m.keepRows ? ' style:may-break-between-rows="false"' : ''; content = content.replace( new RegExp(`(]*style:name="Table${i + 1}"[^>]*>\\s* `${v}cm`); } -// A table's own left/right margins (cm, from the outer-edge drag), clamped into the -// text width. null = the table spans the full text width (the common case). -function tableMarginsCm(node: TiptapNode, contentWidthCm: number): TableMargins | null { +// What rides on a table's own automatic style: its left/right margins (cm, from the +// outer-edge drag, clamped into the text width) and whether a page break may fall +// between its rows. null = a full-width table that may break, the common case. +function tablePropsOf(node: TiptapNode, contentWidthCm: number): TableProps | null { const round3 = (v: number) => Math.round(v * 1000) / 1000; let ml = Math.max(0, Number(node.attrs?.marginLeft) || 0); let mr = Math.max(0, Number(node.attrs?.marginRight) || 0); const mt = Math.max(0, Number(node.attrs?.marginTop) || 0); const mb = Math.max(0, Number(node.attrs?.marginBottom) || 0); + const keepRows = node.attrs?.keepRows === true; if (ml + mr > contentWidthCm - 1) ml = mr = 0; - if (!ml && !mr && !mt && !mb) return null; - return { ml: round3(ml), mr: round3(mr), mt: round3(mt), mb: round3(mb) }; + if (!ml && !mr && !mt && !mb && !keepRows) return null; + return { ml: round3(ml), mr: round3(mr), mt: round3(mt), mb: round3(mb), keepRows }; } // Build an ODF table from a CUST_TABLE node, bypassing odf-kit's native walkTable to // pass an explicit cell border (the native path emits none → invisible). Column widths // come from tableColumnWidthsCm; when absent odf-kit distributes columns evenly. -function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][], tableMargins: (TableMargins | null)[], tableStyleNames: (TableStyleRef | null)[]): void { +function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][], tableMargins: (TableProps | null)[], tableStyleNames: (TableStyleRef | null)[]): void { const rows = (node.content ?? []).filter(r => r.type === 'tableRow'); if (rows.length === 0) return; - const margins = tableMarginsCm(node, contentWidthCm); + const margins = tablePropsOf(node, contentWidthCm); tableMargins.push(margins); // The named table style, if the registry still knows it: its name goes on the table's // automatic style (applyTableStyleNames), its text formatting is baked per cell. @@ -3410,8 +3414,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // Filled by exportTable, in document order, one CellBlock[] per table cell. // applyCellBlocks consumes it to rebuild real //. const cellBlocks: CellBlock[][] = []; - // One entry per table, same order — consumed by applyTableMargins. - const tableMargins: (TableMargins | null)[] = []; + // One entry per table, same order — consumed by applyTableProps. + const tableMargins: (TableProps | null)[] = []; // The named table style + its options per table, same order — applyTableStyleNames. const tableStyleNames: (TableStyleRef | null)[] = []; @@ -3550,7 +3554,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU collectTableRowHeights(raw, rowHeights); const styledRows = applyTableRowHeights(expandCellPadding(styledCells), rowHeights); const withTableMargins = applyTableStyleNames( - applyTableMargins(styledRows, tableMargins, contentWidthCm), tableStyleNames); + applyTableProps(styledRows, tableMargins, contentWidthCm), tableStyleNames); const cleaned = collapseRunWhitespace(withTableMargins); // Before applyInlineSentinels: it matches the bare style:type="char" a leader would hide. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 953b743..306a17e 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -2049,6 +2049,10 @@ function convertTable(el: Element, ctx: Ctx): Node | null { const named = ctx.resolver.namedAncestor(el.getAttributeNS(NS.table, 'style-name'), 'table'); const attrs: Record = { ...(tableMargins(el, ctx) ?? {}), ...tableSpacing(el, ctx) }; if (cellPad) attrs.cellPadding = cellPad; + // The default is to allow a break, so only the explicit "no" is worth an attr. + if (ctx.resolver.tableProps(el.getAttributeNS(NS.table, 'style-name'))['style:may-break-between-rows'] === 'false') { + attrs.keepRows = true; + } if (named) { attrs.tableStyle = displayStyleName(named); // Which conditional areas the table opts into (ODF's table template attributes). diff --git a/tests/unit/table-keep-rows.test.ts b/tests/unit/table-keep-rows.test.ts new file mode 100644 index 0000000..4fce14b --- /dev/null +++ b/tests/unit/table-keep-rows.test.ts @@ -0,0 +1,33 @@ +// ODF style:may-break-between-rows="false": the table moves whole rather than break. +import { describe, it, expect } from 'vitest'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../../src/lib/export/odt'; +import { importOdt } from '../../src/lib/import/odt'; + +type N = any; + +const cell = (text: string): N => ({ + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text }] }], +}); +const table = (attrs: N): N => ({ + type: 'table', + attrs, + content: [0, 1].map((r) => ({ type: 'tableRow', content: [cell(`a${r}`), cell(`b${r}`)] })), +}); + +describe('table keep-rows', () => { + it('round-trips style:may-break-between-rows', async () => { + const doc: N = { type: 'doc', content: [table({ keepRows: true })] }; + const bytes = await buildOdt(doc, undefined, 'portrait'); + expect(strFromU8(unzipSync(bytes)['content.xml'])).toContain('style:may-break-between-rows="false"'); + expect(importOdt(bytes).content.content?.[0]?.attrs?.keepRows).toBe(true); + }); + + it('leaves a table that may break without the attribute', async () => { + const doc: N = { type: 'doc', content: [table({})] }; + const bytes = await buildOdt(doc, undefined, 'portrait'); + expect(strFromU8(unzipSync(bytes)['content.xml'])).not.toContain('may-break-between-rows'); + expect(importOdt(bytes).content.content?.[0]?.attrs?.keepRows ?? null).toBe(null); + }); +});