Skip to content

Commit 8c57b1c

Browse files
authored
fix: handle table cell borders from styles (#1722)
* fix: separate table borders from row borders * fix: handle table cell borders from styles * fix: update screenshots * fix: additional borders fixes
1 parent 6b2d1ea commit 8c57b1c

12 files changed

Lines changed: 696 additions & 66 deletions

File tree

44 Bytes
Loading
30.7 KB
Loading

packages/super-editor/src/core/super-converter/v3/handlers/w/tbl/tbl-translator.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { translateChildNodes } from '@core/super-converter/v2/exporter/helpers/i
66
import { translator as trTranslator } from '../tr';
77
import { translator as tblPrTranslator } from '../tblPr';
88
import { translator as tblGridTranslator } from '../tblGrid';
9+
import { translator as tblStylePrTranslator } from '@converter/v3/handlers/w/tblStylePr';
910
import { buildFallbackGridForTable } from '@core/super-converter/helpers/tableFallbackHelpers.js';
1011

1112
/** @type {import('@translator').XmlNodeName} */
@@ -83,6 +84,8 @@ const encode = (params, encodedAttrs) => {
8384
};
8485
}
8586
}
87+
88+
const tableLook = encodedAttrs.tableProperties.tblLook;
8689
// Table borders can be specified in tblPr or inside a referenced style tag
8790
const borderProps = _processTableBorders(encodedAttrs.tableProperties.borders || {});
8891
const referencedStyles = _getReferencedTableStyles(encodedAttrs.tableStyleId, params) || {};
@@ -124,7 +127,8 @@ const encode = (params, encodedAttrs) => {
124127
extraParams: {
125128
row,
126129
table: node,
127-
rowBorders: encodedAttrs.borders,
130+
tableBorders: encodedAttrs.borders,
131+
tableLook,
128132
columnWidths,
129133
activeRowSpans: activeRowSpans.slice(),
130134
rowIndex,
@@ -340,7 +344,19 @@ export function _getReferencedTableStyles(tableStyleReference, params) {
340344
}
341345
}
342346

343-
return stylesToReturn;
347+
const tblStylePr = styleTag.elements.filter((el) => el.name === 'w:tblStylePr');
348+
let styleProps = {};
349+
if (tblStylePr) {
350+
styleProps = tblStylePr.reduce((acc, el) => {
351+
acc[el.attributes['w:type']] = tblStylePrTranslator.encode({ ...params, nodes: [el] });
352+
return acc;
353+
}, {});
354+
}
355+
356+
return {
357+
...stylesToReturn,
358+
...styleProps,
359+
};
344360
}
345361

346362
/**

packages/super-editor/src/core/super-converter/v3/handlers/w/tblLook/tblLook-translator.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
11
import { NodeTranslator } from '@translator';
22
import { createAttributeHandler, parseBoolean, booleanToString } from '../../utils.js';
33

4+
/**
5+
* Bitmask values for tblLook conditional formatting flags.
6+
* These correspond to OOXML tblLook@w:val bitmask positions.
7+
* @see ECMA-376 Part 1, Section 17.4.56
8+
*/
9+
const tblLookBitmask = Object.freeze({
10+
firstRow: 0x0020,
11+
lastRow: 0x0040,
12+
firstColumn: 0x0080,
13+
lastColumn: 0x0100,
14+
noHBand: 0x0200,
15+
noVBand: 0x0400,
16+
});
17+
18+
/**
19+
* Decodes a tblLook w:val bitmask string into individual boolean flags.
20+
* @param {string|number|null|undefined} val - The bitmask value (hex or decimal string)
21+
* @returns {Object<string, boolean>|null} Object with boolean flags, or null if invalid
22+
*/
23+
const decodeTblLookVal = (val) => {
24+
if (!val) return null;
25+
const raw = typeof val === 'string' ? val.trim() : String(val);
26+
27+
// Try hex first (most common in OOXML), then fall back to decimal
28+
let numeric = Number.parseInt(raw, 16);
29+
if (!Number.isFinite(numeric)) {
30+
numeric = Number.parseInt(raw, 10);
31+
}
32+
if (!Number.isFinite(numeric)) return null;
33+
34+
return Object.fromEntries(Object.entries(tblLookBitmask).map(([key, mask]) => [key, (numeric & mask) === mask]));
35+
};
36+
437
/**
538
* The NodeTranslator instance for the tblLook element.
639
* @type {import('@translator').NodeTranslator}
@@ -14,6 +47,14 @@ export const translator = NodeTranslator.from({
1447
.concat([createAttributeHandler('w:val')]),
1548
encode: (params, encodedAttrs) => {
1649
void params;
50+
const decoded = decodeTblLookVal(encodedAttrs.val);
51+
if (decoded) {
52+
Object.entries(decoded).forEach(([key, value]) => {
53+
if (!Object.prototype.hasOwnProperty.call(encodedAttrs, key)) {
54+
encodedAttrs[key] = value;
55+
}
56+
});
57+
}
1758
return Object.keys(encodedAttrs).length > 0 ? encodedAttrs : undefined;
1859
},
1960
decode: function ({ node }, context) {

packages/super-editor/src/core/super-converter/v3/handlers/w/tblLook/tblLook-translator.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ describe('w:tblLook translator', () => {
2929
val: 'someValue',
3030
});
3131
});
32+
33+
it('decodes w:val bitmask into conditional flags', () => {
34+
const result = translator.encode({
35+
nodes: [
36+
{
37+
attributes: {
38+
'w:val': '04A0',
39+
},
40+
},
41+
],
42+
});
43+
44+
expect(result).toEqual({
45+
val: '04A0',
46+
firstRow: true,
47+
lastRow: false,
48+
firstColumn: true,
49+
lastColumn: false,
50+
noHBand: false,
51+
noVBand: true,
52+
});
53+
});
3254
});
3355

3456
describe('decode', () => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './tblStylePr-translator.js';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { NodeTranslator } from '@translator';
2+
import { translator as tblPrTranslator } from '@converter/v3/handlers/w/tblPr';
3+
import { translator as tcPrTranslator } from '@converter/v3/handlers/w/tcPr';
4+
import { createNestedPropertiesTranslator } from '@converter/v3/handlers/utils.js';
5+
6+
/** @type {import('@translator').NodeTranslatorConfig[]} */
7+
const propertyTranslators = [tblPrTranslator, tcPrTranslator];
8+
9+
/**
10+
* The NodeTranslator instance for the w:tblStylePr element.
11+
* @type {import('@translator').NodeTranslator}
12+
*/
13+
export const translator = NodeTranslator.from(
14+
createNestedPropertiesTranslator('w:tblStylePr', 'tableStyleProperties', propertyTranslators),
15+
);
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { NodeTranslator } from '@translator';
3+
import { translator } from './tblStylePr-translator.js';
4+
5+
describe('w:tblStylePr translator', () => {
6+
describe('config', () => {
7+
it('exports a NodeTranslator instance', () => {
8+
expect(translator).toBeDefined();
9+
expect(translator).toBeInstanceOf(NodeTranslator);
10+
expect(translator.xmlName).toBe('w:tblStylePr');
11+
expect(translator.sdNodeOrKeyName).toBe('tableStyleProperties');
12+
});
13+
});
14+
15+
describe('encode', () => {
16+
it('encodes nested <w:tblPr> and <w:tcPr> correctly', () => {
17+
const params = {
18+
nodes: [
19+
{
20+
name: 'w:tblStylePr',
21+
elements: [
22+
{
23+
name: 'w:tblPr',
24+
elements: [
25+
{ name: 'w:tblStyle', attributes: { 'w:val': 'TableGrid' } },
26+
{ name: 'w:tblW', attributes: { 'w:w': '5000', 'w:type': 'pct' } },
27+
{ name: 'w:jc', attributes: { 'w:val': 'center' } },
28+
],
29+
},
30+
{
31+
name: 'w:tcPr',
32+
elements: [
33+
{ name: 'w:tcW', attributes: { 'w:w': '2000', 'w:type': 'dxa' } },
34+
{ name: 'w:gridSpan', attributes: { 'w:val': '2' } },
35+
{ name: 'w:noWrap' },
36+
],
37+
},
38+
],
39+
},
40+
],
41+
};
42+
43+
const result = translator.encode(params);
44+
45+
expect(result).toEqual({
46+
tableProperties: {
47+
tableStyleId: 'TableGrid',
48+
tableWidth: { value: 5000, type: 'pct' },
49+
justification: 'center',
50+
},
51+
tableCellProperties: {
52+
cellWidth: { value: 2000, type: 'dxa' },
53+
gridSpan: 2,
54+
noWrap: true,
55+
},
56+
});
57+
});
58+
59+
it('returns undefined when no nested properties are encoded', () => {
60+
const params = {
61+
nodes: [
62+
{
63+
name: 'w:tblStylePr',
64+
elements: [
65+
{ name: 'w:tblPr', elements: [{ name: 'w:tblW', attributes: {} }] },
66+
{ name: 'w:tcPr', elements: [{ name: 'w:tcW', attributes: {} }] },
67+
],
68+
},
69+
],
70+
};
71+
72+
expect(translator.encode(params)).toBeUndefined();
73+
});
74+
75+
it('encodes when at least one nested property group is present', () => {
76+
const params = {
77+
nodes: [
78+
{
79+
name: 'w:tblStylePr',
80+
elements: [
81+
{
82+
name: 'w:tblPr',
83+
elements: [{ name: 'w:tblStyle', attributes: { 'w:val': 'TableGrid' } }],
84+
},
85+
],
86+
},
87+
],
88+
};
89+
90+
expect(translator.encode(params)).toEqual({
91+
tableProperties: { tableStyleId: 'TableGrid' },
92+
});
93+
});
94+
});
95+
96+
describe('decode', () => {
97+
it('decodes a complex tableStyleProperties object correctly', () => {
98+
const tableStyleProperties = {
99+
tableProperties: {
100+
tableStyleId: 'TableGrid',
101+
tableWidth: { value: 5000, type: 'pct' },
102+
justification: 'center',
103+
},
104+
tableCellProperties: {
105+
cellWidth: { value: 2000, type: 'dxa' },
106+
gridSpan: 2,
107+
noWrap: true,
108+
},
109+
};
110+
111+
const result = translator.decode({ node: { attrs: { tableStyleProperties } } });
112+
113+
expect(result).toEqual({
114+
name: 'w:tblStylePr',
115+
type: 'element',
116+
attributes: {},
117+
elements: expect.arrayContaining([
118+
{
119+
name: 'w:tblPr',
120+
type: 'element',
121+
attributes: {},
122+
elements: expect.arrayContaining([
123+
{ name: 'w:tblStyle', attributes: { 'w:val': 'TableGrid' } },
124+
{ name: 'w:tblW', attributes: { 'w:w': '5000', 'w:type': 'pct' } },
125+
{ name: 'w:jc', attributes: { 'w:val': 'center' } },
126+
]),
127+
},
128+
{
129+
name: 'w:tcPr',
130+
type: 'element',
131+
attributes: {},
132+
elements: expect.arrayContaining([
133+
{ name: 'w:tcW', attributes: { 'w:w': '2000', 'w:type': 'dxa' } },
134+
{ name: 'w:gridSpan', attributes: { 'w:val': '2' } },
135+
{ name: 'w:noWrap', attributes: { 'w:val': '1' } },
136+
]),
137+
},
138+
]),
139+
});
140+
});
141+
142+
it('handles missing tableStyleProperties object', () => {
143+
expect(translator.decode({ node: { attrs: {} } })).toBeUndefined();
144+
});
145+
146+
it('handles empty tableStyleProperties object', () => {
147+
expect(translator.decode({ node: { attrs: { tableStyleProperties: {} } } })).toBeUndefined();
148+
});
149+
});
150+
151+
describe('round-trip', () => {
152+
it('maintains consistency for a complex object', () => {
153+
const tableStyleProperties = {
154+
tableProperties: {
155+
tableStyleId: 'TableGrid',
156+
tableWidth: { value: 5000, type: 'pct' },
157+
justification: 'center',
158+
},
159+
tableCellProperties: {
160+
cellWidth: { value: 2000, type: 'dxa' },
161+
gridSpan: 2,
162+
noWrap: true,
163+
},
164+
};
165+
166+
const decodedResult = translator.decode({ node: { attrs: { tableStyleProperties } } });
167+
const encodedResult = translator.encode({ nodes: [decodedResult] });
168+
169+
expect(encodedResult).toEqual(tableStyleProperties);
170+
});
171+
});
172+
});

0 commit comments

Comments
 (0)