diff --git a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts index 2d7161109c..f6f8993ce6 100644 --- a/apps/cli/src/__tests__/lib/validate-type-spec.test.ts +++ b/apps/cli/src/__tests__/lib/validate-type-spec.test.ts @@ -7,6 +7,7 @@ describe('validateValueAgainstTypeSpec – oneOf const enumeration', () => { const schema: CliTypeSpec = { oneOf: [ { const: 'headerRow' }, + { const: 'lastRow' }, { const: 'totalRow' }, { const: 'firstColumn' }, { const: 'lastColumn' }, @@ -20,16 +21,24 @@ describe('validateValueAgainstTypeSpec – oneOf const enumeration', () => { expect(() => validateValueAgainstTypeSpec('bandedColumns', schema, 'flag')).not.toThrow(); }); + test('accepts lastRow as a valid flag', () => { + expect(() => validateValueAgainstTypeSpec('lastRow', schema, 'flag')).not.toThrow(); + }); + + test('accepts totalRow as a deprecated alias', () => { + expect(() => validateValueAgainstTypeSpec('totalRow', schema, 'flag')).not.toThrow(); + }); + test('rejects an invalid value and lists all allowed values', () => { try { - validateValueAgainstTypeSpec('lastRow', schema, 'tables set-style-option:flag'); + validateValueAgainstTypeSpec('bogusFlag', schema, 'tables set-style-option:flag'); throw new Error('Expected CliError to be thrown'); } catch (error) { expect(error).toBeInstanceOf(CliError); const cliError = error as CliError; expect(cliError.code).toBe('VALIDATION_ERROR'); expect(cliError.message).toBe( - 'tables set-style-option:flag must be one of: headerRow, totalRow, firstColumn, lastColumn, bandedRows, bandedColumns.', + 'tables set-style-option:flag must be one of: headerRow, lastRow, totalRow, firstColumn, lastColumn, bandedRows, bandedColumns.', ); } }); @@ -41,7 +50,7 @@ describe('validateValueAgainstTypeSpec – oneOf const enumeration', () => { } catch (error) { const cliError = error as CliError; const details = cliError.details as { errors: string[] }; - expect(details.errors).toBeArrayOfSize(6); + expect(details.errors).toBeArrayOfSize(7); } }); }); diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 709b1c4f18..409b798276 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -976,5 +976,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "0ac9ab9c8f464a719722f89f32d725cc3c2079d126fa09d487a90eb7170d474d" + "sourceHash": "3b3a367f04f06a39426291c6d41ab669a58f346f6520fde2f67e1c8e84abfad5" } diff --git a/apps/docs/document-api/reference/tables/get-properties.mdx b/apps/docs/document-api/reference/tables/get-properties.mdx index f0cdf129b5..84d95fd3af 100644 --- a/apps/docs/document-api/reference/tables/get-properties.mdx +++ b/apps/docs/document-api/reference/tables/get-properties.mdx @@ -73,7 +73,7 @@ Returns a TablesGetPropertiesOutput with the table layout, style, border, and sh | `styleOptions.firstColumn` | boolean | no | | | `styleOptions.headerRow` | boolean | no | | | `styleOptions.lastColumn` | boolean | no | | -| `styleOptions.totalRow` | boolean | no | | +| `styleOptions.lastRow` | boolean | no | | ### Example response @@ -184,7 +184,7 @@ Returns a TablesGetPropertiesOutput with the table layout, style, border, and sh "lastColumn": { "type": "boolean" }, - "totalRow": { + "lastRow": { "type": "boolean" } }, diff --git a/apps/docs/document-api/reference/tables/set-style-option.mdx b/apps/docs/document-api/reference/tables/set-style-option.mdx index 0ee9880062..ab9eec6685 100644 --- a/apps/docs/document-api/reference/tables/set-style-option.mdx +++ b/apps/docs/document-api/reference/tables/set-style-option.mdx @@ -31,7 +31,7 @@ Returns a TableMutationResult receipt; reports NO_OP if the style option already | Field | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | boolean | yes | | -| `flag` | enum | yes | `"headerRow"`, `"totalRow"`, `"firstColumn"`, `"lastColumn"`, `"bandedRows"`, `"bandedColumns"` | +| `flag` | enum | yes | `"headerRow"`, `"lastRow"`, `"totalRow"`, `"firstColumn"`, `"lastColumn"`, `"bandedRows"`, `"bandedColumns"` | | `target` | TableAddress | yes | TableAddress | | `target.kind` | `"block"` | yes | Constant: `"block"` | | `target.nodeId` | string | yes | | @@ -42,7 +42,7 @@ Returns a TableMutationResult receipt; reports NO_OP if the style option already | Field | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | boolean | yes | | -| `flag` | enum | yes | `"headerRow"`, `"totalRow"`, `"firstColumn"`, `"lastColumn"`, `"bandedRows"`, `"bandedColumns"` | +| `flag` | enum | yes | `"headerRow"`, `"lastRow"`, `"totalRow"`, `"firstColumn"`, `"lastColumn"`, `"bandedRows"`, `"bandedColumns"` | | `nodeId` | string | yes | | ### Example request @@ -142,6 +142,7 @@ When present, `result.table` is the follow-up address to reuse after this call. "flag": { "enum": [ "headerRow", + "lastRow", "totalRow", "firstColumn", "lastColumn", diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index e59d84eeee..2b0047b7f1 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -580,7 +580,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.format.stylisticSets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. | | `doc.format.contextualAlternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. | | `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. | -| `doc.styles.paragraph.setStyle` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| `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. | | `doc.styles.paragraph.clearStyle` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | | `doc.format.paragraph.resetDirectFormatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | | `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 | `doc.format.stylistic_sets` | `format stylistic-sets` | Set or clear the `stylisticSets` inline run property on the target text range. | | `doc.format.contextual_alternates` | `format contextual-alternates` | Set or clear the `contextualAlternates` inline run property on the target text range. | | `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. | -| `doc.styles.paragraph.set_style` | `styles paragraph set-style` | Set the paragraph style reference (w:pStyle) on a paragraph-like block. | +| `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. | | `doc.styles.paragraph.clear_style` | `styles paragraph clear-style` | Remove the paragraph style reference from a paragraph-like block. | | `doc.format.paragraph.reset_direct_formatting` | `format paragraph reset-direct-formatting` | Strip all direct paragraph formatting while preserving style reference, numbering, and section metadata. | | `doc.format.paragraph.set_alignment` | `format paragraph set-alignment` | Set paragraph alignment (justification) on a paragraph-like block. | diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 20ab4bcab0..3d2d35eed8 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -5034,7 +5034,9 @@ const operationSchemas: Record = { { target: tableAddressSchema, nodeId: { type: 'string' }, - flag: { enum: ['headerRow', 'totalRow', 'firstColumn', 'lastColumn', 'bandedRows', 'bandedColumns'] }, + flag: { + enum: ['headerRow', 'lastRow', 'totalRow', 'firstColumn', 'lastColumn', 'bandedRows', 'bandedColumns'], + }, enabled: { type: 'boolean' }, }, ['flag', 'enabled'], @@ -5238,7 +5240,7 @@ const operationSchemas: Record = { autoFitMode: { enum: ['fixedWidth', 'fitContents', 'fitWindow'] }, styleOptions: objectSchema({ headerRow: { type: 'boolean' }, - totalRow: { type: 'boolean' }, + lastRow: { type: 'boolean' }, firstColumn: { type: 'boolean' }, lastColumn: { type: 'boolean' }, bandedRows: { type: 'boolean' }, diff --git a/packages/document-api/src/types/table-operations.types.ts b/packages/document-api/src/types/table-operations.types.ts index 299c250c5d..37cba58261 100644 --- a/packages/document-api/src/types/table-operations.types.ts +++ b/packages/document-api/src/types/table-operations.types.ts @@ -333,7 +333,8 @@ export type TablesClearStyleInput = TableLocator; export type TableStyleOptionFlag = | 'headerRow' - | 'totalRow' + | 'lastRow' + | 'totalRow' // deprecated alias for 'lastRow' — will be removed in a future release | 'firstColumn' | 'lastColumn' | 'bandedRows' @@ -506,7 +507,7 @@ export interface TablesGetPropertiesOutput { autoFitMode?: TableAutoFitMode; styleOptions?: { headerRow?: boolean; - totalRow?: boolean; + lastRow?: boolean; firstColumn?: boolean; lastColumn?: boolean; bandedRows?: boolean; diff --git a/packages/layout-engine/style-engine/src/ooxml/index.test.ts b/packages/layout-engine/style-engine/src/ooxml/index.test.ts index a1c0c16281..d6d1804558 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.test.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.test.ts @@ -1148,3 +1148,185 @@ describe('ooxml - DEFAULT_TBL_LOOK fallback when tblLook is absent', () => { expect(result.shading).toEqual({ val: 'clear', fill: 'HBAND' }); }); }); + +// ────────────────────────────────────────────────────────────────────────────── +// Corner gating: corners only apply when both row and column toggles are on +// (Word / Office behavior per MS-OI29500 §2.1.1310) +// ────────────────────────────────────────────────────────────────────────────── + +describe('ooxml - corner cell gating matches Word behavior', () => { + const cornerStyles = { + ...emptyStyles, + styles: { + TestCorner: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + wholeTable: { tableCellProperties: { shading: { fill: 'DEFAULT' } } }, + firstRow: { tableCellProperties: { shading: { fill: 'FIRST_ROW' } } }, + lastRow: { tableCellProperties: { shading: { fill: 'LAST_ROW' } } }, + firstCol: { tableCellProperties: { shading: { fill: 'FIRST_COL' } } }, + lastCol: { tableCellProperties: { shading: { fill: 'LAST_COL' } } }, + nwCell: { tableCellProperties: { shading: { fill: 'NW' } } }, + neCell: { tableCellProperties: { shading: { fill: 'NE' } } }, + swCell: { tableCellProperties: { shading: { fill: 'SW' } } }, + seCell: { tableCellProperties: { shading: { fill: 'SE' } } }, + }, + }, + }, + }; + + it('does NOT apply swCell when lastRow is true but firstColumn is false', () => { + const tableInfo = { + tableProperties: { + tableStyleId: 'TestCorner', + tblLook: { firstRow: true, lastRow: true, firstColumn: false, lastColumn: false, noHBand: true, noVBand: true }, + }, + rowIndex: 2, + cellIndex: 0, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles); + const fills = result.map((r: any) => r.shading?.fill); + expect(fills).toContain('LAST_ROW'); + expect(fills).not.toContain('SW'); + }); + + it('does NOT apply seCell when lastRow is true but lastColumn is false', () => { + const tableInfo = { + tableProperties: { + tableStyleId: 'TestCorner', + tblLook: { firstRow: true, lastRow: true, firstColumn: false, lastColumn: false, noHBand: true, noVBand: true }, + }, + rowIndex: 2, + cellIndex: 2, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles); + const fills = result.map((r: any) => r.shading?.fill); + expect(fills).toContain('LAST_ROW'); + expect(fills).not.toContain('SE'); + }); + + it('applies swCell when both lastRow and firstColumn are true', () => { + const tableInfo = { + tableProperties: { + tableStyleId: 'TestCorner', + tblLook: { firstRow: true, lastRow: true, firstColumn: true, lastColumn: false, noHBand: true, noVBand: true }, + }, + rowIndex: 2, + cellIndex: 0, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles); + const fills = result.map((r: any) => r.shading?.fill); + expect(fills).toContain('LAST_ROW'); + expect(fills).toContain('FIRST_COL'); + expect(fills).toContain('SW'); + }); + + it('applies seCell when both lastRow and lastColumn are true', () => { + const tableInfo = { + tableProperties: { + tableStyleId: 'TestCorner', + tblLook: { firstRow: true, lastRow: true, firstColumn: false, lastColumn: true, noHBand: true, noVBand: true }, + }, + rowIndex: 2, + cellIndex: 2, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles); + const fills = result.map((r: any) => r.shading?.fill); + expect(fills).toContain('LAST_ROW'); + expect(fills).toContain('LAST_COL'); + expect(fills).toContain('SE'); + }); + + it('does NOT apply nwCell when firstRow is true but firstColumn is false', () => { + const tableInfo = { + tableProperties: { + tableStyleId: 'TestCorner', + tblLook: { + firstRow: true, + lastRow: false, + firstColumn: false, + lastColumn: false, + noHBand: true, + noVBand: true, + }, + }, + rowIndex: 0, + cellIndex: 0, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles); + const fills = result.map((r: any) => r.shading?.fill); + expect(fills).toContain('FIRST_ROW'); + expect(fills).not.toContain('NW'); + }); + + it('applies nwCell when both firstRow and firstColumn are true', () => { + const tableInfo = { + tableProperties: { + tableStyleId: 'TestCorner', + tblLook: { firstRow: true, lastRow: false, firstColumn: true, lastColumn: false, noHBand: true, noVBand: true }, + }, + rowIndex: 0, + cellIndex: 0, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles); + const fills = result.map((r: any) => r.shading?.fill); + expect(fills).toContain('FIRST_ROW'); + expect(fills).toContain('FIRST_COL'); + expect(fills).toContain('NW'); + }); + + it('does NOT apply neCell when firstRow is true but lastColumn is false', () => { + const tableInfo = { + tableProperties: { + tableStyleId: 'TestCorner', + tblLook: { + firstRow: true, + lastRow: false, + firstColumn: false, + lastColumn: false, + noHBand: true, + noVBand: true, + }, + }, + rowIndex: 0, + cellIndex: 2, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles); + const fills = result.map((r: any) => r.shading?.fill); + expect(fills).toContain('FIRST_ROW'); + expect(fills).not.toContain('NE'); + }); + + it('applies neCell when both firstRow and lastColumn are true', () => { + const tableInfo = { + tableProperties: { + tableStyleId: 'TestCorner', + tblLook: { firstRow: true, lastRow: false, firstColumn: false, lastColumn: true, noHBand: true, noVBand: true }, + }, + rowIndex: 0, + cellIndex: 2, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, cornerStyles); + const fills = result.map((r: any) => r.shading?.fill); + expect(fills).toContain('FIRST_ROW'); + expect(fills).toContain('LAST_COL'); + expect(fills).toContain('NE'); + }); +}); diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index f7a4e85ff8..2e68faeff4 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -565,7 +565,7 @@ const CNF_STYLE_MAP: ReadonlyArray<[keyof ParagraphConditionalFormatting, TableS ['lastRowLastColumn', 'seCell'], ]; -// ECMA-376 §17.7.6 precedence order (low → high). +// Word / Office precedence order (low → high), per MS-OI29500 §2.1.1310. // combineProperties treats later entries as higher priority, so this array // must list types from lowest to highest override strength. const TABLE_STYLE_PRECEDENCE: TableStyleType[] = [ @@ -615,38 +615,23 @@ function determineCellStyleTypes( applicable.add(colGroup % 2 === 0 ? 'band1Vert' : 'band2Vert'); } - if (tblLook?.firstRow && rowIndex === 0) { - applicable.add('firstRow'); - } - if (tblLook?.firstColumn && cellIndex === 0) { - applicable.add('firstCol'); - } - if (tblLook?.lastRow && numRows != null && numRows > 0 && rowIndex === numRows - 1) { - applicable.add('lastRow'); - } - if (tblLook?.lastColumn && numCells != null && numCells > 0 && cellIndex === numCells - 1) { - applicable.add('lastCol'); - } + // Row/column edge flags — reused for both row/col styles and corner gating. + const isFirstRow = !!tblLook?.firstRow && rowIndex === 0; + const isLastRow = !!tblLook?.lastRow && numRows != null && numRows > 0 && rowIndex === numRows - 1; + const isFirstCol = !!tblLook?.firstColumn && cellIndex === 0; + const isLastCol = !!tblLook?.lastColumn && numCells != null && numCells > 0 && cellIndex === numCells - 1; - if (rowIndex === 0 && cellIndex === 0) { - applicable.add('nwCell'); - } - if (rowIndex === 0 && numCells != null && numCells > 0 && cellIndex === numCells - 1) { - applicable.add('neCell'); - } - if (numRows != null && numRows > 0 && rowIndex === numRows - 1 && cellIndex === 0) { - applicable.add('swCell'); - } - if ( - numRows != null && - numRows > 0 && - numCells != null && - numCells > 0 && - rowIndex === numRows - 1 && - cellIndex === numCells - 1 - ) { - applicable.add('seCell'); - } + if (isFirstRow) applicable.add('firstRow'); + if (isFirstCol) applicable.add('firstCol'); + if (isLastRow) applicable.add('lastRow'); + if (isLastCol) applicable.add('lastCol'); + + // Corner cells apply only when the corresponding row AND column toggles + // are both enabled — matching Word / Office behavior (MS-OI29500 §2.1.1310). + if (isFirstRow && isFirstCol) applicable.add('nwCell'); + if (isFirstRow && isLastCol) applicable.add('neCell'); + if (isLastRow && isFirstCol) applicable.add('swCell'); + if (isLastRow && isLastCol) applicable.add('seCell'); // Union in cnfStyle-derived types that index-based logic didn't already add. // cnfStyle only adds types, never removes them. diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblLook/tblLook-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblLook/tblLook-translator.test.js index a5a5221bdd..2fe1672c8e 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tblLook/tblLook-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tblLook/tblLook-translator.test.js @@ -75,6 +75,65 @@ describe('w:tblLook translator', () => { }); }); + describe('lastRow / lastColumn roundtrip', () => { + it('roundtrips explicit lastRow=true', () => { + const encoded = translator.encode({ + nodes: [{ attributes: { 'w:lastRow': '1' } }], + }); + expect(encoded.lastRow).toBe(true); + + const { attributes: decoded } = translator.decode({ + node: { attrs: { tblLook: { lastRow: true } } }, + }); + expect(decoded['w:lastRow']).toBe('1'); + }); + + it('roundtrips explicit lastRow=false', () => { + const encoded = translator.encode({ + nodes: [{ attributes: { 'w:lastRow': '0' } }], + }); + expect(encoded.lastRow).toBe(false); + + const { attributes: decoded } = translator.decode({ + node: { attrs: { tblLook: { lastRow: false } } }, + }); + expect(decoded['w:lastRow']).toBe('0'); + }); + + it('roundtrips explicit lastColumn=true', () => { + const encoded = translator.encode({ + nodes: [{ attributes: { 'w:lastColumn': '1' } }], + }); + expect(encoded.lastColumn).toBe(true); + + const { attributes: decoded } = translator.decode({ + node: { attrs: { tblLook: { lastColumn: true } } }, + }); + expect(decoded['w:lastColumn']).toBe('1'); + }); + + it('roundtrips explicit lastColumn=false', () => { + const encoded = translator.encode({ + nodes: [{ attributes: { 'w:lastColumn': '0' } }], + }); + expect(encoded.lastColumn).toBe(false); + + const { attributes: decoded } = translator.decode({ + node: { attrs: { tblLook: { lastColumn: false } } }, + }); + expect(decoded['w:lastColumn']).toBe('0'); + }); + + it('decodes lastRow and lastColumn from w:val fallback when attrs absent', () => { + // 0x05E0 = firstRow(0x20) + lastRow(0x40) + firstColumn(0x80) + lastColumn(0x100) + noVBand(0x400) + const encoded = translator.encode({ + nodes: [{ attributes: { 'w:val': '05E0' } }], + }); + expect(encoded.lastRow).toBe(true); + expect(encoded.lastColumn).toBe(true); + }); + }); + it('has correct metadata', () => { expect(translator.xmlName).toBe('w:tblLook'); expect(translator.sdNodeOrKeyName).toBe('tblLook'); diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index af12e583a5..5f1dc4e3db 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -10519,7 +10519,7 @@ describe('document-api adapter conformance', () => { autoFitMode: 'fixedWidth', styleOptions: { headerRow: true, - totalRow: false, + lastRow: false, bandedRows: true, bandedColumns: false, }, diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/table-parity.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/table-parity.test.ts index d44eddcacb..09a6bf56a5 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/table-parity.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/table-parity.test.ts @@ -360,6 +360,95 @@ describe('table setter/getter parity', () => { const tp = lastWrittenAttrs(getSetNodeMarkupCalls()).tableProperties as any; expect(tp.tblLook.noHBand).toBe(false); }); + + it('writes lastRow flag via lastRow input', () => { + const { editor, getSetNodeMarkupCalls } = makeTableEditorWithProps(); + tablesSetStyleOptionAdapter(editor, { nodeId: 'table-1', flag: 'lastRow', enabled: true }); + + const tp = lastWrittenAttrs(getSetNodeMarkupCalls()).tableProperties as any; + expect(tp.tblLook.lastRow).toBe(true); + }); + + it('writes lastColumn flag via lastColumn input', () => { + const { editor, getSetNodeMarkupCalls } = makeTableEditorWithProps(); + tablesSetStyleOptionAdapter(editor, { nodeId: 'table-1', flag: 'lastColumn', enabled: true }); + + const tp = lastWrittenAttrs(getSetNodeMarkupCalls()).tableProperties as any; + expect(tp.tblLook.lastColumn).toBe(true); + }); + + it('normalizes deprecated totalRow alias to lastRow', () => { + const { editor, getSetNodeMarkupCalls } = makeTableEditorWithProps(); + tablesSetStyleOptionAdapter(editor, { nodeId: 'table-1', flag: 'totalRow', enabled: true }); + + const tp = lastWrittenAttrs(getSetNodeMarkupCalls()).tableProperties as any; + expect(tp.tblLook.lastRow).toBe(true); + }); + + it('disables lastRow by writing false', () => { + const { editor, getSetNodeMarkupCalls } = makeTableEditorWithProps({ + tblLook: { firstRow: true, lastRow: true, firstColumn: true, lastColumn: false, noHBand: false, noVBand: true }, + }); + tablesSetStyleOptionAdapter(editor, { nodeId: 'table-1', flag: 'lastRow', enabled: false }); + + const tp = lastWrittenAttrs(getSetNodeMarkupCalls()).tableProperties as any; + expect(tp.tblLook.lastRow).toBe(false); + }); + + it('returns NO_OP when tblLook already has the requested value', () => { + const { editor } = makeTableEditorWithProps({ + tblLook: { firstRow: true, lastRow: true, firstColumn: true, lastColumn: false, noHBand: false, noVBand: true }, + }); + const result = tablesSetStyleOptionAdapter(editor, { nodeId: 'table-1', flag: 'lastRow', enabled: true }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failure.code).toBe('NO_OP'); + } + }); + + it('does not return NO_OP when tblLook is absent (materializes baseline)', () => { + const { editor, getSetNodeMarkupCalls } = makeTableEditorWithProps(); + const result = tablesSetStyleOptionAdapter(editor, { nodeId: 'table-1', flag: 'lastRow', enabled: true }); + + expect(result.success).toBe(true); + const tp = lastWrittenAttrs(getSetNodeMarkupCalls()).tableProperties as any; + expect(tp.tblLook.lastRow).toBe(true); + }); + + it('seeds full Word-default baseline on first materialization', () => { + const { editor, getSetNodeMarkupCalls } = makeTableEditorWithProps(); + tablesSetStyleOptionAdapter(editor, { nodeId: 'table-1', flag: 'lastRow', enabled: true }); + + const tp = lastWrittenAttrs(getSetNodeMarkupCalls()).tableProperties as any; + const look = tp.tblLook; + // Word defaults (0x04A0) plus the requested mutation + expect(look.firstRow).toBe(true); + expect(look.lastRow).toBe(true); // mutated + expect(look.firstColumn).toBe(true); + expect(look.lastColumn).toBe(false); + expect(look.noHBand).toBe(false); + expect(look.noVBand).toBe(true); + }); + + it('deletes stale w:val on mutation', () => { + const { editor, getSetNodeMarkupCalls } = makeTableEditorWithProps({ + tblLook: { + val: '04A0', + firstRow: true, + lastRow: false, + firstColumn: true, + lastColumn: false, + noHBand: false, + noVBand: true, + }, + }); + tablesSetStyleOptionAdapter(editor, { nodeId: 'table-1', flag: 'lastRow', enabled: true }); + + const tp = lastWrittenAttrs(getSetNodeMarkupCalls()).tableProperties as any; + expect(tp.tblLook.lastRow).toBe(true); + expect(tp.tblLook.val).toBeUndefined(); + }); }); describe('setCellSpacing → canonical key', () => { @@ -493,11 +582,20 @@ describe('getProperties reads from tableProperties', () => { const result = tablesGetPropertiesAdapter(editor, { nodeId: 'table-1' }); expect(result.styleOptions).toEqual({ headerRow: true, - totalRow: false, + lastRow: false, firstColumn: false, lastColumn: false, bandedRows: true, bandedColumns: false, }); }); + + it('does not include totalRow in styleOptions output', () => { + const { editor } = makeTableEditorWithProps({ + tblLook: { firstRow: true, lastRow: true, noHBand: false, noVBand: true }, + }); + const result = tablesGetPropertiesAdapter(editor, { nodeId: 'table-1' }); + expect(result.styleOptions).toHaveProperty('lastRow', true); + expect(result.styleOptions).not.toHaveProperty('totalRow'); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/tables-adapter.ts b/packages/super-editor/src/document-api-adapters/tables-adapter.ts index 3c00d91699..eb183a975a 100644 --- a/packages/super-editor/src/document-api-adapters/tables-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/tables-adapter.ts @@ -36,6 +36,7 @@ import type { TablesSetStyleInput, TablesClearStyleInput, TablesSetStyleOptionInput, + TableStyleOptionFlag, TablesSetBorderInput, TablesClearBorderInput, TablesApplyBorderPresetInput, @@ -2797,11 +2798,59 @@ export function tablesClearStyleAdapter( } } +/** + * Word-effective defaults for `tblLook` when the element is absent (0x04A0). + * Defined locally to avoid a cross-layer dependency on the style-engine. + * + * @see ECMA-376 §17.4.56, Microsoft open specs for Word's tblLook defaults. + */ +const WORD_DEFAULT_TBL_LOOK: Readonly> = { + firstRow: true, + lastRow: false, + firstColumn: true, + lastColumn: false, + noHBand: false, + noVBand: true, +}; + +/** Maps every public `TableStyleOptionFlag` to its OOXML `tblLook` key. */ +type TblLookKey = 'firstRow' | 'lastRow' | 'firstColumn' | 'lastColumn' | 'noHBand' | 'noVBand'; + +const FLAG_TO_OOXML_KEY: Record, TblLookKey> = { + headerRow: 'firstRow', + lastRow: 'lastRow', + firstColumn: 'firstColumn', + lastColumn: 'lastColumn', + bandedRows: 'noHBand', + bandedColumns: 'noVBand', +}; + +/** Flags whose OOXML semantics are inverted (enabled API → `false` on disk). */ +const INVERTED_FLAGS: ReadonlySet = new Set([ + 'bandedRows', + 'bandedColumns', +]); + +/** + * Resolves a public API flag to its OOXML tblLook key, + * normalizing the deprecated `totalRow` alias to `lastRow`. + */ +function resolveStyleOptionFlag(flag: TableStyleOptionFlag): TblLookKey { + const normalized: Exclude = flag === 'totalRow' ? 'lastRow' : flag; + return FLAG_TO_OOXML_KEY[normalized]; +} + /** * tables.setStyleOption — toggle a table style option flag. * * Maps API flags to OOXML `w:tblLook` attributes, inverting `bandedRows` * and `bandedColumns` to the `noHBand`/`noVBand` semantics. + * + * Behavioral notes: + * - Returns NO_OP when the explicit tblLook already holds the requested value. + * - On first write to a table with no explicit `tblLook`, seeds a full baseline + * from Word's effective defaults (0x04A0) before applying the mutation. + * - Deletes stale `w:val` bitmask on any mutation (explicit attrs are canonical). */ export function tablesSetStyleOptionAdapter( editor: Editor, @@ -2817,31 +2866,29 @@ export function tablesSetStyleOptionAdapter( } try { - const tr = editor.state.tr; + const xmlKey = resolveStyleOptionFlag(input.flag); + const ooxmlValue = INVERTED_FLAGS.has(input.flag) ? !input.enabled : input.enabled; + const currentAttrs = candidate.node.attrs as Record; const currentTableProps = (currentAttrs.tableProperties ?? {}) as Record; - const currentLook = { - ...((currentTableProps.tblLook ?? {}) as Record), - }; + const existingLook = currentTableProps.tblLook as Record | undefined; - // Map API flag names to OOXML tblLook keys. - const flagMap: Record = { - headerRow: 'firstRow', - totalRow: 'lastRow', - firstColumn: 'firstColumn', - lastColumn: 'lastColumn', - bandedRows: 'noHBand', - bandedColumns: 'noVBand', - }; - - const xmlKey = flagMap[input.flag]; - if (xmlKey) { - // bandedRows/bandedColumns are inverted: enabled → noHBand = false. - const value = input.flag === 'bandedRows' || input.flag === 'bandedColumns' ? !input.enabled : input.enabled; - currentLook[xmlKey] = value; + // NO_OP: if tblLook already has an explicit value matching the request, skip. + if (existingLook != null && existingLook[xmlKey] === ooxmlValue) { + return toTableFailure('NO_OP', `Style option '${input.flag}' already has the requested value.`); } - const updatedTableProps = { ...currentTableProps, tblLook: currentLook }; + // Seed from Word defaults on first materialization, then apply the mutation. + const updatedLook: Record = + existingLook != null ? { ...existingLook } : { ...WORD_DEFAULT_TBL_LOOK }; + + updatedLook[xmlKey] = ooxmlValue; + + // Delete stale w:val bitmask — explicit attrs are the canonical representation. + delete updatedLook.val; + + const updatedTableProps = { ...currentTableProps, tblLook: updatedLook }; + const tr = editor.state.tr; tr.setNodeMarkup(candidate.pos, null, { ...currentAttrs, tableProperties: updatedTableProps, @@ -3686,7 +3733,7 @@ export function tablesGetPropertiesAdapter(editor: Editor, input: TablesGetPrope if (look) { result.styleOptions = { headerRow: look.firstRow === true, - totalRow: look.lastRow === true, + lastRow: look.lastRow === true, firstColumn: look.firstColumn === true, lastColumn: look.lastColumn === true, bandedRows: look.noHBand !== true, diff --git a/tests/doc-api-stories/tests/info/live-counts.ts b/tests/doc-api-stories/tests/info/live-counts.ts index 6079a8722a..26700c0ecb 100644 --- a/tests/doc-api-stories/tests/info/live-counts.ts +++ b/tests/doc-api-stories/tests/info/live-counts.ts @@ -8,8 +8,9 @@ import { PresentationEditor, getStarterExtensions, } from '../../../../packages/superdoc/dist/super-editor.es.js'; +import { corpusDoc } from '../harness'; -const FIXTURE_PATH = path.resolve(process.cwd(), 'test-corpus/pagination/longer-header.docx'); +const FIXTURE_PATH = corpusDoc('pagination/longer-header.docx'); const APPEND_MARKER = 'SD2203-DOC-INFO-STORY'; const APPENDED_PARAGRAPH = `${APPEND_MARKER} ${Array.from( { length: 320 }, diff --git a/tests/doc-api-stories/tests/tables/all-commands.ts b/tests/doc-api-stories/tests/tables/all-commands.ts index 55975b4a88..ba6769cc4b 100644 --- a/tests/doc-api-stories/tests/tables/all-commands.ts +++ b/tests/doc-api-stories/tests/tables/all-commands.ts @@ -295,7 +295,8 @@ describe('document-api story: all table commands', () => { }), ); assertMutationSuccess('tables.move', moveResult); - clearContentsTableBySession.set(sessionId, firstTableNodeId); + const movedTableNodeId = moveResult?.table?.nodeId ?? firstTableNodeId; + clearContentsTableBySession.set(sessionId, movedTableNodeId); }, run: async (sessionId) => { const firstTableNodeId = clearContentsTableBySession.get(sessionId); diff --git a/tests/doc-api-stories/tests/tables/fixtures/table-style-options-roundtrip.docx b/tests/doc-api-stories/tests/tables/fixtures/table-style-options-roundtrip.docx new file mode 100644 index 0000000000..9b749a08bc Binary files /dev/null and b/tests/doc-api-stories/tests/tables/fixtures/table-style-options-roundtrip.docx differ diff --git a/tests/doc-api-stories/tests/tables/last-row-last-column-roundtrip.ts b/tests/doc-api-stories/tests/tables/last-row-last-column-roundtrip.ts new file mode 100644 index 0000000000..8dda3a2643 --- /dev/null +++ b/tests/doc-api-stories/tests/tables/last-row-last-column-roundtrip.ts @@ -0,0 +1,272 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +const FIXTURE_DOC = path.resolve(import.meta.dirname, 'fixtures', 'table-style-options-roundtrip.docx'); + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +// --------------------------------------------------------------------------- +// tblLook XML extraction helpers +// --------------------------------------------------------------------------- + +function extractFirstTableXml(documentXml: string): string { + const match = documentXml.match(//); + if (!match) throw new Error('No table markup found in word/document.xml.'); + return match[0]; +} + +function extractTblLookAttr(tableXml: string, attrName: string): string | null { + const tblLookMatch = tableXml.match(/]*)\/?\s*>/); + if (!tblLookMatch) return null; + const attrMatch = tblLookMatch[1].match(new RegExp(`\\bw:${attrName}="([^"]+)"`)); + return attrMatch?.[1] ?? null; +} + +function hasTblLookVal(tableXml: string): boolean { + return extractTblLookAttr(tableXml, 'val') != null; +} + +function isTruthy(value: string | null): boolean { + if (value == null) return false; + return value === '1' || value.toLowerCase() === 'true' || value.toLowerCase() === 'on'; +} + +function isFalsy(value: string | null): boolean { + if (value == null) return false; + return value === '0' || value.toLowerCase() === 'false' || value.toLowerCase() === 'off'; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('document-api story: lastRow / lastColumn style option roundtrip', () => { + const { client, copyDoc, outPath } = useStoryHarness('tables/last-row-last-column-roundtrip', { + preserveResults: true, + }); + + it('enables lastRow + lastColumn, saves, reopens, and verifies OOXML + API state', async () => { + const sourceDoc = await copyDoc(FIXTURE_DOC, 'source.docx'); + const enableSessionId = sid('enable'); + const reopenEnableSessionId = sid('reopen-enable'); + const reopenDisableSessionId = sid('reopen-disable'); + + // ── Enable path ────────────────────────────────────────────────────── + + await client.doc.open({ sessionId: enableSessionId, doc: sourceDoc }); + + const firstTable = unwrap( + await client.doc.query.match({ + sessionId: enableSessionId, + select: { type: 'node', nodeType: 'table' }, + require: 'first', + }), + ); + const tableNodeId = firstTable?.items?.[0]?.address?.nodeId; + expect(typeof tableNodeId).toBe('string'); + + // Set lastRow and lastColumn to true + const setLastRow = unwrap( + await client.doc.tables.setStyleOption({ + sessionId: enableSessionId, + nodeId: tableNodeId, + flag: 'lastRow', + enabled: true, + }), + ); + expect(setLastRow?.success).toBe(true); + + const setLastCol = unwrap( + await client.doc.tables.setStyleOption({ + sessionId: enableSessionId, + nodeId: tableNodeId, + flag: 'lastColumn', + enabled: true, + }), + ); + expect(setLastCol?.success).toBe(true); + + // Save + const enabledDocPath = outPath('enabled.docx'); + await client.doc.save({ sessionId: enableSessionId, out: enabledDocPath, force: true }); + + // Inspect OOXML + const enabledDocXml = await readDocxPart(enabledDocPath, 'word/document.xml'); + const enabledTableXml = extractFirstTableXml(enabledDocXml); + + expect(isTruthy(extractTblLookAttr(enabledTableXml, 'lastRow'))).toBe(true); + expect(isTruthy(extractTblLookAttr(enabledTableXml, 'lastColumn'))).toBe(true); + // Stale w:val must be deleted after mutation + expect(hasTblLookVal(enabledTableXml)).toBe(false); + + // Reopen and verify via getProperties + await client.doc.open({ sessionId: reopenEnableSessionId, doc: enabledDocPath }); + + const reopenedTable = unwrap( + await client.doc.query.match({ + sessionId: reopenEnableSessionId, + select: { type: 'node', nodeType: 'table' }, + require: 'first', + }), + ); + const reopenedNodeId = reopenedTable?.items?.[0]?.address?.nodeId; + + const enabledProps = unwrap( + await client.doc.tables.getProperties({ + sessionId: reopenEnableSessionId, + nodeId: reopenedNodeId, + }), + ); + expect(enabledProps?.styleOptions?.lastRow).toBe(true); + expect(enabledProps?.styleOptions?.lastColumn).toBe(true); + + // ── Disable path ───────────────────────────────────────────────────── + + // Disable lastRow and lastColumn + const disableLastRow = unwrap( + await client.doc.tables.setStyleOption({ + sessionId: reopenEnableSessionId, + nodeId: reopenedNodeId, + flag: 'lastRow', + enabled: false, + }), + ); + expect(disableLastRow?.success).toBe(true); + + const disableLastCol = unwrap( + await client.doc.tables.setStyleOption({ + sessionId: reopenEnableSessionId, + nodeId: reopenedNodeId, + flag: 'lastColumn', + enabled: false, + }), + ); + expect(disableLastCol?.success).toBe(true); + + // Save disabled state + const disabledDocPath = outPath('disabled.docx'); + await client.doc.save({ sessionId: reopenEnableSessionId, out: disabledDocPath, force: true }); + + // Inspect OOXML — attrs must be present with false-equivalent value + const disabledDocXml = await readDocxPart(disabledDocPath, 'word/document.xml'); + const disabledTableXml = extractFirstTableXml(disabledDocXml); + + const disabledLastRow = extractTblLookAttr(disabledTableXml, 'lastRow'); + const disabledLastCol = extractTblLookAttr(disabledTableXml, 'lastColumn'); + expect(disabledLastRow).not.toBeNull(); + expect(isFalsy(disabledLastRow)).toBe(true); + expect(disabledLastCol).not.toBeNull(); + expect(isFalsy(disabledLastCol)).toBe(true); + // w:val must still be absent + expect(hasTblLookVal(disabledTableXml)).toBe(false); + + // Reopen and verify disable state via API + await client.doc.open({ sessionId: reopenDisableSessionId, doc: disabledDocPath }); + + const disabledTable = unwrap( + await client.doc.query.match({ + sessionId: reopenDisableSessionId, + select: { type: 'node', nodeType: 'table' }, + require: 'first', + }), + ); + const disabledTableNodeId = disabledTable?.items?.[0]?.address?.nodeId; + + const disabledProps = unwrap( + await client.doc.tables.getProperties({ + sessionId: reopenDisableSessionId, + nodeId: disabledTableNodeId, + }), + ); + expect(disabledProps?.styleOptions?.lastRow).toBe(false); + expect(disabledProps?.styleOptions?.lastColumn).toBe(false); + }); + + it('materializes full Word-default baseline when tblLook is absent', async () => { + // Create a new table (no tblLook) and set lastRow + const sessionId = sid('materialize'); + + // Open any doc — we'll create a new table in it + const sourceDoc = await copyDoc(FIXTURE_DOC, 'materialize-source.docx'); + await client.doc.open({ sessionId, doc: sourceDoc }); + + // Create a fresh table (no tblLook) + const createResult = unwrap(await client.doc.create.table({ sessionId, rows: 2, columns: 2 })); + expect(createResult?.success).toBe(true); + const newTableNodeId = createResult?.table?.nodeId; + expect(typeof newTableNodeId).toBe('string'); + + // Set lastRow on the new table + const setResult = unwrap( + await client.doc.tables.setStyleOption({ + sessionId, + nodeId: newTableNodeId, + flag: 'lastRow', + enabled: true, + }), + ); + expect(setResult?.success).toBe(true); + + // Save + const savedPath = outPath('materialized.docx'); + await client.doc.save({ sessionId, out: savedPath, force: true }); + + // Inspect OOXML — all flags should be present at Word-default values + const docXml = await readDocxPart(savedPath, 'word/document.xml'); + // Find the LAST table (the created one is appended) + const tables = docXml.match(//g); + expect(tables).not.toBeNull(); + const createdTableXml = tables![tables!.length - 1]; + + expect(isTruthy(extractTblLookAttr(createdTableXml, 'lastRow'))).toBe(true); + expect(isTruthy(extractTblLookAttr(createdTableXml, 'firstRow'))).toBe(true); + expect(isTruthy(extractTblLookAttr(createdTableXml, 'firstColumn'))).toBe(true); + expect(isFalsy(extractTblLookAttr(createdTableXml, 'lastColumn'))).toBe(true); + expect(isFalsy(extractTblLookAttr(createdTableXml, 'noHBand'))).toBe(true); + expect(isTruthy(extractTblLookAttr(createdTableXml, 'noVBand'))).toBe(true); + // w:val must be absent + expect(hasTblLookVal(createdTableXml)).toBe(false); + + // Reopen and verify all six flags via getProperties + const reopenSessionId = sid('materialize-reopen'); + await client.doc.open({ sessionId: reopenSessionId, doc: savedPath }); + + // Find the second table + const allTables = unwrap( + await client.doc.query.match({ + sessionId: reopenSessionId, + select: { type: 'node', nodeType: 'table' }, + }), + ); + const lastTableNodeId = allTables?.items?.[allTables.items.length - 1]?.address?.nodeId; + + const props = unwrap( + await client.doc.tables.getProperties({ + sessionId: reopenSessionId, + nodeId: lastTableNodeId, + }), + ); + expect(props?.styleOptions).toEqual({ + headerRow: true, + lastRow: true, + firstColumn: true, + lastColumn: false, + bandedRows: true, + bandedColumns: false, + }); + }); +});