feat: a table that may not break between rows moves whole
ODF style:may-break-between-rows="false" — probed: LibreOffice leaves four lines of a page empty rather than split such a table, and splits the identical one when the attribute says "true". pageBreaks now emits a single atomic leaf for the wrapper unless the table is taller than a page, where the rule cannot be met. Imported, exported and round-tripped; Word has no equivalent.
This commit is contained in:
1 parent
0d3506a51c
commit
db8f4439b1
7 files changed
+88
-19
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
attributes.keepRows === true ? { 'data-keep-rows': 'true' } : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+19
-15
@@ -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 <text:list-item> 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:style[^>]*style:name="Table${i + 1}"[^>]*>\\s*<style:table-properties)`),
|
||||
`$1${horiz}${vert}`,
|
||||
`$1${horiz}${vert}${keep}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2641,26 +2643,28 @@ function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[]
|
||||
return cm.map(v => `${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 <text:h>/<text:p>/<text:list>.
|
||||
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.
|
||||
|
||||
@@ -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<string, unknown> = { ...(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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user