The DOCX importer read w:tblBorders off the table but never off its table style, so a table whose style declares none (Word's Normal Table, and the thesis' Formel-tab around every formula) fell back to the editor's 0.5pt black default. Resolved per side along the w:basedOn chain, and an undeclared side is not drawn — LibreOffice writes fo:border="none" for exactly those tables when it re-saves the file. The ODF importer already treated an undeclared side that way.
310 lines
14 KiB
TypeScript
310 lines
14 KiB
TypeScript
// Unit tests for the table border feature: the setTableBorders preset command
|
||
// (driven against a ProseMirror EditorState, like table-split.test.ts) and the
|
||
// attr-value parse/normalize helpers used by export and import.
|
||
import { describe, it, expect } from 'vitest';
|
||
import { getSchema } from '@tiptap/core';
|
||
import { EditorState, TextSelection } from '@tiptap/pm/state';
|
||
import { CellSelection } from '@tiptap/pm/tables';
|
||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||
import Document from '@tiptap/extension-document';
|
||
import Paragraph from '@tiptap/extension-paragraph';
|
||
import Text from '@tiptap/extension-text';
|
||
import { Table, TableHeader, TableCell } from '@tiptap/extension-table';
|
||
import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
|
||
import {
|
||
TableCellBorders, setTableBorders, activeBorderPresets, borderAttrValue, parseBorderAttr,
|
||
type BorderPreset, type BorderSpec,
|
||
} from '../../src/lib/editor/extensions/tableCellBorders';
|
||
import { borderAttrFromOdf } from '../../src/lib/import/odt';
|
||
import { zipSync, strToU8 } from 'fflate';
|
||
import { importDocx } from '../../src/lib/import/docx';
|
||
|
||
type N = any;
|
||
|
||
const schema = getSchema([
|
||
Document, Paragraph, Text, Table, ResizableTableRow, TableCell, TableHeader, TableCellBorders,
|
||
]);
|
||
|
||
const cell = (text: string): N => ({
|
||
type: 'tableCell',
|
||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||
});
|
||
const row = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
|
||
// 3×3 grid: [A B C] / [D E F] / [G H I]
|
||
const grid3 = (): N => ({
|
||
type: 'doc',
|
||
content: [{ type: 'table', content: [
|
||
row(cell('A'), cell('B'), cell('C')),
|
||
row(cell('D'), cell('E'), cell('F')),
|
||
row(cell('G'), cell('H'), cell('I')),
|
||
] }],
|
||
});
|
||
|
||
function textPos(doc: PMNode, text: string): number {
|
||
let pos = -1;
|
||
doc.descendants((node: PMNode, p: number) => {
|
||
if (node.isText && node.text === text) pos = p;
|
||
});
|
||
if (pos < 0) throw new Error(`text ${text} not found`);
|
||
return pos;
|
||
}
|
||
|
||
function cellPos(doc: PMNode, text: string): number {
|
||
const $pos = doc.resolve(textPos(doc, text));
|
||
for (let d = $pos.depth; d > 0; d--) {
|
||
if ($pos.node(d).type.spec.tableRole === 'cell') return $pos.before(d);
|
||
}
|
||
throw new Error(`no cell around ${text}`);
|
||
}
|
||
|
||
// Run the command with the cursor in `target` (string) or a CellSelection over
|
||
// [anchor, head] cells; returns cellText → border attrs for the whole table.
|
||
function run(json: N, target: string | [string, string], preset: BorderPreset, spec: BorderSpec): Record<string, N> {
|
||
const doc = schema.nodeFromJSON(json);
|
||
const selection = typeof target === 'string'
|
||
? TextSelection.create(doc, textPos(doc, target))
|
||
: new CellSelection(doc.resolve(cellPos(doc, target[0])), doc.resolve(cellPos(doc, target[1])));
|
||
const state = EditorState.create({ schema, doc, selection });
|
||
let next: EditorState | null = null;
|
||
const ok = setTableBorders(preset, spec)({
|
||
state,
|
||
tr: state.tr,
|
||
dispatch: (tr: any) => { next = state.apply(tr); },
|
||
} as any);
|
||
expect(ok).toBe(true);
|
||
const out: Record<string, N> = {};
|
||
next!.doc.descendants((node: PMNode) => {
|
||
if (node.type.spec.tableRole === 'cell') {
|
||
const { borderTop, borderRight, borderBottom, borderLeft } = node.attrs;
|
||
out[node.textContent] = { borderTop, borderRight, borderBottom, borderLeft };
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
|
||
const RED = { widthPt: 1, color: '#FF0000' };
|
||
const V = '1pt solid #FF0000';
|
||
|
||
describe('setTableBorders', () => {
|
||
it('"all" on a single cell sets its four sides and mirrors the facing neighbour sides', () => {
|
||
const t = run(grid3(), 'E', 'all', RED);
|
||
expect(t.E).toEqual({ borderTop: V, borderRight: V, borderBottom: V, borderLeft: V });
|
||
expect(t.B.borderBottom).toBe(V); // above
|
||
expect(t.H.borderTop).toBe(V); // below
|
||
expect(t.D.borderRight).toBe(V); // left
|
||
expect(t.F.borderLeft).toBe(V); // right
|
||
// Corners untouched.
|
||
expect(t.A).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
|
||
// Neighbours' other sides untouched.
|
||
expect(t.B.borderTop).toBe(null);
|
||
expect(t.D.borderLeft).toBe(null);
|
||
});
|
||
|
||
it('"all" with the default 0.5pt black spec normalizes to null (table default)', () => {
|
||
const t = run(grid3(), 'E', 'all', { widthPt: 0.5, color: '#000000' });
|
||
expect(t.E).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
|
||
});
|
||
|
||
it('a null spec removes borders ("no border"), both sides of each boundary', () => {
|
||
const t = run(grid3(), 'E', 'all', null);
|
||
expect(t.E).toEqual({ borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' });
|
||
expect(t.B.borderBottom).toBe('none');
|
||
expect(t.F.borderLeft).toBe('none');
|
||
});
|
||
|
||
it('"outer" on a 2×2 region sets only the region edges (+ outside mirrors)', () => {
|
||
const t = run(grid3(), ['A', 'E'], 'outer', RED); // region = A B / D E
|
||
expect(t.A.borderTop).toBe(V);
|
||
expect(t.A.borderLeft).toBe(V);
|
||
expect(t.B.borderTop).toBe(V);
|
||
expect(t.B.borderRight).toBe(V);
|
||
expect(t.E.borderRight).toBe(V);
|
||
expect(t.E.borderBottom).toBe(V);
|
||
// Inner boundaries untouched.
|
||
expect(t.A.borderRight).toBe(null);
|
||
expect(t.A.borderBottom).toBe(null);
|
||
expect(t.E.borderTop).toBe(null);
|
||
expect(t.E.borderLeft).toBe(null);
|
||
// Outside neighbours mirror the shared edge.
|
||
expect(t.C.borderLeft).toBe(V);
|
||
expect(t.F.borderLeft).toBe(V);
|
||
expect(t.G.borderTop).toBe(V);
|
||
expect(t.H.borderTop).toBe(V);
|
||
expect(t.I).toEqual({ borderTop: null, borderRight: null, borderBottom: null, borderLeft: null });
|
||
});
|
||
|
||
it('"inner" on a 2×2 region sets only the boundaries between region cells', () => {
|
||
const t = run(grid3(), ['A', 'E'], 'inner', RED);
|
||
expect(t.A.borderRight).toBe(V);
|
||
expect(t.B.borderLeft).toBe(V);
|
||
expect(t.A.borderBottom).toBe(V);
|
||
expect(t.D.borderTop).toBe(V);
|
||
expect(t.E.borderTop).toBe(V);
|
||
expect(t.E.borderLeft).toBe(V);
|
||
// Region edges untouched.
|
||
expect(t.A.borderTop).toBe(null);
|
||
expect(t.A.borderLeft).toBe(null);
|
||
expect(t.E.borderRight).toBe(null);
|
||
expect(t.E.borderBottom).toBe(null);
|
||
expect(t.C.borderLeft).toBe(null);
|
||
});
|
||
|
||
it('"top" sets only the region top edge', () => {
|
||
const t = run(grid3(), ['D', 'E'], 'top', RED); // region = D E (row 1)
|
||
expect(t.D.borderTop).toBe(V);
|
||
expect(t.E.borderTop).toBe(V);
|
||
expect(t.A.borderBottom).toBe(V); // mirror above
|
||
expect(t.B.borderBottom).toBe(V);
|
||
expect(t.D.borderBottom).toBe(null);
|
||
expect(t.D.borderLeft).toBe(null);
|
||
expect(t.F.borderTop).toBe(null);
|
||
});
|
||
});
|
||
|
||
describe('activeBorderPresets', () => {
|
||
function stateFor(json: N, target: string | [string, string]): EditorState {
|
||
const doc = schema.nodeFromJSON(json);
|
||
const selection = typeof target === 'string'
|
||
? TextSelection.create(doc, textPos(doc, target))
|
||
: new CellSelection(doc.resolve(cellPos(doc, target[0])), doc.resolve(cellPos(doc, target[1])));
|
||
return EditorState.create({ schema, doc, selection });
|
||
}
|
||
const THIN = { widthPt: 0.5, color: '#000000' };
|
||
|
||
it('fresh table (default borders) + default pen → every preset active except none', () => {
|
||
const a = activeBorderPresets(stateFor(grid3(), ['A', 'I']), THIN)!;
|
||
expect(a.all && a.outer && a.inner && a.innerH && a.innerV && a.top && a.bottom && a.left && a.right).toBe(true);
|
||
expect(a.none).toBe(false);
|
||
});
|
||
|
||
it('active state follows the pen width: thicker pen → default borders no longer match', () => {
|
||
const a = activeBorderPresets(stateFor(grid3(), ['A', 'I']), { widthPt: 2.25, color: '#000000' })!;
|
||
expect(a.all).toBe(false);
|
||
expect(a.outer).toBe(false);
|
||
expect(a.none).toBe(false);
|
||
});
|
||
|
||
it('borderless region → only none active', () => {
|
||
// Remove all borders of the whole table first.
|
||
const st = stateFor(grid3(), ['A', 'I']);
|
||
let next: EditorState | null = null;
|
||
setTableBorders('all', null)({ state: st, tr: st.tr, dispatch: (tr: any) => { next = st.apply(tr); } } as any);
|
||
const a = activeBorderPresets(next!, THIN)!;
|
||
expect(a.none).toBe(true);
|
||
expect(a.all).toBe(false);
|
||
expect(a.top).toBe(false);
|
||
});
|
||
|
||
it('single cell: inner presets have no boundaries → inactive; edges active', () => {
|
||
const a = activeBorderPresets(stateFor(grid3(), 'E'), THIN)!;
|
||
expect(a.inner).toBe(false);
|
||
expect(a.innerH).toBe(false);
|
||
expect(a.all && a.outer && a.top && a.bottom && a.left && a.right).toBe(true);
|
||
});
|
||
|
||
it('a boundary with a mismatched color breaks the match for presets that cover it', () => {
|
||
const st = stateFor(grid3(), 'E');
|
||
let next: EditorState | null = null;
|
||
setTableBorders('top', { widthPt: 0.5, color: '#FF0000' })({ state: st, tr: st.tr, dispatch: (tr: any) => { next = st.apply(tr); } } as any);
|
||
const withSel = EditorState.create({ schema, doc: next!.doc, selection: TextSelection.create(next!.doc, textPos(next!.doc, 'E')) });
|
||
const a = activeBorderPresets(withSel, THIN)!;
|
||
expect(a.top).toBe(false); // now red, pen is black
|
||
expect(a.all).toBe(false);
|
||
expect(a.bottom).toBe(true); // untouched sides still match
|
||
const red = activeBorderPresets(withSel, { widthPt: 0.5, color: '#FF0000' })!;
|
||
expect(red.top).toBe(true);
|
||
});
|
||
|
||
it('returns null outside a table', () => {
|
||
const doc = schema.nodeFromJSON({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }] });
|
||
const st = EditorState.create({ schema, doc, selection: TextSelection.create(doc, 1) });
|
||
expect(activeBorderPresets(st, THIN)).toBe(null);
|
||
});
|
||
});
|
||
|
||
describe('border attr helpers', () => {
|
||
it('borderAttrValue: default collapses to null, none to "none", custom to canonical', () => {
|
||
expect(borderAttrValue({ widthPt: 0.5, color: '#000000' })).toBe(null);
|
||
expect(borderAttrValue(null)).toBe('none');
|
||
expect(borderAttrValue({ widthPt: 2.25, color: '#ff0000' })).toBe('2.25pt solid #FF0000');
|
||
});
|
||
|
||
it('parseBorderAttr inverts borderAttrValue', () => {
|
||
expect(parseBorderAttr(null)).toBe(null);
|
||
expect(parseBorderAttr('none')).toBe('none');
|
||
expect(parseBorderAttr('2.25pt solid #FF0000')).toEqual({ widthPt: 2.25, color: '#FF0000' });
|
||
});
|
||
|
||
it('borderAttrFromOdf: absent/none → "none", the 0.5pt-black default → null (unit tolerance)', () => {
|
||
expect(borderAttrFromOdf(undefined)).toBe('none');
|
||
expect(borderAttrFromOdf('none')).toBe('none');
|
||
expect(borderAttrFromOdf('0.5pt solid #000000')).toBe(null);
|
||
expect(borderAttrFromOdf('0.018cm solid #000000')).toBe(null); // LibreOffice cm form
|
||
expect(borderAttrFromOdf('2.25pt solid #ff0000')).toBe('2.25pt solid #FF0000');
|
||
expect(borderAttrFromOdf('0.06pt double #808080')).toBe('0.06pt solid #808080'); // style coerced
|
||
});
|
||
});
|
||
|
||
// A Word table draws only what something declares: its own w:tblBorders, else its table
|
||
// style's chain. Nothing anywhere (Normal Table) is no border — the gridlines Word shows
|
||
// on screen are not printed.
|
||
const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||
|
||
const TABLE_STYLES = `<?xml version="1.0"?>
|
||
<w:styles xmlns:w="${W}">
|
||
<w:style w:type="table" w:default="1" w:styleId="NormalTable"><w:name w:val="Normal Table"/></w:style>
|
||
<w:style w:type="table" w:styleId="Formula"><w:name w:val="Formel-tab"/>
|
||
<w:basedOn w:val="NormalTable"/><w:tblPr/></w:style>
|
||
<w:style w:type="table" w:styleId="Grid"><w:name w:val="Table Grid"/><w:basedOn w:val="NormalTable"/>
|
||
<w:tblPr><w:tblBorders>
|
||
<w:top w:val="single" w:sz="4" w:color="auto"/><w:left w:val="single" w:sz="4" w:color="auto"/>
|
||
<w:bottom w:val="single" w:sz="4" w:color="auto"/><w:right w:val="single" w:sz="4" w:color="auto"/>
|
||
<w:insideH w:val="single" w:sz="4" w:color="auto"/><w:insideV w:val="single" w:sz="4" w:color="auto"/>
|
||
</w:tblBorders></w:tblPr></w:style>
|
||
<w:style w:type="table" w:styleId="Thick"><w:name w:val="Thick"/><w:basedOn w:val="Grid"/>
|
||
<w:tblPr><w:tblBorders><w:top w:val="single" w:sz="24" w:color="FF0000"/></w:tblBorders></w:tblPr></w:style>
|
||
</w:styles>`;
|
||
|
||
const styledTable = (styleId: string | null) => `<w:tbl><w:tblPr>
|
||
${styleId ? `<w:tblStyle w:val="${styleId}"/>` : ''}
|
||
</w:tblPr><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>
|
||
<w:tr><w:tc><w:p><w:r><w:t>x</w:t></w:r></w:p></w:tc></w:tr></w:tbl>`;
|
||
|
||
const docxWith = (body: string) =>
|
||
zipSync({
|
||
'word/document.xml': strToU8(
|
||
`<?xml version="1.0"?><w:document xmlns:w="${W}"><w:body>${body}</w:body></w:document>`,
|
||
),
|
||
'word/styles.xml': strToU8(TABLE_STYLES),
|
||
});
|
||
|
||
const importedSides = (bytes: Uint8Array) => {
|
||
const table = (importDocx(bytes).content.content as N[]).find((n: N) => n.type === 'table')!;
|
||
const attrs = table.content[0].content[0].attrs;
|
||
return ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'].map((s) => attrs[s] ?? null);
|
||
};
|
||
|
||
describe('DOCX table borders', () => {
|
||
it('draws nothing when no style in the chain declares one', () => {
|
||
expect(importedSides(docxWith(styledTable('Formula')))).toEqual(['none', 'none', 'none', 'none']);
|
||
expect(importedSides(docxWith(styledTable(null)))).toEqual(['none', 'none', 'none', 'none']);
|
||
});
|
||
|
||
it("takes the table style's borders, the editor default left off the attrs", () => {
|
||
expect(importedSides(docxWith(styledTable('Grid')))).toEqual([null, null, null, null]);
|
||
});
|
||
|
||
it('lets a leaf style override one side and inherit the rest', () => {
|
||
expect(importedSides(docxWith(styledTable('Thick')))).toEqual(['3pt solid #FF0000', null, null, null]);
|
||
});
|
||
|
||
it('lets the table’s own w:tblBorders win over its style', () => {
|
||
const own = styledTable('Grid').replace(
|
||
'</w:tblPr>',
|
||
'<w:tblBorders><w:top w:val="nil"/></w:tblBorders></w:tblPr>',
|
||
);
|
||
expect(importedSides(docxWith(own))).toEqual(['none', null, null, null]);
|
||
});
|
||
});
|