fix: imports re-derive a registry table style's regions and unbake its text

Both importers resolve the table's style name + look + grid position through
resolveTableCell: the region attr comes back on each cell, a bold region's
runs convert like headings (boldByDefault) and a run matching the region's
color loses that mark — the style's own text no longer lands as direct
formatting on reopen. A DOCX file carrying its own w:tblStylePr keeps the
baked areas instead; a style name not in the registry stays untouched.
This commit is contained in:
Steffen Becker committed 2026-08-16 13:42:24 +02:00
1 parent c473fd5f0f
commit 4c55e9b9f5
2 files changed
+98 -25

No files matched your search

+32 -4
View File
@@ -7,7 +7,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { fitInlineImage, framePx } from '../editor/extensions/image';
import { formatTabStops } from '../editor/extensions/tabStops';
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
import { tableLookAttr } from '../styles/tableStyles';
import { builtinTableStyles, parseTableLook, resolveTableCell, tableLookAttr } from '../styles/tableStyles';
import { formatOrdinal, orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
@@ -2186,6 +2186,22 @@ function condPaint(areas: CondArea[]): { fill?: string; run: RunProps } {
return { fill, run };
}
// The export bakes a region's color onto every run (it is CSS in the editor); a run
// matching the region's own color carries the style, not direct formatting. Bold rides
// the boldByDefault channel instead, so an explicitly normal run keeps its override.
function unbakeRegionColor(nodes: Node[], color: string): void {
for (const n of nodes) {
if (n.content) unbakeRegionColor(n.content, color);
const ts = (n.marks ?? []).find((m) => m.type === 'textStyle');
if (ts?.attrs?.color !== color) continue;
delete ts.attrs.color;
if (Object.keys(ts.attrs).length === 0) {
n.marks = n.marks!.filter((m) => m !== ts);
if (n.marks.length === 0) delete n.marks;
}
}
}
// 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 {
@@ -2269,6 +2285,12 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
const conds = ctx.styles.tableConditional(styleId);
const flags = docxLookFlags(fc(tbl, 'tblPr'));
const band = ctx.styles.tableBandSize(styleId);
// A registry style instead is name-only (our own export): its regions are re-derived
// per cell from name + look + grid position. A file carrying w:tblStylePr wins above.
const named = ctx.styles.tableStyleName(styleId);
const look = docxTableLook(fc(tbl, 'tblPr'));
const regStyle = !conds.size && named ? builtinTableStyles()[named] : undefined;
const regLook = parseTableLook(look);
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++) {
@@ -2290,11 +2312,19 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
const box: GridBox = { row: ri, col, rowEnd, colEnd: col + colspan };
const areas = conds.size ? condAreasFor(conds, flags, band, box, trs.length, gridCols) : [];
const paint = condPaint(areas);
const styled = regStyle ? resolveTableCell(regStyle, {
row: ri, col, rowSpan: rowEnd - ri, colSpan: colspan, rows: trs.length, cols: gridCols,
}, regLook) : null;
const fill = fc(tcPr, 'shd')?.getAttributeNS(W, 'fill');
const bg = fill ? hexColor(fill) : paint.fill;
const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell', bg === HEADER_SHADE);
// Header-shaded cells and a bold region render bold by default (CSS); a baked-bold
// run needs no mark, only an explicitly normal one gets fontWeight:normal.
const blocks = convertBlocks(Array.from(tc.children), ctx, 'cell',
bg === HEADER_SHADE || styled?.text.bold === true);
bakeCellRuns(blocks, paint.run);
if (styled?.text.color) unbakeRegionColor(blocks, styled.text.color);
const attrs: Record<string, unknown> = { colspan, rowspan: 1 };
if (styled?.regions.length) attrs.region = styled.regions.join(' ');
if (bg) attrs.backgroundColor = bg;
// w:vAlign — Word's "center" is the editor's "middle"; "top"/"both" stay the default.
const vAlignEl = fc(tcPr, 'vAlign');
@@ -2349,7 +2379,6 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
rows.push(row);
}
if (rows.length === 0) return null;
const named = ctx.styles.tableStyleName(styleId);
const attrs: Record<string, unknown> = { ...(tableMargins(tbl, useWeights, ctx, padBase[3]) ?? {}) };
if (pad) attrs.cellPadding = pad;
// w:tblHeader on the first row: Word repeats it at the top of every page the table
@@ -2360,7 +2389,6 @@ function buildTable(tbl: Element, ctx: Ctx): Node | null {
if (named) {
attrs.tableStyle = named;
// '' is a declared all-off look — dropping it would revert to the default look.
const look = docxTableLook(fc(tbl, 'tblPr'));
if (look != null) attrs.tableLook = look;
}
return Object.keys(attrs).length ? { type: 'table', attrs, content: rows } : { type: 'table', content: rows };
+66 -21
View File
@@ -7,7 +7,10 @@ import { fitInlineImage, framePx } from '../editor/extensions/image';
import { odfChartDataUrl } from './chart';
import { formatTabStops, normalizeLeader } from '../editor/extensions/tabStops';
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
import { TABLE_REGIONS, tableLookAttr, type TableLook, type TableRegion } from '../styles/tableStyles';
import {
TABLE_REGIONS, builtinTableStyles, parseTableLook, resolveTableCell, tableLookAttr,
type TableLook, type TableRegion,
} from '../styles/tableStyles';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
@@ -2561,9 +2564,58 @@ const FORMAT_KINDS: Record<string, FormatKind | undefined> = {
'currency-style': 'currency', 'date-style': 'date',
};
// The export bakes a region's color onto every run (it is CSS in the editor); a run
// matching the region's own color carries the style, not direct formatting. Bold rides
// the boldByDefault channel instead, so an explicitly normal run keeps its override.
function unbakeRegionColor(nodes: Node[], color: string): void {
for (const n of nodes) {
if (n.content) unbakeRegionColor(n.content, color);
const ts = (n.marks ?? []).find((m) => m.type === 'textStyle');
if (ts?.attrs?.color !== color) continue;
delete ts.attrs.color;
if (Object.keys(ts.attrs).length === 0) {
n.marks = n.marks!.filter((m) => m !== ts);
if (n.marks.length === 0) delete n.marks;
}
}
}
function convertTable(el: Element, ctx: Ctx): Node | null {
const weights = columnWeights(el, ctx.resolver);
// The named table style behind the automatic one, hoisted: a registry style's regions
// are re-derived per cell from name + look + grid position, as the editor paints them.
const named = ctx.resolver.namedAncestor(el.getAttributeNS(NS.table, 'style-name'), 'table');
const styleName = named ? displayStyleName(named) : null;
const look = odfTableLook(el);
const regStyle = styleName ? builtinTableStyles()[styleName] : undefined;
const regLook = parseTableLook(look);
const rowEls: { el: Element; header: boolean }[] = [];
for (const child of Array.from(el.children)) {
if (child.namespaceURI !== NS.table) continue;
if (child.localName === 'table-row') rowEls.push({ el: child, header: false });
else if (child.localName === 'table-header-rows' || child.localName === 'table-rows') {
const header = child.localName === 'table-header-rows';
for (const rowEl of Array.from(child.children)) {
if (rowEl.namespaceURI === NS.table && rowEl.localName === 'table-row') rowEls.push({ el: rowEl, header });
}
}
}
// Grid extent up front — resolveTableCell needs the totals (lastRow, banding).
const gridRows = rowEls.length;
let gridCols = weights?.length ?? 0;
if (!gridCols && rowEls[0]) {
for (const c of Array.from(rowEls[0].el.children)) {
if (c.namespaceURI !== NS.table) continue;
const rep = Math.min(256, parseInt(c.getAttributeNS(NS.table, 'number-columns-repeated') ?? '1', 10) || 1);
if (c.localName === 'covered-table-cell') gridCols += rep;
else if (c.localName === 'table-cell') {
gridCols += rep * (parseInt(c.getAttributeNS(NS.table, 'number-columns-spanned') ?? '1', 10) || 1);
}
}
}
const rows: Node[] = [];
// Source-row bookkeeping: a row of only covered cells is dropped (the editor cannot
// hold a cell-less row), so every span reaching over it must shrink by one.
@@ -2606,9 +2658,15 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
const v = borderAttrFromOdf(rawBorders[side]);
if (v !== null) borders[attr] = v;
}
// Header-shaded cells render bold by default (CSS); convert their runs like headings
// so a baked-bold run needs no mark and only an explicitly normal run gets one.
const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell', backgroundColor === HEADER_SHADE);
// Header-shaded cells and a bold region render bold by default (CSS); convert their
// runs like headings, so a baked-bold run needs no mark and only a normal run gets one.
const paint = regStyle ? resolveTableCell(regStyle, {
row: rowIdx, col: colIndex, rowSpan: rowspan, colSpan: colspan,
rows: gridRows, cols: gridCols,
}, regLook) : null;
const blocks = convertBlocks(Array.from(cellEl.children), ctx, 'cell',
backgroundColor === HEADER_SHADE || paint?.text.bold === true);
if (paint?.text.color) unbakeRegionColor(blocks, paint.text.color);
// LibreOffice writes the formula in its own language behind an `ooow:` prefix;
// the cached result is the cell's text and needs nothing here.
const rawFormula = cellEl.getAttributeNS(NS.table, 'formula');
@@ -2617,6 +2675,7 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
const cellFormat = formula ? cellNumberFormat(cellEl, ctx) : null;
for (let r = 0; r < repeated; r++) {
const attrs: Record<string, unknown> = { colspan, rowspan, ...borders };
if (paint?.regions.length) attrs.region = paint.regions.join(' ');
if (rowspan > 1) placed.push({ attrs, row: rowIdx, span: rowspan });
if (formula) attrs.formula = formula;
if (cellFormat) attrs.cellFormat = cellFormat;
@@ -2640,26 +2699,13 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
rows.push(row);
};
for (const child of Array.from(el.children)) {
if (child.namespaceURI !== NS.table) continue;
if (child.localName === 'table-row') addRow(child, false);
else if (child.localName === 'table-header-rows' || child.localName === 'table-rows') {
const header = child.localName === 'table-header-rows';
for (const rowEl of Array.from(child.children)) {
if (rowEl.namespaceURI === NS.table && rowEl.localName === 'table-row') addRow(rowEl, header);
}
}
}
for (const { el: rowEl, header } of rowEls) addRow(rowEl, header);
if (rows.length === 0) return null;
if (dropped.length) for (const p of placed) {
const cut = dropped.filter((d) => d > p.row && d < p.row + p.span).length;
if (cut) p.attrs.rowspan = p.span - cut;
}
// The named table style behind the automatic one. ODF stores no banding, so only the
// name comes back — the look rides on the cell attrs above, and the editor re-derives
// the regions from the registry (refreshTableStyles).
const named = ctx.resolver.namedAncestor(el.getAttributeNS(NS.table, 'style-name'), 'table');
const attrs: Record<string, unknown> = { ...(tableMargins(el, ctx) ?? {}), ...tableSpacing(el, ctx) };
if (cellPad) attrs.cellPadding = cellPad;
// The default is to allow a break, so only the explicit "no" is worth an attr.
@@ -2671,12 +2717,11 @@ function convertTable(el: Element, ctx: Ctx): Node | null {
if (Array.from(el.children).some(c => c.namespaceURI === NS.table && c.localName === 'table-header-rows')) {
attrs.repeatHeader = true;
}
if (named) {
attrs.tableStyle = displayStyleName(named);
if (styleName) {
attrs.tableStyle = styleName;
// Which conditional areas the table opts into (ODF's table template attributes).
// Absent ⇒ leave the attr null, so parseTableLook falls back to the default.
// '' is a declared all-off look — dropping it would revert to the default look.
const look = odfTableLook(el);
if (look != null) attrs.tableLook = look;
}
return Object.keys(attrs).length ? { type: 'table', attrs, content: rows } : { type: 'table', content: rows };