diff --git a/CHANGELOG.md b/CHANGELOG.md index 503dce1..4e7f9ff 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 +- 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 - A new document starts from the built-in styles, like Word's/LibreOffice's default template **Paragraphs & lists** diff --git a/docs/architecture/tables.md b/docs/architecture/tables.md index 2841de9..bd56585 100644 --- a/docs/architecture/tables.md +++ b/docs/architecture/tables.md @@ -81,6 +81,11 @@ We deliberately keep TipTap's `resizable: false` (so its own columnResizing plug `refreshTableStyles`, and `App.svelte` keeps `sheet.table` across an import because the file never carried it. Baked bold is only suppressed for `HEADER_SHADE` cells (the existing `boldByDefault` path) — a foreign style's bold survives as direct formatting. + A **Word** file's own table style does carry its areas (`w:tblStylePr`), and the registry has + no entry to re-derive them from, so the DOCX importer bakes those into the cells instead — + fill, borders and run marks alike (`src/lib/import/CLAUDE.md` for the area rules). What is + re-exported is that bake, which is why the round trip is stable: our `w:tblStyle` is + name-only, so a reimport finds no areas to apply over it. - **Table-style gallery**: `TableStylePicker.svelte` in the floating `TableToolbar` — tiles drawn by `previewCellCss` (the same resolver), so a preview can't disagree with what applying does, diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 1de246a..7b7012e 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -46,6 +46,19 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( drawn**: Word's Normal Table has no border, and the gridlines it shows on screen are not printed. ODF says the same for an undeclared `fo:border`, so both importers agree. +**A table style's conditional areas** (`w:tblStylePr`: header row, banded rows, first +column, …) are **baked into the cells** — fill and borders as attrs, the area's `w:rPr` as +real marks on its runs — because the file's own style is not in the editor's registry, +which is where an assigned style's regions otherwise come from. An area covers a grid box: +its `top`/`bottom`/`left`/`right` apply where the cell sits on that box's edge, its +`insideH`/`insideV` anywhere within. A **band's box is the whole banded region**, not the +one row it shades — that is what makes `insideH` the line *between* two band rows, which +is how a style with no inner rules (Word's Medium Shading) comes out borderless. Areas +layer in Word's ascending precedence, over the table's borders and under the cell's own +`w:tcPr`; banding counts from the first *body* row, so the row under a header row is band 1. +`w:tblLook` decides which areas apply at all — named flags where the file has them, the +older `w:val` bitmask otherwise, and no element at all means none. + Body text with no resolved font falls back to the *document's own theme minor font* (`docx.ts` `runMarks`), not the editor default — Word's implicit body default. Headings don't (they keep the editor heading default); DOCX page margins default to Word's 2.54cm. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 276d0e3..168d0b7 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -1622,6 +1622,102 @@ function docxBorderAttr(el: Element | null): string | null | undefined { return `${w}pt solid ${c}`; } +// A conditional table-style area (w:tblStylePr) and the grid box it covers. A band's box +// is the whole banded region, not the single band: that is what makes its insideH the +// line *between* two band rows, which is how Word and LibreOffice draw one. +type GridBox = { row: number; col: number; rowEnd: number; colEnd: number }; +type CondArea = { el: Element; box: GridBox }; +type LookFlags = { firstRow: boolean; lastRow: boolean; firstCol: boolean; lastCol: boolean; hBand: boolean; vBand: boolean }; + +// w:tblLook: Word 2010+ writes the named attributes, older files only the w:val bitmask. +// No element at all means no conditional area applies. +function docxLookFlags(tblPr: Element | null): LookFlags { + const el = fc(tblPr, 'tblLook'); + const bits = parseInt(el?.getAttributeNS(W, 'val') ?? '', 16); + const on = (name: string, bit: number) => { + const v = el?.getAttributeNS(W, name); + if (v != null) return v === '1' || v === 'true'; + return Number.isFinite(bits) && (bits & bit) !== 0; + }; + if (!el) return { firstRow: false, lastRow: false, firstCol: false, lastCol: false, hBand: false, vBand: false }; + return { + firstRow: on('firstRow', 0x020), lastRow: on('lastRow', 0x040), + firstCol: on('firstColumn', 0x080), lastCol: on('lastColumn', 0x100), + hBand: !on('noHBand', 0x200), vBand: !on('noVBand', 0x400), + }; +} + +// The areas covering one cell, lowest precedence first. Banding counts from the first body +// row/column, so the row under a header row is the first band (as in Word and LibreOffice). +function condAreasFor( + conds: Map, flags: LookFlags, band: { row: number; col: number }, + cell: GridBox, rows: number, cols: number, +): CondArea[] { + const out: CondArea[] = []; + const push = (type: string, box: GridBox) => { + const el = conds.get(type); + if (el) out.push({ el, box }); + }; + const r0 = flags.firstRow ? 1 : 0, r1 = flags.lastRow ? rows - 2 : rows - 1; + const c0 = flags.firstCol ? 1 : 0, c1 = flags.lastCol ? cols - 2 : cols - 1; + if (flags.vBand && cell.col >= c0 && cell.col <= c1) { + const even = Math.floor((cell.col - c0) / band.col) % 2 === 0; + push(even ? 'band1Vert' : 'band2Vert', { row: 0, rowEnd: rows, col: c0, colEnd: c1 + 1 }); + } + if (flags.hBand && cell.row >= r0 && cell.row <= r1) { + const even = Math.floor((cell.row - r0) / band.row) % 2 === 0; + push(even ? 'band1Horz' : 'band2Horz', { row: r0, rowEnd: r1 + 1, col: 0, colEnd: cols }); + } + if (flags.lastCol && cell.colEnd === cols) push('lastCol', { row: 0, rowEnd: rows, col: cols - 1, colEnd: cols }); + if (flags.firstCol && cell.col === 0) push('firstCol', { row: 0, rowEnd: rows, col: 0, colEnd: 1 }); + if (flags.lastRow && cell.rowEnd === rows) push('lastRow', { row: rows - 1, rowEnd: rows, col: 0, colEnd: cols }); + if (flags.firstRow && cell.row === 0) push('firstRow', { row: 0, rowEnd: 1, col: 0, colEnd: cols }); + return out; +} + +// Which side of an area a cell's edge is: its own outer side where the cell sits on the +// area's edge, the area's inside line otherwise. +function areaSide(area: GridBox, cell: GridBox, side: 'top' | 'bottom' | 'left' | 'right'): string { + if (side === 'top') return cell.row <= area.row ? 'top' : 'insideH'; + if (side === 'bottom') return cell.rowEnd >= area.rowEnd ? 'bottom' : 'insideH'; + if (side === 'left') return cell.col <= area.col ? 'left' : 'insideV'; + return cell.colEnd >= area.colEnd ? 'right' : 'insideV'; +} + +// A cell's shading and run properties from the table style, layered over its areas. The +// runs are baked below: a file's own table style has no entry in the editor's registry. +function condPaint(areas: CondArea[]): { fill?: string; run: RunProps } { + let fill: string | undefined; + let run: RunProps = {}; + for (const a of areas) { + const shd = fc(fc(a.el, 'tcPr'), 'shd'); + if (shd) fill = hexColor(shd.getAttributeNS(W, 'fill')); + run = mergeRunProps(run, parseRunProps(fc(a.el, 'rPr'))); + } + return { fill, run }; +} + +// A conditional area's bold/italic/colour as real marks on the cell's text, skipping what +// a run already declares — the file's own formatting outranks its table style. +function bakeCellRuns(nodes: Node[], props: RunProps): void { + const color = hexColor(props.color); + if (!props.bold && !props.italic && !color) return; + for (const n of nodes) { + if (n.content) bakeCellRuns(n.content, props); + if (n.type !== 'text') continue; + const marks = n.marks ?? []; + const has = (t: string) => marks.some((m) => m.type === t); + if (props.bold && !has('bold')) marks.push({ type: 'bold' }); + if (props.italic && !has('italic')) marks.push({ type: 'italic' }); + if (color) { + const ts = marks.find((m) => m.type === 'textStyle'); + if (!ts) marks.push({ type: 'textStyle', attrs: { color } }); + else if (!ts.attrs?.color) ts.attrs = { ...ts.attrs, color }; + } + if (marks.length) n.marks = marks; + } +} + function convertTable(tbl: Element, ctx: Ctx): Node | null { // The table style's own w:pPr/w:spacing governs its cells' paragraphs (Word's Table // Grid zeroes the space after and the line spacing). Restored for a nested table. @@ -1663,11 +1759,6 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { const weights = grid ? fcAll(grid, 'gridCol').map((g) => Math.max(1, intAttr(g, W, 'w') ?? 1)) : null; const useWeights = weights && weights.length ? weights : null; - // Table-level border defaults; per-cell w:tcBorders override per side. Edge cells - // fall back to the outer sides, interior cells to insideH/insideV. - - // From w:tblStyle only the name comes back — the look rides on the cell attrs, and the - // editor re-derives the regions from its own registry (refreshTableStyles). const styleEl = fc(fc(tbl, 'tblPr'), 'tblStyle'); const styleId = styleEl ? wVal(styleEl) : null; const pad = docxCellPadding(tbl, styleId, ctx); @@ -1676,22 +1767,20 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { // The table's own w:tblBorders, then its style's; a side nobody declares is not drawn // (Word's Normal Table has no border — its on-screen gridlines are not printed). const tblBorders = fc(fc(tbl, 'tblPr'), 'tblBorders'); - const tblSide = (name: string) => { - for (const el of [tblBorders, ...ctx.styles.tableBorders(styleId)]) { - const v = docxBorderAttr(fc(el, name)); - if (v !== undefined) return v; - } - return 'none'; - }; - const tblDef = { - top: tblSide('top'), bottom: tblSide('bottom'), left: tblSide('left'), - right: tblSide('right'), insideH: tblSide('insideH'), insideV: tblSide('insideV'), - }; - const gridWidth = useWeights?.length ?? null; + const tblBorderEls = [tblBorders, ...ctx.styles.tableBorders(styleId)]; const rows: Node[] = []; const pending: (Node | null)[] = []; // origin cell per grid column, for vMerge spans const trs = fcAll(tbl, 'tr'); + + // The table style's conditional areas paint what only the style declares, so they are + // baked into the cells: a foreign style is not in the editor's registry, which is where + // an assigned table style's regions otherwise come from (refreshTableStyles). + const conds = ctx.styles.tableConditional(styleId); + const flags = docxLookFlags(fc(tbl, 'tblPr')); + const band = ctx.styles.tableBandSize(styleId); + const gridCols = useWeights?.length + ?? Math.max(1, ...trs.map((tr) => fcAll(tr, 'tc').reduce((n, tc) => n + (intAttr(fc(fc(tc, 'tcPr'), 'gridSpan'), W, 'val') ?? 1), 0))); for (let ri = 0; ri < trs.length; ri++) { const tr = trs[ri]; const cells: Node[] = []; @@ -1707,9 +1796,13 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { col += colspan; continue; // covered cell — dropped, span folded into its origin } + const box: GridBox = { row: ri, col, rowEnd: ri + 1, colEnd: col + colspan }; + const areas = conds.size ? condAreasFor(conds, flags, band, box, trs.length, gridCols) : []; + const paint = condPaint(areas); const fill = fc(tcPr, 'shd')?.getAttributeNS(W, 'fill'); - const bg = fill ? hexColor(fill) : undefined; + const bg = fill ? hexColor(fill) : paint.fill; const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE); + bakeCellRuns(blocks, paint.run); const attrs: Record = { colspan, rowspan: 1 }; if (bg) attrs.backgroundColor = bg; // w:vAlign — Word's "center" is the editor's "middle"; "top"/"both" stay the default. @@ -1720,17 +1813,30 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { // The cell's own margins (w:tcMar), kept only where they differ from the table's. const ownPad = cellPaddingAttr(cellMarginsCm([fc(tcPr, 'tcMar')]), padBase); if (ownPad) attrs.cellPadding = ownPad; + // Per side: the table's borders, then each conditional area over them, then the + // cell's own w:tcBorders. An edge of the box a layer covers takes that layer's + // outer side, anything inside it the layer's insideH/insideV. const tcBorders = fc(tcPr, 'tcBorders'); - const isRight = gridWidth != null && col + colspan >= gridWidth; - const resolve = (name: string, tblVal: string | null | undefined) => { - const own = docxBorderAttr(fc(tcBorders, name)); - return own !== undefined ? own : tblVal; + const table: GridBox = { row: 0, col: 0, rowEnd: trs.length, colEnd: gridCols }; + const layers: { els: (Element | null)[]; box: GridBox }[] = [ + { els: tblBorderEls, box: table }, + ...areas.map((a) => ({ els: [fc(fc(a.el, 'tcPr'), 'tcBorders')], box: a.box })), + { els: [tcBorders], box }, + ]; + const resolve = (side: 'top' | 'bottom' | 'left' | 'right') => { + let v: string | null | undefined; + for (const layer of layers) { + const name = areaSide(layer.box, box, side); + for (const el of layer.els) { + const got = docxBorderAttr(fc(el, name)); + if (got !== undefined) { v = got; break; } + } + } + return v === undefined ? 'none' : v; }; - const sides: [string, string | null | undefined][] = [ - ['borderTop', resolve('top', ri === 0 ? tblDef.top : tblDef.insideH)], - ['borderBottom', resolve('bottom', ri === trs.length - 1 ? tblDef.bottom : tblDef.insideH)], - ['borderLeft', resolve('left', col === 0 ? tblDef.left : tblDef.insideV)], - ['borderRight', resolve('right', isRight ? tblDef.right : tblDef.insideV)], + const sides: [string, string | null][] = [ + ['borderTop', resolve('top')], ['borderBottom', resolve('bottom')], + ['borderLeft', resolve('left')], ['borderRight', resolve('right')], ]; // undefined (nothing declared) and null (= editor default) both leave the attr off. for (const [attr, v] of sides) if (typeof v === 'string') attrs[attr] = v; @@ -1761,16 +1867,11 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null { // Word's w:tblLook → the editor's tableLook attr (its band flags are inverted). // null when the file declares none, so the default look applies. function docxTableLook(tblPr: Element | null): string | null { - const el = fc(tblPr, 'tblLook'); - if (!el) return null; - const on = (name: string) => { - const v = el.getAttributeNS(W, name); - return v === '1' || v === 'true'; - }; + if (!fc(tblPr, 'tblLook')) return null; + const f = docxLookFlags(tblPr); return tableLookAttr({ - headerRow: on('firstRow'), lastRow: on('lastRow'), - firstColumn: on('firstColumn'), lastColumn: on('lastColumn'), - bandedRow: !on('noHBand'), bandedColumn: !on('noVBand'), + headerRow: f.firstRow, lastRow: f.lastRow, firstColumn: f.firstCol, + lastColumn: f.lastCol, bandedRow: f.hBand, bandedColumn: f.vBand, }); } diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index aecfd2a..3b5e7fd 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -181,6 +181,8 @@ export class DocxStyles { private ownTabs = new Map(); // style's own w:pPr/w:tabs private ownCellMar = new Map(); // table style's own w:tblPr/w:tblCellMar private ownTblBorders = new Map(); // table style's own w:tblPr/w:tblBorders + private ownCond = new Map>(); // table styleId → w:type → w:tblStylePr + private ownBandSize = new Map(); // w:tblStyle*BandSize private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); private abstractLevels = new Map>(); @@ -260,6 +262,19 @@ export class DocxStyles { if (cellMar) this.ownCellMar.set(id, cellMar); const tblBorders = tblPr && firstChild(tblPr, 'tblBorders'); if (tblBorders) this.ownTblBorders.set(id, tblBorders); + const conds = Array.from(style.children).filter((c) => c.namespaceURI === W && c.localName === 'tblStylePr'); + if (conds.length) { + const m = new Map(); + for (const c of conds) { const t = c.getAttributeNS(W, 'type'); if (t) m.set(t, c); } + this.ownCond.set(id, m); + } + const bandSize = (name: string) => { + const el = tblPr && firstChild(tblPr, name); + const n = parseInt((el && wVal(el)) ?? '', 10); + return Number.isFinite(n) && n > 0 ? n : 0; + }; + const rowBand = bandSize('tblStyleRowBandSize'), colBand = bandSize('tblStyleColBandSize'); + if (rowBand || colBand) this.ownBandSize.set(id, { row: rowBand, col: colBand }); const kind = style.getAttributeNS(W, 'type'); if (kind === 'paragraph' || kind === 'character' || kind === 'table') { const nameEl = firstChild(style, 'name'); @@ -481,6 +496,26 @@ export class DocxStyles { return own ? [own, ...rest] : rest; } + // The table style's conditional areas (w:tblStylePr) along the w:basedOn chain: Word's + // "first row", "banded row 1", … each carrying the w:tcPr and w:rPr it paints. Leaf wins + // per area, so a derived style replaces the whole area rather than merging into it. + tableConditional(styleId: string | null | undefined, seen = new Set()): Map { + if (!styleId || seen.has(styleId)) return new Map(); + seen.add(styleId); + const base = this.tableConditional(this.basedOn.get(styleId) ?? null, seen); + for (const [type, el] of this.ownCond.get(styleId) ?? []) base.set(type, el); + return base; + } + + // How many rows/columns one band spans (w:tblStyleRowBandSize), 1 where none is declared. + tableBandSize(styleId: string | null | undefined, seen = new Set()): { row: number; col: number } { + if (!styleId || seen.has(styleId)) return { row: 1, col: 1 }; + seen.add(styleId); + const base = this.tableBandSize(this.basedOn.get(styleId) ?? null, seen); + const own = this.ownBandSize.get(styleId); + return { row: own?.row || base.row, col: own?.col || base.col }; + } + level(numId: number, ilvl: number): LevelDef { const abs = this.numToAbstract.get(String(numId)); if (!abs) return {}; diff --git a/tests/unit/docx-table-style.test.ts b/tests/unit/docx-table-style.test.ts new file mode 100644 index 0000000..f204636 --- /dev/null +++ b/tests/unit/docx-table-style.test.ts @@ -0,0 +1,102 @@ +// Word's table styles paint their conditional areas (w:tblStylePr) — header row, banded +// rows, first column. The definition is not in the editor's registry, so import bakes it +// into the cells; a re-export then carries the bake, not the areas. +import { describe, it, expect } from 'vitest'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { importDocx } from '../../src/lib/import/docx'; +import { buildDocx } from '../../src/lib/export/docx'; + +type N = any; + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const BLUE = 'single" w:sz="8" w:color="4F81BD'; + +const STYLES = ` + + + + + + + + + + + + + + + + + + +`; + +// 4 rows × 2 columns under Word's default look: header row, first column, row bands. +const table = (look = '') => + `${look} + ` + + [0, 1, 2, 3].map((r) => + `${[0, 1].map((c) => `r${r}c${c}`).join('')}`, + ).join('') + ''; + +const docxWith = (body: string) => + zipSync({ + 'word/document.xml': strToU8(`${body}`), + 'word/styles.xml': strToU8(STYLES), + }); + +const cellsOf = (bytes: Uint8Array): N[][] => { + const t = (importDocx(bytes).content.content as N[]).find((n: N) => n.type === 'table')!; + return t.content.map((row: N) => row.content); +}; +const marksOf = (cell: N): N[] => cell.content[0].content[0].marks ?? []; + +describe('a Word table style paints its conditional areas', () => { + const rows = cellsOf(docxWith(table())); + + it('shades the header row and bands from the first body row', () => { + expect(rows[0][0].attrs.backgroundColor).toBe('#4F81BD'); + expect(rows[1][0].attrs.backgroundColor).toBe('#D3DFEE'); + expect(rows[2][0].attrs.backgroundColor).toBeUndefined(); + expect(rows[3][0].attrs.backgroundColor).toBe('#D3DFEE'); + }); + + it('bakes the area’s run properties onto the cell’s text', () => { + expect(marksOf(rows[0][1])).toEqual([{ type: 'bold' }, { type: 'textStyle', attrs: { color: '#FFFFFF' } }]); + expect(marksOf(rows[1][0])).toEqual([{ type: 'bold' }]); // first column + expect(marksOf(rows[1][1])).toEqual([]); + }); + + it('takes an area’s outer side at its edge and its inside line within it', () => { + expect(rows[0][0].attrs.borderBottom).toBe('2.25pt solid #4F81BD'); // rule under the header + expect(rows[0][0].attrs.borderRight).toBe('none'); // firstRow insideV nil + // Only band1 declares insideH, and only a boundary inside the banded region takes it. + expect(rows[3][0].attrs.borderTop).toBe('none'); + expect(rows[2][0].attrs.borderTop).toBe('1pt solid #4F81BD'); + expect(rows[3][0].attrs.borderBottom).toBe('1pt solid #4F81BD'); // the table's own edge + }); + + it('paints nothing the table’s look switches off', () => { + const off = cellsOf(docxWith(table(''))); + expect(off[0][0].attrs.backgroundColor).toBeUndefined(); + expect(off[1][0].attrs.backgroundColor).toBeUndefined(); + expect(marksOf(off[1][0])).toEqual([]); + }); + + it('reads the older w:val bitmask where the named flags are absent', () => { + const hex = cellsOf(docxWith(table(''))); + expect(hex[0][0].attrs.backgroundColor).toBe('#4F81BD'); + expect(hex[1][0].attrs.backgroundColor).toBe('#D3DFEE'); + }); + + it('carries the bake back out, since the exported style has no areas', async () => { + const doc = { type: 'doc', content: [{ type: 'table', content: importDocx(docxWith(table())).content.content[0].content }] }; + const bytes = await buildDocx(doc as N, { top: 2, bottom: 2, left: 2, right: 2 }); + expect(strFromU8(unzipSync(bytes)['word/document.xml'])).not.toContain('tblStylePr'); + const again = cellsOf(bytes); + expect(again[0][0].attrs.backgroundColor).toBe('#4F81BD'); + expect(again[2][0].attrs.backgroundColor).toBeUndefined(); + expect(marksOf(again[1][0])).toEqual([{ type: 'bold' }]); + }); +});