// 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();
});
});