From ca4fbd422b5f67d632a48d844e167b404bba083a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 31 Jul 2026 17:46:26 +0200 Subject: [PATCH] feat: table margin edge resize --- .../editor/extensions/tableColumnResize.ts | 262 +++++++++++++----- src/lib/editor/extensions/tableView.ts | 11 + src/lib/export/docx.ts | 9 +- src/lib/export/odt.ts | 50 +++- src/lib/import/docx.ts | 39 ++- src/lib/import/odt.ts | 44 ++- src/lib/import/styleResolver.ts | 6 + src/styles/editor.css | 9 +- tests/docx-roundtrip.test.ts | 26 ++ tests/roundtrip.test.ts | 37 +++ tests/unit/table-edge-resize.test.ts | 79 ++++++ 11 files changed, 494 insertions(+), 78 deletions(-) create mode 100644 tests/unit/table-edge-resize.test.ts diff --git a/src/lib/editor/extensions/tableColumnResize.ts b/src/lib/editor/extensions/tableColumnResize.ts index 04d0f93..a053759 100644 --- a/src/lib/editor/extensions/tableColumnResize.ts +++ b/src/lib/editor/extensions/tableColumnResize.ts @@ -5,25 +5,49 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view'; import type { EditorView } from '@tiptap/pm/view'; import { TableMap, cellAround, pointsAtCell } from '@tiptap/pm/tables'; import { columnWeightsFromRow, columnPercents } from './tableView'; +import { PX_PER_CM } from '../../storage/pageMargins'; -// Word-style column resizing: dragging the border between columns i and i+1 trades -// width between only those two (their sum stays constant, table stays full width). -// Live drag pokes the TableView elements; release writes `colwidth` weights. +// Word-style column resizing: an inner border trades width between its two columns +// (table width fixed); an outer edge moves the table's `marginLeft`/`marginRight` (cm) +// and the adjacent column. Live drag pokes the DOM; release writes the attrs. const HANDLE_WIDTH = 5; // px from a column border that activates the handle const CELL_MIN_PX = 30; // smallest a column may be dragged to // Resolution of the stored integer weights (percent × this). Only ratios matter. const WEIGHT_SCALE = 100; +// Which border of the active cell is hovered: a column border, or the table's own +// left/right edge (the first/last column's outer side). +type EdgeSide = 'inner' | 'left' | 'right'; + interface ResizeState { - activeHandle: number; // doc pos of the cell whose RIGHT edge is active, or -1 + activeHandle: number; // doc pos of the cell owning the active border, or -1 + edge: EdgeSide; dragging: boolean; } type ResizeAction = - | { setHandle: number } + | { setHandle: number; edge: EdgeSide } | { setDragging: boolean }; +const round3 = (v: number) => Math.round(v * 1000) / 1000; + +// A CSS margin (our own output is cm, pasted HTML may be px) as cm; 0 when absent. +function marginCm(value: string): number { + const n = parseFloat(value); + if (!Number.isFinite(n) || n <= 0) return 0; + return value.trim().endsWith('px') ? round3(n / PX_PER_CM) : round3(n); +} + +// Inline style for the static HTML paths: the wrapper-less carries the margins +// itself, so its width must shrink by them (editor.css has it at width:100%). +function tableMarginStyle(attrs: Record): Record { + const ml = Number(attrs.marginLeft) || 0; + const mr = Number(attrs.marginRight) || 0; + if (!ml && !mr) return {}; + return { style: `margin-left: ${ml}cm; margin-right: ${mr}cm; width: calc(100% - ${round3(ml + mr)}cm)` }; +} + export const tableColumnResizeKey = new PluginKey('tableColumnResize'); // ─── DOM / geometry helpers (mirrors prosemirror-tables) ─────────────────── @@ -36,30 +60,28 @@ export function domCellAround(target: HTMLElement | null): HTMLElement | null { return el; } -// Resolve the cell adjacent to a column border near the pointer. For "right" the -// returned pos is the cell on the left of the border; for "left" it's the cell -// left of the border before the pointer's cell. Returns -1 at the outer edges. -function edgeCell(view: EditorView, event: MouseEvent, side: 'left' | 'right'): number { +// Resolve the border near the pointer: the cell that owns it plus which border it is. +// For "right" that's the cell left of the border, for "left" the cell right of it — +// unless the border is the table's own outer edge (first/last column). cell -1 = none. +function borderAt(view: EditorView, event: MouseEvent, side: 'left' | 'right'): { cell: number; edge: EdgeSide } { + const none = { cell: -1, edge: 'inner' as EdgeSide }; const offset = side === 'right' ? -HANDLE_WIDTH : HANDLE_WIDTH; const found = view.posAtCoords({ left: event.clientX + offset, top: event.clientY }); - if (!found) return -1; + if (!found) return none; const $cell = cellAround(view.state.doc.resolve(found.pos)); - if (!$cell) return -1; - if (side === 'right') return $cell.pos; + if (!$cell) return none; const map = TableMap.get($cell.node(-1)); const start = $cell.start(-1); - const index = map.map.indexOf($cell.pos - start); - return index % map.width === 0 ? -1 : start + map.map[index - 1]; -} - -// The rightmost column index covered by the cell at `cellPos`. -function rightColOf(state: EditorState, cellPos: number): { col: number; width: number } { - const $cell = state.doc.resolve(cellPos); - const table = $cell.node(-1); - const map = TableMap.get(table); - const start = $cell.start(-1); + if (side === 'left') { + const index = map.map.indexOf($cell.pos - start); + if (index < 0) return none; + return index % map.width === 0 + ? { cell: $cell.pos, edge: 'left' } + : { cell: start + map.map[index - 1], edge: 'inner' }; + } const colspan = ($cell.nodeAfter?.attrs.colspan as number) ?? 1; - return { col: map.colCount($cell.pos - start) + colspan - 1, width: map.width }; + const col = map.colCount($cell.pos - start) + colspan - 1; + return { cell: $cell.pos, edge: col >= map.width - 1 ? 'right' : 'inner' }; } function tableDOM(view: EditorView, tableStart: number): HTMLTableElement | null { @@ -77,26 +99,52 @@ function handleMouseMove(view: EditorView, event: MouseEvent): void { const target = domCellAround(event.target as HTMLElement | null); let cell = -1; + let edge: EdgeSide = 'inner'; if (target) { const { left, right } = target.getBoundingClientRect(); - if (event.clientX - left <= HANDLE_WIDTH) cell = edgeCell(view, event, 'left'); - else if (right - event.clientX <= HANDLE_WIDTH) cell = edgeCell(view, event, 'right'); + if (event.clientX - left <= HANDLE_WIDTH) ({ cell, edge } = borderAt(view, event, 'left')); + else if (right - event.clientX <= HANDLE_WIDTH) ({ cell, edge } = borderAt(view, event, 'right')); } - // Only internal borders are draggable — the outer right edge would change the - // table width, which we never want (the table stays full text width). - if (cell !== -1) { - const { col, width } = rightColOf(view.state, cell); - if (col >= width - 1) cell = -1; - } - - if (cell !== state.activeHandle) { - view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: cell } as ResizeAction)); + if (cell !== state.activeHandle || edge !== state.edge) { + view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: cell, edge } as ResizeAction)); } } -// Write the final per-column weights into every cell's `colwidth` attribute. -function commitColumnWidths(view: EditorView, activeHandle: number, finalPercents: number[]): void { +// Outer-edge drag, Word-style: only the adjacent column gives up width, so every +// other gridline stays put. All lengths in unscaled document px; the edge stops at +// the text margin (margins never go negative) and at the column's minimum width. +export function edgeResize( + percents: number[], + tableWidth: number, + marginLeftPx: number, + marginRightPx: number, + side: 'left' | 'right', + dx: number, +): { marginLeftPx: number; marginRightPx: number; percents: number[] } { + const abs = percents.map(p => (p / 100) * tableWidth); + const i = side === 'left' ? 0 : abs.length - 1; + const delta = side === 'left' + ? Math.max(-marginLeftPx, Math.min(abs[i] - CELL_MIN_PX, dx)) + : Math.max(CELL_MIN_PX - abs[i], Math.min(marginRightPx, dx)); + abs[i] += side === 'left' ? -delta : delta; + const total = abs.reduce((a, b) => a + b, 0); + return { + marginLeftPx: side === 'left' ? marginLeftPx + delta : marginLeftPx, + marginRightPx: side === 'right' ? marginRightPx - delta : marginRightPx, + percents: abs.map(w => (w / total) * 100), + }; +} + +// Write the final per-column weights into every cell's `colwidth` attribute, plus the +// table's own margins when an outer edge was dragged (sizes are unchanged, so both +// markups can share one transaction). +function commitColumnWidths( + view: EditorView, + activeHandle: number, + finalPercents: number[], + tableMargins?: { marginLeft: number; marginRight: number }, +): void { const $cell = view.state.doc.resolve(activeHandle); const table = $cell.node(-1); const map = TableMap.get(table); @@ -121,13 +169,76 @@ function commitColumnWidths(view: EditorView, activeHandle: number, finalPercent tr.setNodeMarkup(start + pos, undefined, { ...cellNode.attrs, colwidth }); } } + if (tableMargins) tr.setNodeMarkup(start - 1, undefined, { ...table.attrs, ...tableMargins }); if (tr.docChanged) view.dispatch(tr); } +// Drag of the table's left/right edge: moves that edge only (margin + adjacent column). +function startEdgeDrag(view: EditorView, event: MouseEvent, cellPos: number, side: 'left' | 'right'): boolean { + const $cell = view.state.doc.resolve(cellPos); + const table = $cell.node(-1); + const tableStart = $cell.start(-1); + const startPercents = columnPercents(columnWeightsFromRow(table.firstChild)); + if (startPercents.length !== TableMap.get(table).width) return false; + + const dom = tableDOM(view, tableStart); + const wrapper = dom?.parentElement; + const colgroup = dom?.querySelector(':scope > colgroup') as HTMLElement | null; + if (!dom || !wrapper || !colgroup) return false; + const tableWidth = dom.offsetWidth; // unscaled document px + if (!tableWidth) return false; + // offsetWidth is unscaled, getBoundingClientRect is scaled by the .paper zoom: + // their ratio converts the mouse delta back to document px (mirrors tableRowResize). + const zoom = dom.getBoundingClientRect().width / tableWidth || 1; + + const ml0 = (((table.attrs.marginLeft as number) || 0)) * PX_PER_CM; + const mr0 = (((table.attrs.marginRight as number) || 0)) * PX_PER_CM; + const startX = event.clientX; + let last = { marginLeftPx: ml0, marginRightPx: mr0, percents: startPercents }; + let moved = false; + const win = view.dom.ownerDocument.defaultView ?? window; + + view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setDragging: true } as ResizeAction)); + + function move(e: MouseEvent): void { + if (!e.buttons) { + finish(); + return; + } + last = edgeResize(startPercents, tableWidth, ml0, mr0, side, (e.clientX - startX) / zoom); + wrapper!.style.marginLeft = `${last.marginLeftPx}px`; + wrapper!.style.marginRight = `${last.marginRightPx}px`; + const cols = colgroup!.children as HTMLCollectionOf; + last.percents.forEach((p, i) => { if (cols[i]) cols[i].style.width = `${p}%`; }); + moved = true; + } + + function finish(): void { + win.removeEventListener('mouseup', finish); + win.removeEventListener('mousemove', move); + const st = tableColumnResizeKey.getState(view.state); + if (st?.dragging) { + if (moved) { + commitColumnWidths(view, cellPos, last.percents, { + marginLeft: round3(last.marginLeftPx / PX_PER_CM), + marginRight: round3(last.marginRightPx / PX_PER_CM), + }); + } + view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setDragging: false } as ResizeAction)); + } + } + + win.addEventListener('mouseup', finish); + win.addEventListener('mousemove', move); + event.preventDefault(); + return true; +} + function handleMouseDown(view: EditorView, event: MouseEvent): boolean { if (!view.editable) return false; const state = tableColumnResizeKey.getState(view.state); if (!state || state.activeHandle === -1 || state.dragging) return false; + if (state.edge !== 'inner') return startEdgeDrag(view, event, state.activeHandle, state.edge); const activeHandle = state.activeHandle; const $cell = view.state.doc.resolve(activeHandle); @@ -137,7 +248,6 @@ function handleMouseDown(view: EditorView, event: MouseEvent): boolean { const colspan = ($cell.nodeAfter?.attrs.colspan as number) ?? 1; const leftCol = map.colCount($cell.pos - tableStart) + colspan - 1; const rightCol = leftCol + 1; - if (rightCol > map.width - 1) return false; // outer edge, never resizable const startPercents = columnPercents(columnWeightsFromRow(table.firstChild)); if (startPercents.length !== map.width) return false; @@ -202,7 +312,7 @@ function handleMouseDown(view: EditorView, event: MouseEvent): boolean { // ─── Decorations: the visible handle at the active column border ───────────── -function handleDecorations(state: EditorState, cell: number): DecorationSet { +function handleDecorations(state: EditorState, cell: number, edge: EdgeSide): DecorationSet { const decorations: Decoration[] = []; const $cell = state.doc.resolve(cell); const table = $cell.node(-1); @@ -210,31 +320,32 @@ function handleDecorations(state: EditorState, cell: number): DecorationSet { const map = TableMap.get(table); const start = $cell.start(-1); const colspan = ($cell.nodeAfter?.attrs.colspan as number) ?? 1; - const col = map.colCount($cell.pos - start) + colspan - 1; + const col = edge === 'left' ? 0 : map.colCount($cell.pos - start) + colspan - 1; const dragging = tableColumnResizeKey.getState(state)?.dragging; for (let row = 0; row < map.height; row++) { const index = col + row * map.width; - // Skip cells that span across this border (right) or merge into the row above. - if ( - (col === map.width - 1 || map.map[index] !== map.map[index + 1]) && - (row === 0 || map.map[index] !== map.map[index - map.width]) - ) { - const cellPos = map.map[index]; - const cellNode = table.nodeAt(cellPos); - if (!cellNode) continue; - const pos = start + cellPos + cellNode.nodeSize - 1; - const dom = document.createElement('div'); - dom.className = 'column-resize-handle'; - if (dragging) { - decorations.push( - Decoration.node(start + cellPos, start + cellPos + cellNode.nodeSize, { - class: 'column-resize-dragging', - }), - ); - } - decorations.push(Decoration.widget(pos, dom)); + // Skip cells that span across this border (irrelevant at the left edge) or that + // merge into the row above. + const spansBorder = edge !== 'left' && col < map.width - 1 && map.map[index] === map.map[index + 1]; + const mergedUp = row > 0 && map.map[index] === map.map[index - map.width]; + if (spansBorder || mergedUp) continue; + + const cellPos = map.map[index]; + const cellNode = table.nodeAt(cellPos); + if (!cellNode) continue; + // Widgets sit INSIDE the cell (which is the handle's positioning context). + const pos = edge === 'left' ? start + cellPos + 1 : start + cellPos + cellNode.nodeSize - 1; + const dom = document.createElement('div'); + dom.className = edge === 'left' ? 'column-resize-handle left' : 'column-resize-handle'; + if (dragging) { + decorations.push( + Decoration.node(start + cellPos, start + cellPos + cellNode.nodeSize, { + class: 'column-resize-dragging', + }), + ); } + decorations.push(Decoration.widget(pos, dom)); } return DecorationSet.create(state.doc, decorations); } @@ -244,20 +355,43 @@ function handleDecorations(state: EditorState, cell: number): DecorationSet { export const TableColumnResize = Extension.create({ name: 'tableColumnResize', + // The table's own margins (cm, 0 = flush with the text area), set by the outer-edge + // drag. Rendered on the wrapper by TableView; the inline style here serves the + // static HTML paths (generateHTML → PDF export, clipboard round trip). + addGlobalAttributes() { + return [ + { + types: ['table'], + attributes: { + marginLeft: { + default: 0, + parseHTML: (element: HTMLElement) => marginCm(element.style.marginLeft), + renderHTML: (attributes: Record) => tableMarginStyle(attributes), + }, + marginRight: { + default: 0, + parseHTML: (element: HTMLElement) => marginCm(element.style.marginRight), + renderHTML: () => ({}), + }, + }, + }, + ]; + }, + addProseMirrorPlugins() { return [ new Plugin({ key: tableColumnResizeKey, state: { - init: (): ResizeState => ({ activeHandle: -1, dragging: false }), + init: (): ResizeState => ({ activeHandle: -1, edge: 'inner', dragging: false }), apply(tr: Transaction, prev: ResizeState): ResizeState { const action = tr.getMeta(tableColumnResizeKey) as ResizeAction | undefined; - if (action && 'setHandle' in action) return { activeHandle: action.setHandle, dragging: false }; - if (action && 'setDragging' in action) return { activeHandle: prev.activeHandle, dragging: action.setDragging }; + if (action && 'setHandle' in action) return { activeHandle: action.setHandle, edge: action.edge, dragging: false }; + if (action && 'setDragging' in action) return { ...prev, dragging: action.setDragging }; if (prev.activeHandle > -1 && tr.docChanged) { let handle = tr.mapping.map(prev.activeHandle, -1); if (!pointsAtCell(tr.doc.resolve(handle))) handle = -1; - return { activeHandle: handle, dragging: prev.dragging }; + return { activeHandle: handle, edge: prev.edge, dragging: prev.dragging }; } return prev; }, @@ -276,7 +410,7 @@ export const TableColumnResize = Extension.create({ mouseleave: (view) => { const s = tableColumnResizeKey.getState(view.state); if (s && s.activeHandle > -1 && !s.dragging) { - view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: -1 } as ResizeAction)); + view.dispatch(view.state.tr.setMeta(tableColumnResizeKey, { setHandle: -1, edge: 'inner' } as ResizeAction)); } return false; }, @@ -284,7 +418,7 @@ export const TableColumnResize = Extension.create({ }, decorations: (state) => { const s = tableColumnResizeKey.getState(state); - if (s && s.activeHandle > -1) return handleDecorations(state, s.activeHandle); + if (s && s.activeHandle > -1) return handleDecorations(state, s.activeHandle, s.edge); return null; }, }, diff --git a/src/lib/editor/extensions/tableView.ts b/src/lib/editor/extensions/tableView.ts index e5620d9..67281c7 100644 --- a/src/lib/editor/extensions/tableView.ts +++ b/src/lib/editor/extensions/tableView.ts @@ -36,6 +36,15 @@ export function columnPercents(weights: (number | null)[]): number[] { return filled.map((w) => (w / total) * 100); } +// The table's own margins (cm) go on the wrapper; the table stays 100% of it, so a +// dragged outer edge (tableColumnResize.ts) narrows the table without touching layout. +function applyMargins(node: PMNode, wrapper: HTMLElement): void { + const ml = (node.attrs.marginLeft as number) || 0; + const mr = (node.attrs.marginRight as number) || 0; + wrapper.style.marginLeft = ml ? `${ml}cm` : ''; + wrapper.style.marginRight = mr ? `${mr}cm` : ''; +} + function buildColgroup(node: PMNode, colgroup: HTMLElement): void { while (colgroup.firstChild) colgroup.removeChild(colgroup.firstChild); const percents = columnPercents(columnWeightsFromRow(node.firstChild)); @@ -59,6 +68,7 @@ export class TableView { this.cellMinWidth = cellMinWidth; this.dom = document.createElement('div'); this.dom.className = 'tableWrapper'; + applyMargins(node, this.dom); this.table = this.dom.appendChild(document.createElement('table')); // Percentage columns are authoritative only under fixed layout; the actual // table-layout/border-collapse come from editor.css. width:100% keeps the @@ -72,6 +82,7 @@ export class TableView { update(node: PMNode): boolean { if (node.type !== this.node.type) return false; this.node = node; + applyMargins(node, this.dom); buildColgroup(node, this.colgroup); return true; } diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 409b8cf..160e314 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -700,9 +700,13 @@ function cellBlocksToDocx(content: TiptapNode[] = [], headerBold: boolean, num: function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): Table { const rows = (node.content ?? []).filter((r) => r.type === 'tableRow'); - const colsCm = columnWidthsCm(node, contentWidthCm); + // A dragged table edge (tableColumnResize.ts) → w:tblInd + the narrower grid. + let ml = Math.max(0, Number(node.attrs?.marginLeft) || 0); + let mr = Math.max(0, Number(node.attrs?.marginRight) || 0); + if (ml + mr > contentWidthCm - 1) { ml = 0; mr = 0; } + const colsCm = columnWidthsCm(node, contentWidthCm - ml - mr); const colsTwip = colsCm?.map(cmToTwip); - const totalTwip = colsTwip ? colsTwip.reduce((a, b) => a + b, 0) : cmToTwip(contentWidthCm); + const totalTwip = colsTwip ? colsTwip.reduce((a, b) => a + b, 0) : cmToTwip(contentWidthCm - ml - mr); const tableRows = rows.map((row) => { const rh = row.attrs?.rowHeight; @@ -740,6 +744,7 @@ function tableToDocx(node: TiptapNode, contentWidthCm: number, num: Numbering): rows: tableRows, columnWidths: colsTwip, width: { size: totalTwip, type: WidthType.DXA }, + indent: ml ? { size: cmToTwip(ml), type: WidthType.DXA } : undefined, layout: TableLayoutType.FIXED, margins: { marginUnitType: WidthType.DXA, top: cmToTwip(0.05), bottom: cmToTwip(0.05), left: cmToTwip(0.1), right: cmToTwip(0.1) }, borders: { diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 79db6fb..5b4b95a 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -645,6 +645,8 @@ type CellBlock = | { kind: 'paragraph'; style: ParaStyle } | { 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 }; // Collect the alignment + paragraph spacing of each listItem's first paragraph, // in DFS order — matching the order that odf-kit emits elements @@ -730,6 +732,30 @@ function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]): return rezipOdt(files); } +// 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 { + if (margins.every(m => m === null)) return odtBytes; + + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + + let content = strFromU8(contentBytes); + margins.forEach((m, i) => { + if (!m) return; + const width = Math.round((contentWidthCm - m.ml - m.mr) * 1000) / 1000; + 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 { + const round3 = (v: number) => Math.round(v * 1000) / 1000; + const ml = Math.max(0, Number(node.attrs?.marginLeft) || 0); + const mr = Math.max(0, Number(node.attrs?.marginRight) || 0); + if (!ml && !mr) return null; + if (ml + mr > contentWidthCm - 1) return null; + return { ml: round3(ml), mr: round3(mr) }; +} + // 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[][]): void { +function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][], tableMargins: (TableMargins | null)[]): void { const rows = (node.content ?? []).filter(r => r.type === 'tableRow'); if (rows.length === 0) return; - const columnWidths = tableColumnWidthsCm(node, contentWidthCm); + const margins = tableMarginsCm(node, contentWidthCm); + tableMargins.push(margins); + const columnWidths = tableColumnWidthsCm(node, contentWidthCm - (margins ? margins.ml + margins.mr : 0)); doc.addTable((t: TableBuilder) => { for (const row of rows) { t.addRow((r: RowBuilder) => { @@ -2624,6 +2663,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)[] = []; const odt = await tiptapToOdt(json, { // Orientation sets style:print-orientation; rewriteStylesXml overrides the exact @@ -2643,7 +2684,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU return; } if (node.type === CUST_TABLE) { - exportTable(node, doc, contentWidthCm, cellBlocks); + exportTable(node, doc, contentWidthCm, cellBlocks, tableMargins); return; } const opts: { @@ -2735,8 +2776,9 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const rowHeights: (string | null)[] = []; collectTableRowHeights(raw, rowHeights); const styledRows = applyTableRowHeights(styledCells, rowHeights); + const withTableMargins = applyTableMargins(styledRows, tableMargins, contentWidthCm); - const cleaned = collapseRunWhitespace(styledRows); + const cleaned = collapseRunWhitespace(withTableMargins); const withBreaks = applyInlineSentinels(cleaned); const withImages = applyImages(withBreaks, images); const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null); diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 9f6627b..0c47ea8 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -41,6 +41,8 @@ type Ctx = { convertedImages: ConvertedImages; pendingBlocks: Node[]; listCounters: Map>; // numId → ilvl → last number used + // Text width (cm) of the file's page setup; a table's margins are relative to it. + contentWidthCm: number; }; // ---- units & editor defaults to suppress ----------------------------------- @@ -115,11 +117,12 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = const defaultStyleId = styles.defaultParagraphStyle(); for (const [id, def] of styles.namedParagraphStyles()) styleNames.set(id, registryName(id, def.name, id === defaultStyleId)); const charStyleNames = styles.namedCharacterStyles(); - const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map() }; - const body = docDoc.getElementsByTagNameNS(W, 'body')[0]; if (!body) throw new Error('Not a Word document (no w:body).'); + const contentWidthCm = sectionContentWidthCm(fc(body, 'sectPr')); + const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm }; + // Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares // more than one column becomes a columns node (the trailing group is described // by the body-final sectPr, covering whole-document multi-column files). @@ -1296,7 +1299,28 @@ function convertTable(tbl: Element, ctx: Ctx): Node | null { if (h && h > 0) row.attrs = { rowHeight: Math.round(twipToPx(h)) }; rows.push(row); } - return rows.length ? { type: 'table', content: rows } : null; + if (rows.length === 0) return null; + const margins = tableMargins(tbl, useWeights, ctx); + return margins ? { type: 'table', attrs: margins, content: rows } : { type: 'table', content: rows }; +} + +// A table narrower than the text width: w:tblInd is its left indent, the grid (or +// w:tblW) its width — the rest becomes the editor's right margin. +function tableMargins(tbl: Element, weights: number[] | null, ctx: Ctx): { marginLeft: number; marginRight: number } | null { + const tblPr = fc(tbl, 'tblPr'); + const content = ctx.contentWidthCm; + const dxa = (el: Element | null) => (el?.getAttributeNS(W, 'type') ?? 'dxa') === 'dxa' ? intAttr(el, W, 'w') : null; + const left = Math.max(0, twipToCm(dxa(fc(tblPr, 'tblInd')) ?? 0)); + const declared = dxa(fc(tblPr, 'tblW')); + const width = declared && declared > 0 + ? twipToCm(declared) + : weights ? twipToCm(weights.reduce((a, b) => a + b, 0)) : null; + const right = width != null ? Math.max(0, content - left - width) : 0; + + if (left < 0.05 && right < 0.05) return null; + if (left + right > content - 1) return null; + const round2 = (v: number) => Math.round(v * 100) / 100; + return { marginLeft: round2(left), marginRight: round2(right) }; } function flattenTable(tbl: Element, ctx: Ctx): Node[] { @@ -1306,6 +1330,15 @@ function flattenTable(tbl: Element, ctx: Ctx): Node[] { } // ---- section: page geometry + headers/footers ------------------------------ +// Text width of a section (A4 with Word's 2.54cm margins when it declares none). +function sectionContentWidthCm(sect: Element | null): number { + const w = intAttr(fc(sect, 'pgSz'), W, 'w') ?? 11906; + const pgMar = fc(sect, 'pgMar'); + const left = intAttr(pgMar, W, 'left') ?? 1440; + const right = intAttr(pgMar, W, 'right') ?? 1440; + return Math.max(1, twipToCm(w - left - right)); +} + // Split body children into sections: a whose pPr carries a sectPr ends the // group before it (dropped unless it has run content). The trailing group has // sectPr null — the caller pairs it with the body-final . diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 3105b6c..dd50989 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -9,7 +9,7 @@ import { matchFormat, toDateValue, type Token } from '../utils/dateTime'; import { imageDataUrl, type ConvertedImages } from './imageFormats'; import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins'; import type { Orientation } from '../storage/pageOrientation'; -import type { PageFormat } from '../storage/pageFormat'; +import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc } from '../storage/headerFooter'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; @@ -80,6 +80,8 @@ type Ctx = { imageCache: Map; convertedImages: ConvertedImages; pendingBlocks: Node[]; + // Text width (cm) of the file's page setup; a table's margins are relative to it. + contentWidthCm: number; }; // Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format @@ -349,7 +351,13 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = for (const [name, def] of resolver.namedParagraphStyles()) styleNames.set(name, displayStyleName(name, def.display)); const charStyleNames = new Map(); for (const [name, def] of resolver.namedTextStyles()) charStyleNames.set(name, displayStyleName(name, def.display)); - const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [] }; + // Text width of the file's own page setup — the reference a table's margins are + // measured against (falls back to A4 with the ODF default margins). + const geo = resolver.pageGeometry(); + const contentWidthCm = geo + ? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right + : pageDimsCm('A4', 'portrait').w - 2 * 2.12; + const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm }; let blocks = convertBlocks(Array.from(body.children), ctx, 'body'); if (blocks.length === 0) blocks.push({ type: 'paragraph' }); @@ -364,7 +372,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = const hf = resolver.masterPageHF(); - const geometry = resolver.pageGeometry(); + const geometry = geo; const edge = resolver.edgeDistancesCm(); const fonts: EmbeddedFont[] = []; @@ -1496,7 +1504,35 @@ function convertTable(el: Element, ctx: Ctx): Node | null { } } - return rows.length ? { type: 'table', content: rows } : null; + if (rows.length === 0) return null; + const margins = tableMargins(el, ctx); + return margins ? { type: 'table', attrs: margins, content: rows } : { type: 'table', content: rows }; +} + +// A table narrower than the text width → the editor's marginLeft/marginRight attrs. +// LibreOffice states the width plus one margin (per table:align), the other follows. +function tableMargins(el: Element, ctx: Ctx): { marginLeft: number; marginRight: number } | null { + const props = ctx.resolver.tableProps(el.getAttributeNS(NS.table, 'style-name')); + const content = ctx.contentWidthCm; + const rel = parseFloat(props['style:rel-width'] ?? ''); + const width = lengthToCm(props['style:width']) + ?? (Number.isFinite(rel) && rel > 0 ? (Math.min(rel, 100) / 100) * content : null); + + let left = lengthToCm(props['fo:margin-left']); + let right = lengthToCm(props['fo:margin-right']); + if (left == null && width != null) { + const align = props['table:align']; + left = align === 'center' ? (content - width) / 2 : align === 'right' ? content - width : 0; + } + left = Math.max(0, left ?? 0); + if (right == null) right = width != null ? content - left - width : 0; + right = Math.max(0, right); + + // Rounding noise from the producer, or a table that would be left with no room. + if (left < 0.05 && right < 0.05) return null; + if (left + right > content - 1) return null; + const round2 = (v: number) => Math.round(v * 100) / 100; + return { marginLeft: round2(left), marginRight: round2(right) }; } // Per-column proportional weights for the colwidth cell attr (tableView.ts uses diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 9818508..68a6c62 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -115,6 +115,7 @@ function entryFromStyleElement(el: Element): StyleEntry { }; collectProps(el, 'text-properties', entry.text); collectProps(el, 'paragraph-properties', entry.para); + collectProps(el, 'table-properties', entry.misc); collectProps(el, 'table-column-properties', entry.misc); collectProps(el, 'table-row-properties', entry.misc); collectProps(el, 'table-cell-properties', entry.misc); @@ -318,6 +319,11 @@ export class StyleResolver { return null; } + // A table style's raw (style:width, fo:margin-*, table:align). + tableProps(styleName: string | null): PropMap { + return styleName ? this.merged('table', styleName).misc : {}; + } + columnWidthCm(styleName: string | null): number | null { if (!styleName) return null; return lengthToCm(this.merged('table-column', styleName).misc['style:column-width']); diff --git a/src/styles/editor.css b/src/styles/editor.css index f02687b..bef96be 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -459,7 +459,8 @@ • padding → export emits fo:padding 0.1cm per cell (CELL_PADDING). Tables are wrapped in
by TipTap's node view. */ .paper .tiptap .tableWrapper { - /* The exported table style carries no margin (nor do Word/LO tables). */ + /* Default is flush with the text area; a dragged outer edge sets an inline + margin-left/-right here (tableView.ts), which is what the export writes. */ margin: 0; overflow-x: auto; } @@ -533,6 +534,12 @@ pointer-events: none; } +/* The table's outer left edge: same handle, mirrored to the cell's left side. */ +.paper .tiptap .column-resize-handle.left { + left: -2px; + right: auto; +} + .paper .tiptap .column-resize-dragging { /* Suppress text-selection flicker while dragging a column border. */ user-select: none; diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index d2cf3bf..ad631c2 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -999,3 +999,29 @@ describe('DOCX named character styles', () => { expect(res.styles.character['Signal'].text).toEqual({ bold: true, color: '#CC0000' }); }); }); + +describe('DOCX table margins (dragged outer edges)', () => { + // Default Word geometry here: 21 - 2.12 - 2.12 = 16.76cm of text width, so the + // table is 16.76 - 2 - 3 = 11.76cm wide and indented 2cm. + const fixture = { + type: 'doc', + content: [ + { type: 'table', attrs: { marginLeft: 2, marginRight: 3 }, content: [ + { type: 'tableRow', content: [cell('A', { colwidth: [200] }), cell('B', { colwidth: [100] })] }, + ] }, + ], + } as any; + + it('writes w:tblInd + the narrower grid and round-trips the margins', async () => { + const bytes = await buildDocx(fixture, { top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['word/document.xml']); + expect(xml).toContain(``); + + const res = await importDocx(bytes); + const table = walk(res.content as N, 'table')[0]; + expect(table.attrs.marginLeft).toBeCloseTo(2, 1); + expect(table.attrs.marginRight).toBeCloseTo(3, 1); + const weights = (table.content?.[0].content ?? []).map((c: N) => c.attrs.colwidth[0]); + expect(weights[0] / weights[1]).toBeCloseTo(2, 2); + }); +}); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 4cd18ae..f6bc72e 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -484,6 +484,43 @@ describe('Leg 1d: table cell borders (per-side, fo:border-*)', () => { }); }); +describe('Leg 1e: table margins (dragged outer edges)', () => { + // Text width here is 21 - 2.5 - 1.5 = 17cm, so the table is 17 - 2 - 3 = 12cm wide. + const doc: N = { type: 'doc', content: [ + { type: 'table', attrs: { marginLeft: 2, marginRight: 3 }, content: [ + ROW(CELL([200], P(null, T('A'))), CELL([100], P(null, T('B')))), + ] }, + ] }; + + it('exports fo:margin-* + style:width and re-imports the attrs', async () => { + const bytes = await buildOdt(doc, margins, 'portrait'); + const xml = strFromU8(unzipSync(bytes)['content.xml']); + const tableStyle = xml.match(/]*style:family="table"[^>]*>\s*]*>/)?.[0] ?? ''; + check('export emits the left margin', tableStyle.includes('fo:margin-left="2cm"'), tableStyle); + check('export emits the right margin', tableStyle.includes('fo:margin-right="3cm"'), tableStyle); + check('export emits the remaining width', tableStyle.includes('style:width="12cm"'), tableStyle); + // Columns keep their 2:1 ratio inside the narrowed table. + check('columns fill the narrowed table', xml.includes('style:column-width="8cm"') && xml.includes('style:column-width="4cm"'), xml.match(/style:column-width="[^"]*"/g)); + + const res = importOdt(bytes); + check('no warnings on own export', res.warnings.length === 0, res.warnings); + const table = (res.content.content ?? []).find((n: N) => n.type === 'table'); + check('left margin round-trips', table?.attrs?.marginLeft === 2, table?.attrs); + check('right margin round-trips', table?.attrs?.marginRight === 3, table?.attrs); + const weights = (table?.content?.[0]?.content ?? []).map((c: N) => c.attrs?.colwidth?.[0]); + check('column ratio survives', weights[0] === 2 * weights[1], weights); + }); + + it('leaves a full-width table without margin attrs', async () => { + const plain: N = { type: 'doc', content: [ + { type: 'table', content: [ROW(CELL([100], P(null, T('A'))), CELL([100], P(null, T('B'))))] }, + ] }; + const res = importOdt(await buildOdt(plain, margins, 'portrait')); + const table = (res.content.content ?? []).find((n: N) => n.type === 'table'); + check('no margins on a full-width table', !table?.attrs?.marginLeft && !table?.attrs?.marginRight, table?.attrs); + }); +}); + describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => { it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => { const stylesXml = ` diff --git a/tests/unit/table-edge-resize.test.ts b/tests/unit/table-edge-resize.test.ts new file mode 100644 index 0000000..146bd1e --- /dev/null +++ b/tests/unit/table-edge-resize.test.ts @@ -0,0 +1,79 @@ +// The outer-edge drag math (tableColumnResize.ts): Word semantics — only the adjacent +// column gives up width, every other gridline stays put, and the edge stops at the +// text margin / the column minimum. +import { describe, it, expect } from 'vitest'; +import { generateHTML } from '@tiptap/core'; +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'; +import { edgeResize, TableColumnResize } from '../../src/lib/editor/extensions/tableColumnResize'; + +type Result = { marginLeftPx: number; marginRightPx: number; percents: number[] }; + +// A 3-column table at 300/200/100 px. +const PERCENTS = [50, 100 / 3, 100 / 6]; + +// Resulting column widths in px: the table width changes by whatever the margins gave up. +const cols = (r: Result, tableWidth: number, ml = 0, mr = 0): number[] => { + const width = tableWidth - (r.marginLeftPx - ml) - (r.marginRightPx - mr); + return r.percents.map((p) => (p / 100) * width); +}; + +describe('edgeResize', () => { + it('left edge: only column 1 shrinks, the others keep their width', () => { + const r = edgeResize(PERCENTS, 600, 0, 0, 'left', 100); + expect(r.marginLeftPx).toBe(100); + expect(r.marginRightPx).toBe(0); + const [a, b, c] = cols(r, 600); + expect(a).toBeCloseTo(200); + expect(b).toBeCloseTo(200); + expect(c).toBeCloseTo(100); + }); + + it('right edge: only the last column grows, reclaiming its own margin', () => { + const r = edgeResize(PERCENTS, 450, 0, 150, 'right', 100); + expect(r.marginRightPx).toBe(50); + const [a, b, c] = cols(r, 450, 0, 150); + expect(a).toBeCloseTo(225); + expect(b).toBeCloseTo(150); + expect(c).toBeCloseTo(175); + }); + + it('stops at the text margin and never goes negative', () => { + const r = edgeResize(PERCENTS, 600, 0, 0, 'right', 100); // no right margin to reclaim + expect(r.marginRightPx).toBe(0); + expect(r.percents).toEqual(PERCENTS); + }); + + it('keeps the adjacent column above the minimum width', () => { + const r = edgeResize(PERCENTS, 600, 0, 0, 'left', 500); // column 1 is only 300px + expect(r.marginLeftPx).toBe(270); // 300 - CELL_MIN_PX + expect(cols(r, 600)[0]).toBeCloseTo(30); + }); +}); + +// The static HTML path (generateHTML) feeds the PDF export, which has no node view. +describe('table margins in rendered HTML', () => { + const exts = [Document, Paragraph, Text, Table, TableRow, TableCell, TableHeader, TableColumnResize]; + const table = (attrs: Record) => ({ + type: 'doc', + content: [{ type: 'table', attrs, content: [ + { type: 'tableRow', content: [{ type: 'tableCell', content: [{ type: 'paragraph' }] }] }, + ] }], + }); + + it('renders the margins and the matching width', () => { + const html = generateHTML(table({ marginLeft: 2, marginRight: 3 }), exts); + expect(html).toContain('margin-left: 2cm'); + expect(html).toContain('margin-right: 3cm'); + // jsdom's CSS parser normalizes absolute lengths in `width` to px (browsers don't). + const [, value, unit] = html.match(/width: calc\(100% - ([\d.]+)(cm|px)\)/) ?? []; + expect(unit).toBeTruthy(); + expect(unit === 'px' ? Number(value) / (96 / 2.54) : Number(value)).toBeCloseTo(5, 3); + }); + + it('leaves a full-width table unstyled', () => { + expect(generateHTML(table({ marginLeft: 0, marginRight: 0 }), exts)).not.toContain('margin-left'); + }); +});