The stripe began one row late: regionsAt counted the second body row as the first band. Probed on a five-row fixture with a red band1Horz — LibreOffice shades row 0 where the style paints no header region, and the row under the header where it does. The corpus cannot see this (no fixture uses the editor's own registry), so it stayed at 739; checked by eye and by the unit test's inverted expectations. A document with a table style assigned re-bands on its next open.
352 lines
16 KiB
TypeScript
352 lines
16 KiB
TypeScript
// Table styles, the third named-style family — Word's table styles / LibreOffice's table
|
||
// AutoFormats. A style paints conditional regions (header row, banded rows, first column,
|
||
// …); resolveTableCell is the single source of truth for applying, CSS, export and import.
|
||
|
||
import { textDeclarations, type TextProps } from './styleSheet';
|
||
|
||
// Ascending precedence, as in Word: a later region's properties win over an earlier one's.
|
||
// `wholeTable` is the base rather than a conditional region, so it is layered first and
|
||
// never listed in a cell's `region` attr.
|
||
export const TABLE_REGIONS = [
|
||
'bandedColumn', 'bandedRow', 'lastColumn', 'firstColumn', 'lastRow', 'headerRow',
|
||
] as const;
|
||
export type TableRegion = (typeof TABLE_REGIONS)[number];
|
||
|
||
// Word's "Table Style Options": which conditional regions a given table opts into. A
|
||
// region paints only when the style defines it AND the table's look enables it, so the
|
||
// same style reads differently per table (and the gallery previews follow the toggles).
|
||
export type TableLook = Record<TableRegion, boolean>;
|
||
|
||
// Word's default for a new table (its w:tblLook 04A0): header row, first column, bands.
|
||
export const DEFAULT_TABLE_LOOK: TableLook = {
|
||
headerRow: true, firstColumn: true, bandedRow: true,
|
||
lastRow: false, lastColumn: false, bandedColumn: false,
|
||
};
|
||
|
||
const isRegion = (v: string): v is TableRegion => (TABLE_REGIONS as readonly string[]).includes(v);
|
||
|
||
// Stored space-separated on the table node, like a cell's `region` list. An absent attr
|
||
// means the Word default, so a table predating the toggles keeps looking the same.
|
||
export function parseTableLook(attr: unknown): TableLook {
|
||
if (typeof attr !== 'string') return { ...DEFAULT_TABLE_LOOK };
|
||
const on = new Set(attr.split(' ').filter(isRegion));
|
||
return Object.fromEntries(TABLE_REGIONS.map(r => [r, on.has(r)])) as TableLook;
|
||
}
|
||
|
||
export function tableLookAttr(look: TableLook): string {
|
||
return TABLE_REGIONS.filter(r => look[r]).join(' ');
|
||
}
|
||
|
||
export type BorderSideKey = 'top' | 'right' | 'bottom' | 'left';
|
||
|
||
export type TableRegionProps = {
|
||
// null = explicitly no fill; undefined = inherit from the lower-precedence region.
|
||
fill?: string | null;
|
||
text?: TextProps;
|
||
// Border overrides for the region's own sides, e.g. a rule under the header row.
|
||
borders?: Partial<Record<BorderSideKey, string | null>>;
|
||
};
|
||
|
||
// Border values follow the cell attrs (tableCellBorders.ts): null = the table default
|
||
// 0.5pt black, 'none' = no border, else the canonical '<W>pt solid #RRGGBB'.
|
||
export type TableStyle = {
|
||
name: string;
|
||
builtin?: boolean;
|
||
// The Table Style Options this style is about, applied on assignment over whatever the
|
||
// table already had. Defining all six areas keeps every toggle meaningful, so without
|
||
// this a row-banded and a column-banded style would be indistinguishable.
|
||
look?: Partial<TableLook>;
|
||
border?: string | null; // the table's outer edges
|
||
innerBorder?: string | null; // the grid lines inside it
|
||
innerBorderH?: string | null; // horizontal inner lines only (falls back to innerBorder)
|
||
innerBorderV?: string | null; // vertical inner lines only
|
||
wholeTable?: TableRegionProps;
|
||
regions: Partial<Record<TableRegion, TableRegionProps>>;
|
||
};
|
||
|
||
// The families Word and LibreOffice ship: plain grids, shaded lists, the coloured box
|
||
// lists, Word's accent tables, and the rule-only academic/financial pair. Colours follow
|
||
// the Office theme accents; a yellow header keeps black text to stay readable.
|
||
const HEADER_FILL = '#F2F2F2';
|
||
const BAND_FILL = '#F7F7F7';
|
||
const ACCENT = '#4472C4';
|
||
const ACCENT_LIGHT = '#D9E2F3';
|
||
const ACCENT_MID = '#B4C6E7';
|
||
|
||
// Every built-in defines all six conditional areas, so each Table-Style-Option toggle
|
||
// visibly does something (as in Word).
|
||
const boxList = (name: string, header: string, band: string, mid: string, text = '#FFFFFF'): TableStyle => ({
|
||
name, builtin: true, border: 'none', innerBorder: 'none',
|
||
regions: {
|
||
headerRow: { fill: header, text: { bold: true, color: text } },
|
||
bandedRow: { fill: band },
|
||
bandedColumn: { fill: band },
|
||
// The colourful families shade their emphasis areas as well, so switching one on
|
||
// is as visible as the header row (Word's Grid Table families do the same).
|
||
firstColumn: { fill: mid, text: { bold: true } },
|
||
lastColumn: { fill: mid, text: { bold: true } },
|
||
lastRow: { fill: mid, text: { bold: true }, borders: { top: `1pt solid ${header}` } },
|
||
},
|
||
});
|
||
|
||
// The grey and rule-based families keep emphasis text-only, as in Word.
|
||
const emphasis = { text: { bold: true } };
|
||
const greyEdges = {
|
||
firstColumn: emphasis,
|
||
lastColumn: emphasis,
|
||
lastRow: { ...emphasis, borders: { top: '1pt solid #000000' } },
|
||
};
|
||
|
||
export const TABLE_BUILTINS: TableStyle[] = [
|
||
{ name: 'Simple Grid', builtin: true, border: null, innerBorder: null,
|
||
regions: {
|
||
headerRow: { fill: HEADER_FILL, text: { bold: true } },
|
||
bandedRow: { fill: BAND_FILL }, bandedColumn: { fill: BAND_FILL },
|
||
...greyEdges,
|
||
} },
|
||
{ name: 'Simple Grid Rows', builtin: true, border: null, innerBorderH: null, innerBorderV: 'none',
|
||
regions: {
|
||
headerRow: { fill: HEADER_FILL, text: { bold: true } },
|
||
bandedRow: { fill: BAND_FILL }, bandedColumn: { fill: BAND_FILL },
|
||
...greyEdges,
|
||
} },
|
||
{ name: 'Simple Grid Columns', builtin: true, border: null, innerBorderH: 'none', innerBorderV: null,
|
||
look: { bandedRow: false, bandedColumn: true },
|
||
regions: {
|
||
headerRow: { fill: HEADER_FILL, text: { bold: true } },
|
||
bandedRow: { fill: BAND_FILL }, bandedColumn: { fill: BAND_FILL },
|
||
...greyEdges,
|
||
} },
|
||
{ name: 'Simple List Shaded', builtin: true, border: null, innerBorder: 'none',
|
||
regions: {
|
||
headerRow: { fill: HEADER_FILL, text: { bold: true } },
|
||
bandedRow: { fill: BAND_FILL }, bandedColumn: { fill: BAND_FILL },
|
||
...greyEdges,
|
||
} },
|
||
{ name: 'Simple List Columns', builtin: true, border: null, innerBorder: 'none',
|
||
look: { bandedRow: false, bandedColumn: true },
|
||
regions: {
|
||
headerRow: { fill: HEADER_FILL, text: { bold: true } },
|
||
bandedColumn: { fill: BAND_FILL }, bandedRow: { fill: BAND_FILL },
|
||
...greyEdges,
|
||
} },
|
||
// Word's "Plain Table": no lines at all, emphasis by weight only.
|
||
{ name: 'Plain Table', builtin: true, border: 'none', innerBorder: 'none',
|
||
regions: {
|
||
headerRow: { text: { bold: true }, borders: { bottom: '0.75pt solid #000000' } },
|
||
bandedRow: { fill: BAND_FILL }, bandedColumn: { fill: BAND_FILL },
|
||
...greyEdges,
|
||
} },
|
||
boxList('Box List Blue', '#4A7EBB', '#DCE6F1', '#B8CCE4'),
|
||
boxList('Box List Green', '#77933C', '#EBF1DE', '#D6E3BC'),
|
||
boxList('Box List Red', '#C0504D', '#F2DCDB', '#E5B8B7'),
|
||
boxList('Box List Yellow', '#E6B800', '#FFF2CC', '#FFE699', '#000000'),
|
||
{ name: 'Grid Table Accent', builtin: true, border: `0.5pt solid #8EAADB`, innerBorder: '0.5pt solid #8EAADB',
|
||
regions: {
|
||
headerRow: { fill: ACCENT, text: { bold: true, color: '#FFFFFF' } },
|
||
bandedRow: { fill: ACCENT_LIGHT }, bandedColumn: { fill: ACCENT_LIGHT },
|
||
firstColumn: { fill: ACCENT_MID, text: { bold: true } },
|
||
lastColumn: { fill: ACCENT_MID, text: { bold: true } },
|
||
lastRow: { fill: ACCENT_MID, text: { bold: true }, borders: { top: `1pt solid ${ACCENT}` } },
|
||
} },
|
||
{ name: 'List Table Accent', builtin: true, border: 'none', innerBorderH: null, innerBorderV: 'none',
|
||
regions: {
|
||
headerRow: { text: { bold: true, color: ACCENT }, borders: { bottom: `1pt solid ${ACCENT}` } },
|
||
bandedRow: { fill: ACCENT_LIGHT }, bandedColumn: { fill: ACCENT_LIGHT },
|
||
firstColumn: { fill: ACCENT_MID, text: { bold: true } },
|
||
lastColumn: { fill: ACCENT_MID, text: { bold: true } },
|
||
lastRow: { fill: ACCENT_MID, text: { bold: true }, borders: { top: `1pt solid ${ACCENT}` } },
|
||
} },
|
||
{ name: 'Academic', builtin: true, border: 'none', innerBorder: 'none',
|
||
look: { bandedRow: false, bandedColumn: false, firstColumn: false },
|
||
regions: {
|
||
headerRow: { text: { bold: true }, borders: { top: '1pt solid #000000', bottom: '0.75pt solid #000000' } },
|
||
lastRow: { borders: { bottom: '1pt solid #000000' } },
|
||
bandedRow: { fill: BAND_FILL }, bandedColumn: { fill: BAND_FILL },
|
||
firstColumn: emphasis, lastColumn: emphasis,
|
||
} },
|
||
{ name: 'Financial', builtin: true, border: 'none', innerBorder: 'none',
|
||
look: { lastRow: true, bandedRow: false, bandedColumn: false },
|
||
regions: {
|
||
headerRow: { text: { bold: true }, borders: { top: '1pt solid #000000', bottom: '0.75pt solid #000000' } },
|
||
firstColumn: emphasis, lastColumn: emphasis,
|
||
lastRow: { ...emphasis, borders: { top: '0.75pt solid #000000', bottom: '1pt solid #000000' } },
|
||
bandedRow: { fill: BAND_FILL }, bandedColumn: { fill: BAND_FILL },
|
||
} },
|
||
];
|
||
|
||
export function builtinTableStyles(): Record<string, TableStyle> {
|
||
const out: Record<string, TableStyle> = {};
|
||
for (const s of TABLE_BUILTINS) out[s.name] = structuredClone(s);
|
||
return out;
|
||
}
|
||
|
||
export type CellCoords = {
|
||
row: number; col: number;
|
||
rowSpan?: number; colSpan?: number;
|
||
rows: number; cols: number;
|
||
};
|
||
|
||
export type ResolvedTableCell = {
|
||
regions: TableRegion[];
|
||
fill: string | null;
|
||
text: TextProps;
|
||
borders: Record<'borderTop' | 'borderRight' | 'borderBottom' | 'borderLeft', string | null>;
|
||
};
|
||
|
||
// 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, 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,
|
||
): TableRegion[] {
|
||
// A region counts as present only when the style paints it and the table opts in —
|
||
// that also decides whether banding skips the header row / first column.
|
||
const on = (name: TableRegion) => !!style.regions[name] && look[name];
|
||
const bodyRow = row - (on('headerRow') ? 1 : 0);
|
||
const bodyCol = col - (on('firstColumn') ? 1 : 0);
|
||
const matches: Record<TableRegion, boolean> = {
|
||
bandedColumn: bodyCol >= 0 && bodyCol % 2 === 0,
|
||
bandedRow: bodyRow >= 0 && bodyRow % 2 === 0,
|
||
lastColumn: col === cols - 1,
|
||
firstColumn: col === 0,
|
||
lastRow: row === rows - 1,
|
||
headerRow: row === 0,
|
||
};
|
||
return TABLE_REGIONS.filter(name => on(name) && matches[name]);
|
||
}
|
||
|
||
// The border of one grid line, decided from both sides so the two facing cells always
|
||
// agree (collapsed borders must not disagree — see setTableBorders).
|
||
function boundary(
|
||
style: TableStyle,
|
||
before: TableRegion[] | null,
|
||
after: TableRegion[] | null,
|
||
beforeSide: BorderSideKey,
|
||
afterSide: BorderSideKey,
|
||
): string | null {
|
||
const horizontal = beforeSide === 'bottom';
|
||
let value = before === null || after === null
|
||
? style.border
|
||
: (horizontal ? style.innerBorderH : style.innerBorderV) ?? style.innerBorder;
|
||
if (value === undefined) value = null;
|
||
const layer = (regions: TableRegion[] | null, side: BorderSideKey) => {
|
||
if (!regions) return;
|
||
for (const name of regions) {
|
||
const v = style.regions[name]?.borders?.[side];
|
||
if (v !== undefined) value = v;
|
||
}
|
||
};
|
||
layer(before, beforeSide);
|
||
layer(after, afterSide);
|
||
return value;
|
||
}
|
||
|
||
// Everything a table style paints on one cell. Fill and borders are materialized as cell
|
||
// attrs (so export/PDF/clipboard need no extra work); `text` is rendered via CSS keyed on
|
||
// the `region` attr and baked onto the runs on export.
|
||
export function resolveTableCell(
|
||
style: TableStyle, cell: CellCoords, look: TableLook = DEFAULT_TABLE_LOOK,
|
||
): ResolvedTableCell {
|
||
const { row, col, rows, cols } = cell;
|
||
const rowEnd = row + (cell.rowSpan ?? 1);
|
||
const colEnd = col + (cell.colSpan ?? 1);
|
||
const at = (r: number, c: number) => regionsAt(style, look, r, c, rows, cols);
|
||
const regions = at(row, col);
|
||
|
||
let fill: string | null = style.wholeTable?.fill ?? null;
|
||
const text: TextProps = { ...style.wholeTable?.text };
|
||
for (const name of regions) {
|
||
const props = style.regions[name];
|
||
if (!props) continue;
|
||
if (props.fill !== undefined) fill = props.fill;
|
||
for (const [key, v] of Object.entries(props.text ?? {})) {
|
||
if (v !== undefined) (text as Record<string, unknown>)[key] = v;
|
||
}
|
||
}
|
||
|
||
return {
|
||
regions,
|
||
fill,
|
||
text,
|
||
borders: {
|
||
borderTop: boundary(style, row > 0 ? at(row - 1, col) : null, regions, 'bottom', 'top'),
|
||
borderBottom: boundary(style, at(rowEnd - 1, col), rowEnd < rows ? at(rowEnd, col) : null, 'bottom', 'top'),
|
||
borderLeft: boundary(style, col > 0 ? at(row, col - 1) : null, regions, 'right', 'left'),
|
||
borderRight: boundary(style, at(row, colEnd - 1), colEnd < cols ? at(row, colEnd) : null, 'right', 'left'),
|
||
},
|
||
};
|
||
}
|
||
|
||
// The text formatting a cell inherits from its table style, read off the `region` attr
|
||
// the paint left on it. Shared by the editor (Bold button) and both exporters.
|
||
export function regionText(style: TableStyle | null | undefined, regionAttr: unknown): TextProps {
|
||
if (!style) return {};
|
||
const text: TextProps = { ...style.wholeTable?.text };
|
||
for (const name of String(regionAttr ?? '').split(' ').filter(Boolean)) {
|
||
Object.assign(text, style.regions[name as TableRegion]?.text ?? {});
|
||
}
|
||
return text;
|
||
}
|
||
|
||
// Inline CSS for one cell of a style's preview grid (the toolbar gallery and the style
|
||
// manager show the same tile). Widths collapse to 1–2px so a thick rule stays legible;
|
||
// the table default border rides on currentColor, so the tile has to carry a page color.
|
||
export function previewCellCss(
|
||
style: TableStyle, row: number, col: number, rows: number, cols: number,
|
||
look: TableLook = DEFAULT_TABLE_LOOK,
|
||
): string {
|
||
const paint = resolveTableCell(style, { row, col, rows, cols }, look);
|
||
const side = (value: string | null): string => {
|
||
if (value === 'none') return 'none';
|
||
if (value === null) return '1px solid currentColor';
|
||
const m = /^([\d.]+)pt solid (#[0-9A-Fa-f]{6})$/.exec(value);
|
||
if (!m) return 'none';
|
||
return `${parseFloat(m[1]) > 0.75 ? 2 : 1}px solid ${m[2]}`;
|
||
};
|
||
const borders = (['Top', 'Right', 'Bottom', 'Left'] as const)
|
||
.map(s => `border-${s.toLowerCase()}: ${side(paint.borders[`border${s}`])}`)
|
||
.join(';');
|
||
return `background: ${paint.fill ?? 'transparent'};${borders};font-weight: ${paint.text.bold ? 700 : 400}`;
|
||
}
|
||
|
||
// The options a style is applied with: its own declared ones over the table's current.
|
||
export function styleLook(style: TableStyle | undefined, table: TableLook): TableLook {
|
||
return { ...table, ...style?.look };
|
||
}
|
||
|
||
// The text bar inside a preview cell: a schematic line whose weight and colour follow
|
||
// the region, so bold-only areas (first/last column, total row) are visible in a tile —
|
||
// they would otherwise look identical, the tiles carrying no real text.
|
||
export function previewTextCss(
|
||
style: TableStyle, row: number, col: number, rows: number, cols: number,
|
||
look: TableLook = DEFAULT_TABLE_LOOK,
|
||
): string {
|
||
const { text } = resolveTableCell(style, { row, col, rows, cols }, look);
|
||
const bold = text.bold === true;
|
||
return `height: ${bold ? 2 : 1}px; background: ${text.color ?? 'currentColor'};`
|
||
+ `opacity: ${bold ? 0.85 : 0.45};`
|
||
// A slant for italic, so an italic-only region is visible too.
|
||
+ `transform: ${text.italic ? 'skewX(-14deg)' : 'none'}`;
|
||
}
|
||
|
||
// One rule per style and region, emitted in ascending precedence so an overlap (header
|
||
// row ∩ first column) resolves by source order. The descendant selector is required: the
|
||
// Standard paragraph rule would otherwise beat a plain cell rule on font/size/color.
|
||
export function tableStyleCss(styles: Record<string, TableStyle>): string {
|
||
const rules: string[] = [];
|
||
for (const style of Object.values(styles ?? {})) {
|
||
const table = `.paper .tiptap table[data-table-style="${style.name.replace(/"/g, '\\"')}"]`;
|
||
const emit = (cellSel: string, text: TextProps | undefined) => {
|
||
const decls = textDeclarations(text ?? {});
|
||
if (!decls.length) return;
|
||
const selectors = [`${table} ${cellSel}`, `${table} ${cellSel} :is(p,h1,h2,h3,h4,h5,li)`];
|
||
rules.push(`${selectors.join(',\n')} {\n ${decls.join(';\n ')};\n}`);
|
||
};
|
||
emit(':is(td,th)', style.wholeTable?.text);
|
||
for (const name of TABLE_REGIONS) emit(`:is(td,th)[data-region~="${name}"]`, style.regions[name]?.text);
|
||
}
|
||
return rules.join('\n\n');
|
||
}
|