Skip to content

Commit e84f695

Browse files
authored
feat(tables): support lastRow style options with OOXML roundtrip parity (#2467)
1 parent 99bd4e5 commit e84f695

17 files changed

Lines changed: 729 additions & 71 deletions

File tree

apps/cli/src/__tests__/lib/validate-type-spec.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ describe('validateValueAgainstTypeSpec – oneOf const enumeration', () => {
77
const schema: CliTypeSpec = {
88
oneOf: [
99
{ const: 'headerRow' },
10+
{ const: 'lastRow' },
1011
{ const: 'totalRow' },
1112
{ const: 'firstColumn' },
1213
{ const: 'lastColumn' },
@@ -20,16 +21,24 @@ describe('validateValueAgainstTypeSpec – oneOf const enumeration', () => {
2021
expect(() => validateValueAgainstTypeSpec('bandedColumns', schema, 'flag')).not.toThrow();
2122
});
2223

24+
test('accepts lastRow as a valid flag', () => {
25+
expect(() => validateValueAgainstTypeSpec('lastRow', schema, 'flag')).not.toThrow();
26+
});
27+
28+
test('accepts totalRow as a deprecated alias', () => {
29+
expect(() => validateValueAgainstTypeSpec('totalRow', schema, 'flag')).not.toThrow();
30+
});
31+
2332
test('rejects an invalid value and lists all allowed values', () => {
2433
try {
25-
validateValueAgainstTypeSpec('lastRow', schema, 'tables set-style-option:flag');
34+
validateValueAgainstTypeSpec('bogusFlag', schema, 'tables set-style-option:flag');
2635
throw new Error('Expected CliError to be thrown');
2736
} catch (error) {
2837
expect(error).toBeInstanceOf(CliError);
2938
const cliError = error as CliError;
3039
expect(cliError.code).toBe('VALIDATION_ERROR');
3140
expect(cliError.message).toBe(
32-
'tables set-style-option:flag must be one of: headerRow, totalRow, firstColumn, lastColumn, bandedRows, bandedColumns.',
41+
'tables set-style-option:flag must be one of: headerRow, lastRow, totalRow, firstColumn, lastColumn, bandedRows, bandedColumns.',
3342
);
3443
}
3544
});
@@ -41,7 +50,7 @@ describe('validateValueAgainstTypeSpec – oneOf const enumeration', () => {
4150
} catch (error) {
4251
const cliError = error as CliError;
4352
const details = cliError.details as { errors: string[] };
44-
expect(details.errors).toBeArrayOfSize(6);
53+
expect(details.errors).toBeArrayOfSize(7);
4554
}
4655
});
4756
});

apps/docs/document-api/reference/_generated-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,5 +976,5 @@
976976
}
977977
],
978978
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
979-
"sourceHash": "0ac9ab9c8f464a719722f89f32d725cc3c2079d126fa09d487a90eb7170d474d"
979+
"sourceHash": "3b3a367f04f06a39426291c6d41ab669a58f346f6520fde2f67e1c8e84abfad5"
980980
}

apps/docs/document-api/reference/tables/get-properties.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Returns a TablesGetPropertiesOutput with the table layout, style, border, and sh
7373
| `styleOptions.firstColumn` | boolean | no | |
7474
| `styleOptions.headerRow` | boolean | no | |
7575
| `styleOptions.lastColumn` | boolean | no | |
76-
| `styleOptions.totalRow` | boolean | no | |
76+
| `styleOptions.lastRow` | boolean | no | |
7777

7878
### Example response
7979

@@ -184,7 +184,7 @@ Returns a TablesGetPropertiesOutput with the table layout, style, border, and sh
184184
"lastColumn": {
185185
"type": "boolean"
186186
},
187-
"totalRow": {
187+
"lastRow": {
188188
"type": "boolean"
189189
}
190190
},

apps/docs/document-api/reference/tables/set-style-option.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Returns a TableMutationResult receipt; reports NO_OP if the style option already
3131
| Field | Type | Required | Description |
3232
| --- | --- | --- | --- |
3333
| `enabled` | boolean | yes | |
34-
| `flag` | enum | yes | `"headerRow"`, `"totalRow"`, `"firstColumn"`, `"lastColumn"`, `"bandedRows"`, `"bandedColumns"` |
34+
| `flag` | enum | yes | `"headerRow"`, `"lastRow"`, `"totalRow"`, `"firstColumn"`, `"lastColumn"`, `"bandedRows"`, `"bandedColumns"` |
3535
| `target` | TableAddress | yes | TableAddress |
3636
| `target.kind` | `"block"` | yes | Constant: `"block"` |
3737
| `target.nodeId` | string | yes | |
@@ -42,7 +42,7 @@ Returns a TableMutationResult receipt; reports NO_OP if the style option already
4242
| Field | Type | Required | Description |
4343
| --- | --- | --- | --- |
4444
| `enabled` | boolean | yes | |
45-
| `flag` | enum | yes | `"headerRow"`, `"totalRow"`, `"firstColumn"`, `"lastColumn"`, `"bandedRows"`, `"bandedColumns"` |
45+
| `flag` | enum | yes | `"headerRow"`, `"lastRow"`, `"totalRow"`, `"firstColumn"`, `"lastColumn"`, `"bandedRows"`, `"bandedColumns"` |
4646
| `nodeId` | string | yes | |
4747

4848
### Example request
@@ -142,6 +142,7 @@ When present, `result.table` is the follow-up address to reuse after this call.
142142
"flag": {
143143
"enum": [
144144
"headerRow",
145+
"lastRow",
145146
"totalRow",
146147
"firstColumn",
147148
"lastColumn",

apps/docs/document-engine/sdks.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p
580580
| `doc.format.stylisticSets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. |
581581
| `doc.format.contextualAlternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. |
582582
| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. |
583-
| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. |
583+
| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references. |
584584
| `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. |
585585
| `doc.format.paragraph.resetDirectFormatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. |
586586
| `doc.format.paragraph.setAlignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. |
@@ -1025,7 +1025,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p
10251025
| `doc.format.stylistic_sets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. |
10261026
| `doc.format.contextual_alternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. |
10271027
| `doc.styles.apply` | `styles apply` | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run and paragraph channels with set-style patch semantics. |
1028-
| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. |
1028+
| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Apply a paragraph style (w:pStyle) to a paragraph-like block, clearing direct run formatting while preserving character-style references. |
10291029
| `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. |
10301030
| `doc.format.paragraph.reset_direct_formatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. |
10311031
| `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. |

packages/document-api/src/contract/schemas.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5034,7 +5034,9 @@ const operationSchemas: Record<OperationId, OperationSchemaSet> = {
50345034
{
50355035
target: tableAddressSchema,
50365036
nodeId: { type: 'string' },
5037-
flag: { enum: ['headerRow', 'totalRow', 'firstColumn', 'lastColumn', 'bandedRows', 'bandedColumns'] },
5037+
flag: {
5038+
enum: ['headerRow', 'lastRow', 'totalRow', 'firstColumn', 'lastColumn', 'bandedRows', 'bandedColumns'],
5039+
},
50385040
enabled: { type: 'boolean' },
50395041
},
50405042
['flag', 'enabled'],
@@ -5238,7 +5240,7 @@ const operationSchemas: Record<OperationId, OperationSchemaSet> = {
52385240
autoFitMode: { enum: ['fixedWidth', 'fitContents', 'fitWindow'] },
52395241
styleOptions: objectSchema({
52405242
headerRow: { type: 'boolean' },
5241-
totalRow: { type: 'boolean' },
5243+
lastRow: { type: 'boolean' },
52425244
firstColumn: { type: 'boolean' },
52435245
lastColumn: { type: 'boolean' },
52445246
bandedRows: { type: 'boolean' },

packages/document-api/src/types/table-operations.types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,8 @@ export type TablesClearStyleInput = TableLocator;
333333

334334
export type TableStyleOptionFlag =
335335
| 'headerRow'
336-
| 'totalRow'
336+
| 'lastRow'
337+
| 'totalRow' // deprecated alias for 'lastRow' — will be removed in a future release
337338
| 'firstColumn'
338339
| 'lastColumn'
339340
| 'bandedRows'
@@ -506,7 +507,7 @@ export interface TablesGetPropertiesOutput {
506507
autoFitMode?: TableAutoFitMode;
507508
styleOptions?: {
508509
headerRow?: boolean;
509-
totalRow?: boolean;
510+
lastRow?: boolean;
510511
firstColumn?: boolean;
511512
lastColumn?: boolean;
512513
bandedRows?: boolean;

packages/layout-engine/style-engine/src/ooxml/index.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,3 +1148,185 @@ describe('ooxml - DEFAULT_TBL_LOOK fallback when tblLook is absent', () => {
11481148
expect(result.shading).toEqual({ val: 'clear', fill: 'HBAND' });
11491149
});
11501150
});
1151+
1152+
// ──────────────────────────────────────────────────────────────────────────────
1153+
// Corner gating: corners only apply when both row and column toggles are on
1154+
// (Word / Office behavior per MS-OI29500 §2.1.1310)
1155+
// ──────────────────────────────────────────────────────────────────────────────
1156+
1157+
describe('ooxml - corner cell gating matches Word behavior', () => {
1158+
const cornerStyles = {
1159+
...emptyStyles,
1160+
styles: {
1161+
TestCorner: {
1162+
type: 'table',
1163+
tableProperties: {},
1164+
tableStyleProperties: {
1165+
wholeTable: { tableCellProperties: { shading: { fill: 'DEFAULT' } } },
1166+
firstRow: { tableCellProperties: { shading: { fill: 'FIRST_ROW' } } },
1167+
lastRow: { tableCellProperties: { shading: { fill: 'LAST_ROW' } } },
1168+
firstCol: { tableCellProperties: { shading: { fill: 'FIRST_COL' } } },
1169+
lastCol: { tableCellProperties: { shading: { fill: 'LAST_COL' } } },
1170+
nwCell: { tableCellProperties: { shading: { fill: 'NW' } } },
1171+
neCell: { tableCellProperties: { shading: { fill: 'NE' } } },
1172+
swCell: { tableCellProperties: { shading: { fill: 'SW' } } },
1173+
seCell: { tableCellProperties: { shading: { fill: 'SE' } } },
1174+
},
1175+
},
1176+
},
1177+
};
1178+
1179+
it('does NOT apply swCell when lastRow is true but firstColumn is false', () => {
1180+
const tableInfo = {
1181+
tableProperties: {
1182+
tableStyleId: 'TestCorner',
1183+
tblLook: { firstRow: true, lastRow: true, firstColumn: false, lastColumn: false, noHBand: true, noVBand: true },
1184+
},
1185+
rowIndex: 2,
1186+
cellIndex: 0,
1187+
numRows: 3,
1188+
numCells: 3,
1189+
};
1190+
const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles);
1191+
const fills = result.map((r: any) => r.shading?.fill);
1192+
expect(fills).toContain('LAST_ROW');
1193+
expect(fills).not.toContain('SW');
1194+
});
1195+
1196+
it('does NOT apply seCell when lastRow is true but lastColumn is false', () => {
1197+
const tableInfo = {
1198+
tableProperties: {
1199+
tableStyleId: 'TestCorner',
1200+
tblLook: { firstRow: true, lastRow: true, firstColumn: false, lastColumn: false, noHBand: true, noVBand: true },
1201+
},
1202+
rowIndex: 2,
1203+
cellIndex: 2,
1204+
numRows: 3,
1205+
numCells: 3,
1206+
};
1207+
const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles);
1208+
const fills = result.map((r: any) => r.shading?.fill);
1209+
expect(fills).toContain('LAST_ROW');
1210+
expect(fills).not.toContain('SE');
1211+
});
1212+
1213+
it('applies swCell when both lastRow and firstColumn are true', () => {
1214+
const tableInfo = {
1215+
tableProperties: {
1216+
tableStyleId: 'TestCorner',
1217+
tblLook: { firstRow: true, lastRow: true, firstColumn: true, lastColumn: false, noHBand: true, noVBand: true },
1218+
},
1219+
rowIndex: 2,
1220+
cellIndex: 0,
1221+
numRows: 3,
1222+
numCells: 3,
1223+
};
1224+
const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles);
1225+
const fills = result.map((r: any) => r.shading?.fill);
1226+
expect(fills).toContain('LAST_ROW');
1227+
expect(fills).toContain('FIRST_COL');
1228+
expect(fills).toContain('SW');
1229+
});
1230+
1231+
it('applies seCell when both lastRow and lastColumn are true', () => {
1232+
const tableInfo = {
1233+
tableProperties: {
1234+
tableStyleId: 'TestCorner',
1235+
tblLook: { firstRow: true, lastRow: true, firstColumn: false, lastColumn: true, noHBand: true, noVBand: true },
1236+
},
1237+
rowIndex: 2,
1238+
cellIndex: 2,
1239+
numRows: 3,
1240+
numCells: 3,
1241+
};
1242+
const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles);
1243+
const fills = result.map((r: any) => r.shading?.fill);
1244+
expect(fills).toContain('LAST_ROW');
1245+
expect(fills).toContain('LAST_COL');
1246+
expect(fills).toContain('SE');
1247+
});
1248+
1249+
it('does NOT apply nwCell when firstRow is true but firstColumn is false', () => {
1250+
const tableInfo = {
1251+
tableProperties: {
1252+
tableStyleId: 'TestCorner',
1253+
tblLook: {
1254+
firstRow: true,
1255+
lastRow: false,
1256+
firstColumn: false,
1257+
lastColumn: false,
1258+
noHBand: true,
1259+
noVBand: true,
1260+
},
1261+
},
1262+
rowIndex: 0,
1263+
cellIndex: 0,
1264+
numRows: 3,
1265+
numCells: 3,
1266+
};
1267+
const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles);
1268+
const fills = result.map((r: any) => r.shading?.fill);
1269+
expect(fills).toContain('FIRST_ROW');
1270+
expect(fills).not.toContain('NW');
1271+
});
1272+
1273+
it('applies nwCell when both firstRow and firstColumn are true', () => {
1274+
const tableInfo = {
1275+
tableProperties: {
1276+
tableStyleId: 'TestCorner',
1277+
tblLook: { firstRow: true, lastRow: false, firstColumn: true, lastColumn: false, noHBand: true, noVBand: true },
1278+
},
1279+
rowIndex: 0,
1280+
cellIndex: 0,
1281+
numRows: 3,
1282+
numCells: 3,
1283+
};
1284+
const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles);
1285+
const fills = result.map((r: any) => r.shading?.fill);
1286+
expect(fills).toContain('FIRST_ROW');
1287+
expect(fills).toContain('FIRST_COL');
1288+
expect(fills).toContain('NW');
1289+
});
1290+
1291+
it('does NOT apply neCell when firstRow is true but lastColumn is false', () => {
1292+
const tableInfo = {
1293+
tableProperties: {
1294+
tableStyleId: 'TestCorner',
1295+
tblLook: {
1296+
firstRow: true,
1297+
lastRow: false,
1298+
firstColumn: false,
1299+
lastColumn: false,
1300+
noHBand: true,
1301+
noVBand: true,
1302+
},
1303+
},
1304+
rowIndex: 0,
1305+
cellIndex: 2,
1306+
numRows: 3,
1307+
numCells: 3,
1308+
};
1309+
const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles);
1310+
const fills = result.map((r: any) => r.shading?.fill);
1311+
expect(fills).toContain('FIRST_ROW');
1312+
expect(fills).not.toContain('NE');
1313+
});
1314+
1315+
it('applies neCell when both firstRow and lastColumn are true', () => {
1316+
const tableInfo = {
1317+
tableProperties: {
1318+
tableStyleId: 'TestCorner',
1319+
tblLook: { firstRow: true, lastRow: false, firstColumn: false, lastColumn: true, noHBand: true, noVBand: true },
1320+
},
1321+
rowIndex: 0,
1322+
cellIndex: 2,
1323+
numRows: 3,
1324+
numCells: 3,
1325+
};
1326+
const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles);
1327+
const fills = result.map((r: any) => r.shading?.fill);
1328+
expect(fills).toContain('FIRST_ROW');
1329+
expect(fills).toContain('LAST_COL');
1330+
expect(fills).toContain('NE');
1331+
});
1332+
});

0 commit comments

Comments
 (0)