diff --git a/CHANGELOG.md b/CHANGELOG.md index 43aa09b..8bef6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Word's Table Style Options as six checkboxes beside the gallery (header row, total row, first/last column, banded rows/columns): an area is painted only when the style defines it *and* the table opts in, and turning the header row off shifts the banding by one row. The toolbar's header row / first column buttons drive the same flags, so both surfaces always agree - Table styles are edited in the same style manager (third tab: area picker for fill and font, outer / row line / column line controls, new / rename / remove), also reachable from the insert-table dropdown - Round-trip: the style name travels as an ODF `style:family="table"` (`style:parent-style-name`) and DOCX `w:tblStyle`, the options as ODF's `table:use-*-styles` and Word's `w:tblLook`. Fills and borders are materialized into the cells and the region's font is baked onto the runs, so the table looks right in Word/LibreOffice even though ODF has no banding concept — the style definitions themselves live in the app, as LibreOffice's AutoFormats do +- Banded rows and columns start on the **first** body row/column, as in Word and LibreOffice — the stripe used to begin one row late. A document with a table style assigned re-bands itself when it is next opened - A paragraph style's own rule line and colored field are read from the style, not only from direct formatting, so a Word Title keeps the line under it — with the gap the file asks for (`w:pBdr w:space` / ODF `fo:padding`), on the ruled sides only - A Word table's indent follows the file's compatibility mode: up to Word 2010 `w:tblInd` is measured to the cell's text, so the table hangs its left cell margin into the page margin and the first column's text lines up with the body — from Word 2013 it is the table's edge - A Word file's own table style is read the other way round: its conditional areas (`w:tblStylePr` — header row, banded rows, first/last column) are baked into the cells on import, shading, borders and bold/colour alike, so a document styled with Word's Light Grid or Medium Shading opens looking like it does in Word and LibreOffice diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index 556d2c2..87ddbc9 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -31,7 +31,9 @@ We deliberately keep TipTap's `resizable: false` (so its own columnResizing plug `lastColumn` < `firstColumn` < `lastRow` < `headerRow`), with `wholeTable` as the base. `resolveTableCell(style, coords, look)` is the single source of truth for **all four** consumers (apply, CSS, export bake, preview tiles): it layers the matching regions, counts - banding over *body* rows (a header row doesn't shift the stripes) and decides each border + banding over *body* rows — a header row doesn't shift the stripes, and the **first** body + row is the first stripe (probed: LibreOffice shades the row under the header, and row 0 + where the style paints no header) — and decides each border **per grid line** from both sides, so two facing cells never disagree once collapsed. **Table Style Options** are Word's per-table `w:tblLook`: a `TableLook` (one flag per region) stored space-separated in the table's `tableLook` attr, absent ⇒ `DEFAULT_TABLE_LOOK` (Word's diff --git a/src/lib/styles/tableStyles.ts b/src/lib/styles/tableStyles.ts index c933a3e..724e692 100644 --- a/src/lib/styles/tableStyles.ts +++ b/src/lib/styles/tableStyles.ts @@ -195,7 +195,8 @@ export type ResolvedTableCell = { }; // The conditional regions a grid position belongs to, in ascending precedence. Banding -// counts body rows only, so a header row doesn't shift the stripes (as in Word). +// counts body rows only, so a header row doesn't shift the stripes, and the **first** +// body row is the first stripe — both probed against LibreOffice. function regionsAt( style: TableStyle, look: TableLook, row: number, col: number, rows: number, cols: number, @@ -206,8 +207,8 @@ function regionsAt( const bodyRow = row - (on('headerRow') ? 1 : 0); const bodyCol = col - (on('firstColumn') ? 1 : 0); const matches: Record = { - bandedColumn: bodyCol >= 0 && bodyCol % 2 === 1, - bandedRow: bodyRow >= 0 && bodyRow % 2 === 1, + bandedColumn: bodyCol >= 0 && bodyCol % 2 === 0, + bandedRow: bodyRow >= 0 && bodyRow % 2 === 0, lastColumn: col === cols - 1, firstColumn: col === 0, lastRow: row === rows - 1, diff --git a/tests/unit/table-style.test.ts b/tests/unit/table-style.test.ts index e94103c..a63e969 100644 --- a/tests/unit/table-style.test.ts +++ b/tests/unit/table-style.test.ts @@ -51,15 +51,15 @@ describe('resolveTableCell', () => { expect(corner.fill).toBe('#EEEEEE'); }); - it('counts banding over body rows, so a header row does not shift the stripes', () => { - expect(at(banded, 1, 1).fill).toBe(null); // first body row - expect(at(banded, 2, 1).fill).toBe('#FAFAFA'); - expect(at(banded, 3, 1).fill).toBe(null); + it('starts the stripes at the first body row, past a header row', () => { + expect(at(banded, 1, 1).fill).toBe('#FAFAFA'); // first body row + expect(at(banded, 2, 1).fill).toBe(null); + expect(at(banded, 3, 1).fill).toBe('#FAFAFA'); - // Without a header region the stripes start one row higher. + // Without a header region the body starts at row 0, and so do the stripes. const noHeader: TableStyleDef = { ...banded, regions: { bandedRow: { fill: '#FAFAFA' } } }; - expect(at(noHeader, 0, 0).fill).toBe(null); - expect(at(noHeader, 1, 0).fill).toBe('#FAFAFA'); + expect(at(noHeader, 0, 0).fill).toBe('#FAFAFA'); + expect(at(noHeader, 1, 0).fill).toBe(null); }); it('gives both cells of a grid line the same border', () => { @@ -105,7 +105,8 @@ describe('table style options (Word\'s tblLook)', () => { it('only paints a region the table opts into', () => { const off: TableLook = { ...ALL_ON, headerRow: false }; expect(at(banded, 0, 1).fill).toBe('#EEEEEE'); - expect(at(banded, 0, 1, 4, 2, off).fill).toBe(null); + // The header's own fill is gone; row 0 is the body's first stripe now. + expect(at(banded, 0, 1, 4, 2, off).fill).toBe('#FAFAFA'); // The header's rule goes with it, from both sides of that grid line. expect(at(banded, 0, 0, 4, 2, off).borders.borderBottom).toBe('none'); expect(at(banded, 1, 0, 4, 2, off).borders.borderTop).toBe('none'); @@ -113,13 +114,13 @@ describe('table style options (Word\'s tblLook)', () => { it('shifts the banding when the header row is switched off', () => { const off: TableLook = { ...ALL_ON, headerRow: false }; - // Header on: row 0 is the header, so the first stripe is row 2. + // Header on: row 0 is the header, so the first stripe is row 1. expect([0, 1, 2, 3].map(r => at(banded, r, 1).fill)).toEqual([ - '#EEEEEE', null, '#FAFAFA', null, + '#EEEEEE', '#FAFAFA', null, '#FAFAFA', ]); // Header off: the body starts at row 0, so the stripes move up one row. expect([0, 1, 2, 3].map(r => at(banded, r, 1, 4, 2, off).fill)).toEqual([ - null, '#FAFAFA', null, '#FAFAFA', + '#FAFAFA', null, '#FAFAFA', null, ]); }); @@ -227,7 +228,7 @@ describe('header toggles and style options are one state', () => { editor.commands.toggleHeaderRowStyle(); expect(parseTableLook(table(editor).attrs.tableLook).headerRow).toBe(false); expect(isHeaderStyled(editor.state, 'row')).toBe(false); - expect(table(editor).content[0].content[0].attrs.backgroundColor).toBe(null); + expect(table(editor).content[0].content[0].attrs.backgroundColor).toBe('#FAFAFA'); // And the other way round: setting the option updates the button's state. editor.commands.setTableLook('headerRow', true); @@ -260,7 +261,7 @@ describe('setTableStyle', () => { expect(t.content[0].content[1].attrs.region).toBe('headerRow'); expect(t.content[0].content[0].attrs.borderBottom).toBe(RULE); expect(t.content[1].content[0].attrs.borderTop).toBe(RULE); - expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA']); + expect(fills(editor)).toEqual(['#EEEEEE', '#FAFAFA', null]); // Applying again changes nothing — the re-band plugin depends on this. const before = JSON.stringify(editor.getJSON()); @@ -273,11 +274,11 @@ describe('setTableStyle', () => { const editor = makeEditor(); editor.commands.focus('start'); editor.commands.setTableStyle('Test Bands'); - expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA']); + expect(fills(editor)).toEqual(['#EEEEEE', '#FAFAFA', null]); // Cursor is in the header row; a row added after it shifts every stripe below. editor.commands.addRowAfter(); - expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA', null]); + expect(fills(editor)).toEqual(['#EEEEEE', '#FAFAFA', null, '#FAFAFA']); editor.destroy(); }); @@ -341,7 +342,7 @@ describe('setTableStyle', () => { const t = table(editor); expect(t.content[1].content.length).toBe(1); expect(t.content[1].content[0].attrs.colspan).toBe(2); - expect(t.content[1].content[0].attrs.region).toBe('firstColumn'); + expect(t.content[1].content[0].attrs.region).toBe('bandedRow firstColumn'); // Its bottom is the boundary to the banded row below, its right the table edge. expect(t.content[1].content[0].attrs.borderRight).toBe(null); editor.destroy(); @@ -351,16 +352,16 @@ describe('setTableStyle', () => { const editor = makeEditor(4); editor.commands.focus('start'); editor.commands.setTableStyle('Test Bands'); - expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA', null]); + expect(fills(editor)).toEqual(['#EEEEEE', '#FAFAFA', null, '#FAFAFA']); // Header row off: it loses its fill and the stripes move up a row. editor.commands.setTableLook('headerRow', false); - expect(fills(editor)).toEqual([null, '#FAFAFA', null, '#FAFAFA']); - expect(table(editor).content[0].content[0].attrs.region).toBe('firstColumn'); + expect(fills(editor)).toEqual(['#FAFAFA', null, '#FAFAFA', null]); + expect(table(editor).content[0].content[0].attrs.region).toBe('bandedRow firstColumn'); // And back again. editor.commands.setTableLook('headerRow', true); - expect(fills(editor)).toEqual(['#EEEEEE', null, '#FAFAFA', null]); + expect(fills(editor)).toEqual(['#EEEEEE', '#FAFAFA', null, '#FAFAFA']); editor.destroy(); });