// 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('') + ''; // The same grid with its first column merged from the first body row down to the last. const mergedTable = () => ` ` + [0, 1, 2, 3].map((r) => { const merge = r === 1 ? '' : ''; const first = r === 0 ? 'h' : `${merge}m`; return `${first}r${r}`; }).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('gives a merged cell the edges of every row it covers', () => { const merged = cellsOf(docxWith(mergedTable())); expect(merged[1][0].attrs.rowspan).toBe(3); expect(merged[1][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' }]); }); }); // docDefaults ← the table style's w:pPr ← the paragraph style's chain ← direct w:pPr. // Probed against soffice: a table style zeroing the space after does not take it off a // Normal that declares one, but its line spacing does beat docDefaults'. const SPACING_STYLES = ` `; const spacedTable = (cellPPr = '') => zipSync({ 'word/document.xml': strToU8(`` + `` + `${cellPPr}cell` + ``), 'word/styles.xml': strToU8(SPACING_STYLES), }); describe('a table style’s w:pPr ranks under the paragraph style', () => { const attrsOf = (bytes: Uint8Array) => cellsOf(bytes)[0][0].content[0].attrs; it('keeps the paragraph style’s space after and takes the table style’s line', () => { expect(attrsOf(spacedTable())).toMatchObject({ spaceAfter: 8, lineHeight: '1' }); }); it('lets direct w:pPr beat both', () => { const direct = spacedTable(''); expect(attrsOf(direct)).toMatchObject({ spaceAfter: 24, lineHeight: '2' }); }); it('round-trips the baked values', async () => { const doc = { type: 'doc', content: importDocx(spacedTable()).content.content }; const bytes = await buildDocx(doc as N, { top: 2, bottom: 2, left: 2, right: 2 }); // Our own export's default line height is single again, so the re-import drops // the baked '1' as default-equal; the non-default space after stays. const attrs = attrsOf(bytes); expect(attrs).toMatchObject({ spaceAfter: 8 }); expect(attrs.lineHeight).toBeUndefined(); }); });