From 3500838c2fd84001d5212ebd32e5e6f9f7d8b703 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 19:25:37 -0800 Subject: [PATCH 1/5] feat(document-api): doc defaults initial infra - styles namespace, bold --- apps/cli/scripts/export-sdk-contract.ts | 1 + .../src/__tests__/conformance/scenarios.ts | 18 + apps/cli/src/cli/operation-hints.ts | 4 + .../document-api/available-operations.mdx | 2 + .../reference/_generated-manifest.json | 11 +- .../reference/capabilities/get.mdx | 204 ++++++-- apps/docs/document-api/reference/index.mdx | 7 + .../document-api/reference/styles/apply.mdx | 407 ++++++++++++++++ .../document-api/reference/styles/index.mdx | 18 + .../src/capabilities/capabilities.ts | 2 + .../src/contract/contract.test.ts | 1 + .../src/contract/operation-definitions.ts | 17 + .../src/contract/operation-registry.ts | 4 + .../src/contract/reference-doc-map.ts | 5 + packages/document-api/src/contract/schemas.ts | 66 +++ packages/document-api/src/index.ts | 29 ++ .../document-api/src/invoke/invoke.test.ts | 29 ++ packages/document-api/src/invoke/invoke.ts | 3 + .../document-api/src/styles/styles.test.ts | 278 +++++++++++ packages/document-api/src/styles/styles.ts | 281 +++++++++++ .../contract-conformance.test.ts | 89 ++++ .../assemble-adapters.ts | 4 + .../capabilities-adapter.test.ts | 79 +++ .../capabilities-adapter.ts | 44 +- .../collaboration-detection.test.ts | 78 +++ .../collaboration-detection.ts | 60 +++ .../src/document-api-adapters/index.ts | 4 + .../out-of-band-mutation.test.ts | 135 ++++++ .../out-of-band-mutation.ts | 73 +++ .../styles-adapter.test.ts | 450 ++++++++++++++++++ .../document-api-adapters/styles-adapter.ts | 233 +++++++++ 31 files changed, 2593 insertions(+), 43 deletions(-) create mode 100644 apps/docs/document-api/reference/styles/apply.mdx create mode 100644 apps/docs/document-api/reference/styles/index.mdx create mode 100644 packages/document-api/src/styles/styles.test.ts create mode 100644 packages/document-api/src/styles/styles.ts create mode 100644 packages/super-editor/src/document-api-adapters/collaboration-detection.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/collaboration-detection.ts create mode 100644 packages/super-editor/src/document-api-adapters/out-of-band-mutation.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/out-of-band-mutation.ts create mode 100644 packages/super-editor/src/document-api-adapters/styles-adapter.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/styles-adapter.ts diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index f86dafcd71..e49b171b53 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -64,6 +64,7 @@ const INTENT_NAMES = { 'doc.format.fontFamily': 'format_font_family', 'doc.format.color': 'format_color', 'doc.format.align': 'format_align', + 'doc.styles.apply': 'styles_apply', 'doc.create.paragraph': 'create_paragraph', 'doc.create.heading': 'create_heading', 'doc.lists.list': 'list_lists', diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 2300a2d2c5..347795e99d 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -614,6 +614,24 @@ export const SUCCESS_SCENARIOS = { ], }; }, + 'doc.styles.apply': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-styles-apply-success'); + const docPath = await harness.copyFixtureDoc('doc-styles-apply'); + return { + stateDir, + args: [ + 'styles', + 'apply', + docPath, + '--target-json', + JSON.stringify({ scope: 'docDefaults', channel: 'run' }), + '--patch-json', + JSON.stringify({ bold: true }), + '--out', + harness.createOutputPath('doc-styles-apply-output'), + ], + }; + }, 'doc.trackChanges.list': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-track-changes-list-success'); const fixture = await harness.addTrackedChangeFixture(stateDir, 'doc-track-changes-list'); diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 030d909acb..016b82381a 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -41,6 +41,7 @@ export const SUCCESS_VERB: Record = { 'format.fontFamily': 'set font family', 'format.color': 'set text color', 'format.align': 'set alignment', + 'styles.apply': 'applied stylesheet defaults', 'create.paragraph': 'created paragraph', 'create.heading': 'created heading', 'lists.list': 'listed items', @@ -103,6 +104,7 @@ export const OUTPUT_FORMAT: Record = { 'format.fontFamily': 'mutationReceipt', 'format.color': 'mutationReceipt', 'format.align': 'mutationReceipt', + 'styles.apply': 'receipt', 'create.paragraph': 'createResult', 'create.heading': 'createResult', 'lists.list': 'listResult', @@ -153,6 +155,7 @@ export const RESPONSE_ENVELOPE_KEY: Record 'format.fontFamily': null, 'format.color': null, 'format.align': null, + 'styles.apply': 'receipt', 'create.paragraph': 'result', 'create.heading': 'result', 'lists.list': 'result', @@ -232,6 +235,7 @@ export const OPERATION_FAMILY: Record = 'format.fontFamily': 'textMutation', 'format.color': 'textMutation', 'format.align': 'textMutation', + 'styles.apply': 'general', 'create.paragraph': 'create', 'create.heading': 'create', 'lists.list': 'lists', diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 0f0a66de8e..2f1536ade4 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -23,6 +23,7 @@ Use the tables below to see what operations are available and where each one is | Lists | 8 | 0 | 8 | [Reference](/document-api/reference/lists/index) | | Mutations | 2 | 0 | 2 | [Reference](/document-api/reference/mutations/index) | | Query | 1 | 0 | 1 | [Reference](/document-api/reference/query/index) | +| Styles | 1 | 0 | 1 | [Reference](/document-api/reference/styles/index) | | Track Changes | 3 | 0 | 3 | [Reference](/document-api/reference/track-changes/index) | | Editor method | Operation | @@ -64,6 +65,7 @@ Use the tables below to see what operations are available and where each one is | editor.doc.mutations.preview(...) | [`mutations.preview`](/document-api/reference/mutations/preview) | | editor.doc.mutations.apply(...) | [`mutations.apply`](/document-api/reference/mutations/apply) | | editor.doc.query.match(...) | [`query.match`](/document-api/reference/query/match) | +| editor.doc.styles.apply(...) | [`styles.apply`](/document-api/reference/styles/apply) | | editor.doc.trackChanges.list(...) | [`trackChanges.list`](/document-api/reference/track-changes/list) | | editor.doc.trackChanges.get(...) | [`trackChanges.get`](/document-api/reference/track-changes/get) | | editor.doc.trackChanges.decide(...) | [`trackChanges.decide`](/document-api/reference/track-changes/decide) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index c70671fae0..4df8a3efb7 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -44,6 +44,8 @@ "apps/docs/document-api/reference/query/index.mdx", "apps/docs/document-api/reference/query/match.mdx", "apps/docs/document-api/reference/replace.mdx", + "apps/docs/document-api/reference/styles/apply.mdx", + "apps/docs/document-api/reference/styles/index.mdx", "apps/docs/document-api/reference/track-changes/decide.mdx", "apps/docs/document-api/reference/track-changes/get.mdx", "apps/docs/document-api/reference/track-changes/index.mdx", @@ -86,6 +88,13 @@ "pagePath": "apps/docs/document-api/reference/format/index.mdx", "title": "Format" }, + { + "aliasMemberPaths": [], + "key": "styles", + "operationIds": ["styles.apply"], + "pagePath": "apps/docs/document-api/reference/styles/index.mdx", + "title": "Styles" + }, { "aliasMemberPaths": [], "key": "lists", @@ -132,5 +141,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "87c87f3fdb162eb43656a1971fd5f93fec1c0e76cc1f9a9ac22b585f1e4b0b28" + "sourceHash": "f9d12120c152cb1bfc33ab599262bbc34d6f1b55a803242c2dceb5d4e09022e5" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 9e608c93a8..6e680f3f64 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -336,6 +336,14 @@ _No fields._ ], "tracked": true }, + "styles.apply": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "trackChanges.decide": { "available": true, "dryRun": true, @@ -427,7 +435,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -452,7 +462,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -477,7 +489,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -502,7 +516,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -542,7 +558,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -575,7 +593,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -608,7 +628,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -641,7 +663,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -674,7 +698,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -707,7 +733,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -740,7 +768,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -773,7 +803,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -806,7 +838,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -839,7 +873,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -872,7 +908,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -905,7 +943,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -938,7 +978,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -971,7 +1013,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1004,7 +1048,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1037,7 +1083,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1070,7 +1118,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1103,7 +1153,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1136,7 +1188,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1169,7 +1223,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1202,7 +1258,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1235,7 +1293,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1268,7 +1328,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1301,7 +1363,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1334,7 +1398,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1367,7 +1433,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1400,7 +1468,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1433,7 +1503,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1466,7 +1538,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1499,7 +1573,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1532,7 +1608,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1565,7 +1643,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1598,7 +1678,44 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, + "styles.apply": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1631,7 +1748,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1664,7 +1783,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1697,7 +1818,9 @@ _No fields._ "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", - "NAMESPACE_UNAVAILABLE" + "NAMESPACE_UNAVAILABLE", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" ] }, "type": "array" @@ -1729,6 +1852,7 @@ _No fields._ "format.fontFamily", "format.color", "format.align", + "styles.apply", "create.paragraph", "create.heading", "lists.list", diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index dce32d3a65..cbf08273ad 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -25,6 +25,7 @@ Document API is currently alpha and subject to breaking changes. | Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) | | Create | 2 | 0 | 2 | [Open](/document-api/reference/create/index) | | Format | 5 | 4 | 9 | [Open](/document-api/reference/format/index) | +| Styles | 1 | 0 | 1 | [Open](/document-api/reference/styles/index) | | Lists | 8 | 0 | 8 | [Open](/document-api/reference/lists/index) | | Comments | 5 | 0 | 5 | [Open](/document-api/reference/comments/index) | | Track Changes | 3 | 0 | 3 | [Open](/document-api/reference/track-changes/index) | @@ -81,6 +82,12 @@ The tables below are grouped by namespace. | format.underline | editor.doc.format.underline(...) | Convenience alias for `format.apply` with `inline.underline: true`. | | format.strikethrough | editor.doc.format.strikethrough(...) | Convenience alias for `format.apply` with `inline.strike: true`. | +#### Styles + +| Operation | API member path | Description | +| --- | --- | --- | +| styles.apply | editor.doc.styles.apply(...) | Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run properties with boolean patch semantics. | + #### Lists | Operation | API member path | Description | diff --git a/apps/docs/document-api/reference/styles/apply.mdx b/apps/docs/document-api/reference/styles/apply.mdx new file mode 100644 index 0000000000..47f40cef0b --- /dev/null +++ b/apps/docs/document-api/reference/styles/apply.mdx @@ -0,0 +1,407 @@ +--- +title: styles.apply +sidebarTitle: styles.apply +description: Reference for styles.apply +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `styles.apply` +- API member path: `editor.doc.styles.apply(...)` +- Mutates document: `yes` +- Idempotency: `idempotent` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `patch` | object | yes | | +| `target` | object(scope="docDefaults") | yes | | + +### Example request + +```json +{ + "patch": { + "bold": true + }, + "target": { + "channel": "run", + "scope": "docDefaults" + } +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "after": { + "bold": "on" + }, + "before": { + "bold": "on" + }, + "changed": true, + "dryRun": true, + "resolution": { + "channel": "run", + "scope": "docDefaults", + "xmlPart": "word/styles.xml", + "xmlPath": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + }, + "success": true +} +``` + +## Pre-apply throws + +- `INVALID_TARGET` +- `INVALID_INPUT` +- `CAPABILITY_UNAVAILABLE` +- `REVISION_MISMATCH` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "patch": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bold": { + "type": "boolean" + } + }, + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "channel": { + "const": "run" + }, + "scope": { + "const": "docDefaults" + } + }, + "required": [ + "scope", + "channel" + ], + "type": "object" + } + }, + "required": [ + "target", + "patch" + ], + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "after": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "off", + "inherit" + ] + } + }, + "required": [ + "bold" + ], + "type": "object" + }, + "before": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "off", + "inherit" + ] + } + }, + "required": [ + "bold" + ], + "type": "object" + }, + "changed": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "channel": { + "const": "run" + }, + "scope": { + "const": "docDefaults" + }, + "xmlPart": { + "const": "word/styles.xml" + }, + "xmlPath": { + "const": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + } + }, + "required": [ + "scope", + "channel", + "xmlPart", + "xmlPath" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "changed", + "resolution", + "dryRun", + "before", + "after" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "channel": { + "const": "run" + }, + "scope": { + "const": "docDefaults" + }, + "xmlPart": { + "const": "word/styles.xml" + }, + "xmlPath": { + "const": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + } + }, + "required": [ + "scope", + "channel", + "xmlPart", + "xmlPath" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "resolution", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "after": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "off", + "inherit" + ] + } + }, + "required": [ + "bold" + ], + "type": "object" + }, + "before": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "off", + "inherit" + ] + } + }, + "required": [ + "bold" + ], + "type": "object" + }, + "changed": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "channel": { + "const": "run" + }, + "scope": { + "const": "docDefaults" + }, + "xmlPart": { + "const": "word/styles.xml" + }, + "xmlPath": { + "const": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + } + }, + "required": [ + "scope", + "channel", + "xmlPart", + "xmlPath" + ], + "type": "object" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "changed", + "resolution", + "dryRun", + "before", + "after" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "channel": { + "const": "run" + }, + "scope": { + "const": "docDefaults" + }, + "xmlPart": { + "const": "word/styles.xml" + }, + "xmlPath": { + "const": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + } + }, + "required": [ + "scope", + "channel", + "xmlPart", + "xmlPath" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "resolution", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/styles/index.mdx b/apps/docs/document-api/reference/styles/index.mdx new file mode 100644 index 0000000000..074d41ab45 --- /dev/null +++ b/apps/docs/document-api/reference/styles/index.mdx @@ -0,0 +1,18 @@ +--- +title: Styles operations +sidebarTitle: Styles +description: Styles operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Document-level stylesheet mutations (docDefaults, style definitions). + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| styles.apply | `styles.apply` | Yes | `idempotent` | No | Yes | + diff --git a/packages/document-api/src/capabilities/capabilities.ts b/packages/document-api/src/capabilities/capabilities.ts index 60b86bc06a..bce227e30c 100644 --- a/packages/document-api/src/capabilities/capabilities.ts +++ b/packages/document-api/src/capabilities/capabilities.ts @@ -7,6 +7,8 @@ export const CAPABILITY_REASON_CODES = [ 'TRACKED_MODE_UNAVAILABLE', 'DRY_RUN_UNAVAILABLE', 'NAMESPACE_UNAVAILABLE', + 'STYLES_PART_MISSING', + 'COLLABORATION_ACTIVE', ] as const; export type CapabilityReasonCode = (typeof CAPABILITY_REASON_CODES)[number]; diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 92c817b50f..c6cb57588d 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -104,6 +104,7 @@ describe('document-api contract catalog', () => { 'capabilities', 'create', 'format', + 'styles', 'lists', 'comments', 'trackChanges', diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index f6415a818e..9a064463c4 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -37,6 +37,7 @@ export type ReferenceGroupKey = | 'capabilities' | 'create' | 'format' + | 'styles' | 'lists' | 'comments' | 'trackChanges' @@ -328,6 +329,22 @@ export const OPERATION_DEFINITIONS = { referenceGroup: 'format', }, + 'styles.apply': { + memberPath: 'styles.apply', + description: + 'Apply document-level default style changes to the stylesheet (word/styles.xml). Targets docDefaults run properties with boolean patch semantics.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'idempotent', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: NONE_FAILURES, + throws: ['INVALID_TARGET', 'INVALID_INPUT', 'CAPABILITY_UNAVAILABLE', 'REVISION_MISMATCH'], + }), + referenceDocPath: 'styles/apply.mdx', + referenceGroup: 'styles', + }, + 'create.paragraph': { memberPath: 'create.paragraph', description: 'Create a new paragraph at the target position.', diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index 2fa1ef5535..0e8bdbd83b 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -34,6 +34,7 @@ import type { FormatColorInput, FormatAlignInput, } from '../format/format.js'; +import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt } from '../styles/styles.js'; import type { CommentsCreateInput, CommentsPatchInput, @@ -87,6 +88,9 @@ export interface OperationRegistry { 'format.color': { input: FormatColorInput; options: MutationOptions; output: TextMutationReceipt }; 'format.align': { input: FormatAlignInput; options: MutationOptions; output: TextMutationReceipt }; + // --- styles.* --- + 'styles.apply': { input: StylesApplyInput; options: StylesApplyOptions; output: StylesApplyReceipt }; + // --- create.* --- 'create.paragraph': { input: CreateParagraphInput; options: MutationOptions; output: CreateParagraphResult }; 'create.heading': { input: CreateHeadingInput; options: MutationOptions; output: CreateHeadingResult }; diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index 7bb70ed8f6..d9fcd71c2d 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -46,6 +46,11 @@ const GROUP_METADATA: Record = { ), failure: preApplyFailureResultSchemaFor('blocks.delete'), }, + 'styles.apply': (() => { + const stylesTargetResolutionSchema = objectSchema( + { + scope: { const: 'docDefaults' }, + channel: { const: 'run' }, + xmlPart: { const: 'word/styles.xml' }, + xmlPath: { const: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr' }, + }, + ['scope', 'channel', 'xmlPart', 'xmlPath'], + ); + const stylesStateSchema = objectSchema({ bold: { enum: ['on', 'off', 'inherit'] } }, ['bold']); + const stylesSuccessSchema = objectSchema( + { + success: { const: true }, + changed: { type: 'boolean' }, + resolution: stylesTargetResolutionSchema, + dryRun: { type: 'boolean' }, + before: stylesStateSchema, + after: stylesStateSchema, + }, + ['success', 'changed', 'resolution', 'dryRun', 'before', 'after'], + ); + const stylesFailureSchema = objectSchema( + { + success: { const: false }, + resolution: stylesTargetResolutionSchema, + failure: objectSchema( + { + code: { type: 'string' }, + message: { type: 'string' }, + details: {}, + }, + ['code', 'message'], + ), + }, + ['success', 'resolution', 'failure'], + ); + return { + input: objectSchema( + { + target: objectSchema( + { + scope: { const: 'docDefaults' }, + channel: { const: 'run' }, + }, + ['scope', 'channel'], + ), + patch: { + ...objectSchema( + { + bold: { type: 'boolean' }, + }, + [], + ), + minProperties: 1, + }, + }, + ['target', 'patch'], + ), + output: { oneOf: [stylesSuccessSchema, stylesFailureSchema] }, + success: stylesSuccessSchema, + failure: stylesFailureSchema, + }; + })(), 'create.paragraph': { input: objectSchema({ at: { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index ee5aeb1b05..b21176696e 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -58,6 +58,14 @@ import type { FormatAlignInput, } from './format/format.js'; import { executeStyleApply, executeFontSize, executeFontFamily, executeColor, executeAlign } from './format/format.js'; +import type { + StylesAdapter, + StylesApi, + StylesApplyInput, + StylesApplyOptions, + StylesApplyReceipt, +} from './styles/styles.js'; +import { executeStylesApply } from './styles/styles.js'; import type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; import { executeGetNode, executeGetNodeById } from './get-node/get-node.js'; import { executeGetText, type GetTextAdapter, type GetTextInput } from './get-text/get-text.js'; @@ -136,6 +144,17 @@ export type { FormatAlignInput, } from './format/format.js'; export { ALIGNMENTS, type Alignment } from './format/format.js'; +export type { + StylesAdapter, + StylesApplyInput, + StylesApplyOptions, + StylesApplyReceipt, + StylesBooleanState, + StylesTargetResolution, + StylesApplyReceiptSuccess, + StylesApplyReceiptFailure, + NormalizedStylesApplyOptions, +} from './styles/styles.js'; export type { CreateAdapter } from './create/create.js'; export type { TrackChangesAdapter, @@ -273,6 +292,10 @@ export interface DocumentApi { * Formatting operations. */ format: FormatApi; + /** + * Stylesheet operations (docDefaults, style definitions). + */ + styles: StylesApi; /** * Tracked-change operations (list, get, decide). */ @@ -327,6 +350,7 @@ export interface DocumentApiAdapters { comments: CommentsAdapter; write: WriteAdapter; format: FormatAdapter; + styles: StylesAdapter; trackChanges: TrackChangesAdapter; create: CreateAdapter; blocks: BlocksAdapter; @@ -426,6 +450,11 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeAlign(adapters.format, input, options); }, }, + styles: { + apply(input: StylesApplyInput, options?: StylesApplyOptions): StylesApplyReceipt { + return executeStylesApply(adapters.styles, input, options); + }, + }, trackChanges: { list(input?: TrackChangesListInput): TrackChangesListResult { return executeTrackChangesList(adapters.trackChanges, input); diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 5c7d8268bb..b395f0be5c 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -6,6 +6,7 @@ import type { FindAdapter } from '../find/find.js'; import type { GetNodeAdapter } from '../get-node/get-node.js'; import type { WriteAdapter } from '../write/write.js'; import type { FormatAdapter } from '../format/format.js'; +import type { StylesAdapter } from '../styles/styles.js'; import type { TrackChangesAdapter } from '../track-changes/track-changes.js'; import type { CreateAdapter } from '../create/create.js'; import type { ListsAdapter } from '../lists/lists.js'; @@ -90,6 +91,21 @@ function makeAdapters() { color: vi.fn(formatReceipt), align: vi.fn(formatReceipt), }; + const stylesAdapter: StylesAdapter = { + apply: vi.fn(() => ({ + success: true as const, + changed: true, + resolution: { + scope: 'docDefaults' as const, + channel: 'run' as const, + xmlPart: 'word/styles.xml' as const, + xmlPath: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr' as const, + }, + dryRun: false, + before: { bold: 'inherit' as const }, + after: { bold: 'on' as const }, + })), + }; const trackChangesAdapter: TrackChangesAdapter = { list: vi.fn(() => ({ evaluatedRevision: '', total: 0, items: [], page: { limit: 50, offset: 0, returned: 0 } })), get: vi.fn((input: { id: string }) => ({ @@ -169,6 +185,7 @@ function makeAdapters() { comments: commentsAdapter, write: writeAdapter, format: formatAdapter, + styles: stylesAdapter, trackChanges: trackChangesAdapter, create: createAdapter, lists: listsAdapter, @@ -325,6 +342,18 @@ describe('invoke', () => { expect(invoked).toEqual(direct); }); + it('styles.apply: invoke returns same result as direct call', () => { + const { adapters } = makeAdapters(); + const api = createDocumentApi(adapters); + const input = { + target: { scope: 'docDefaults' as const, channel: 'run' as const }, + patch: { bold: true }, + }; + const direct = api.styles.apply(input); + const invoked = api.invoke({ operationId: 'styles.apply', input }); + expect(invoked).toEqual(direct); + }); + it('create.heading: invoke returns same result as direct call', () => { const { adapters } = makeAdapters(); const api = createDocumentApi(adapters); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 4de5d7bbf5..60a02824a6 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -54,6 +54,9 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { 'format.color': (input, options) => api.format.color(input, options), 'format.align': (input, options) => api.format.align(input, options), + // --- styles.* --- + 'styles.apply': (input, options) => api.styles.apply(input, options), + // --- create.* --- 'create.paragraph': (input, options) => api.create.paragraph(input, options), 'create.heading': (input, options) => api.create.heading(input, options), diff --git a/packages/document-api/src/styles/styles.test.ts b/packages/document-api/src/styles/styles.test.ts new file mode 100644 index 0000000000..5418026dbe --- /dev/null +++ b/packages/document-api/src/styles/styles.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + executeStylesApply, + type StylesAdapter, + type StylesApplyInput, + type StylesApplyOptions, + type StylesApplyReceipt, +} from './styles.js'; +import { DocumentApiValidationError } from '../errors.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeAdapter(receipt?: Partial): StylesAdapter { + return { + apply: vi.fn( + (): StylesApplyReceipt => ({ + success: true, + changed: true, + resolution: { + scope: 'docDefaults', + channel: 'run', + xmlPart: 'word/styles.xml', + xmlPath: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', + }, + dryRun: false, + before: { bold: 'inherit' }, + after: { bold: 'on' }, + ...receipt, + }), + ), + }; +} + +const VALID_INPUT: StylesApplyInput = { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { bold: true }, +}; + +function expectValidationError(fn: () => void, code: string, messagePattern?: RegExp) { + try { + fn(); + throw new Error('Expected DocumentApiValidationError to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiValidationError); + expect((err as DocumentApiValidationError).code).toBe(code); + if (messagePattern) { + expect((err as DocumentApiValidationError).message).toMatch(messagePattern); + } + } +} + +// --------------------------------------------------------------------------- +// Validation matrix — all locked error mappings +// --------------------------------------------------------------------------- + +describe('styles.apply validation', () => { + // --- Input shape --- + + it('throws INVALID_INPUT for non-object input', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesApply(adapter, null as never), 'INVALID_INPUT'); + expectValidationError(() => executeStylesApply(adapter, 42 as never), 'INVALID_INPUT'); + expectValidationError(() => executeStylesApply(adapter, 'string' as never), 'INVALID_INPUT'); + }); + + // --- Target validation --- + + it('throws INVALID_TARGET when target is missing', () => { + const adapter = makeAdapter(); + expectValidationError(() => executeStylesApply(adapter, { patch: { bold: true } } as never), 'INVALID_TARGET'); + }); + + it('throws INVALID_TARGET when target is not an object', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesApply(adapter, { target: 'bad', patch: { bold: true } } as never), + 'INVALID_TARGET', + ); + }); + + it('throws INVALID_TARGET when target.scope is not docDefaults', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'namedStyle' as never, channel: 'run' }, + patch: { bold: true }, + }), + 'INVALID_TARGET', + /scope/, + ); + }); + + it('throws INVALID_TARGET when target.channel is not run', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' as never }, + patch: { bold: true }, + }), + 'INVALID_TARGET', + /channel/, + ); + }); + + // --- Patch validation --- + + it('throws INVALID_INPUT when patch is missing', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' } } as never), + 'INVALID_INPUT', + ); + }); + + it('throws INVALID_INPUT when patch is not an object', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: 'bad' as never, + }), + 'INVALID_INPUT', + ); + }); + + it('throws INVALID_INPUT when patch is empty', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: {}, + }), + 'INVALID_INPUT', + /at least one/, + ); + }); + + it('throws INVALID_INPUT for unknown patch keys', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { italic: true } as never, + }), + 'INVALID_INPUT', + /Unknown patch key/, + ); + }); + + it('throws INVALID_INPUT when patch.bold is not a boolean', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { bold: 'yes' as never }, + }), + 'INVALID_INPUT', + /boolean/, + ); + }); + + // --- Unknown input fields --- + + it('throws INVALID_INPUT for unknown top-level fields', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + ...VALID_INPUT, + extra: true, + } as never), + 'INVALID_INPUT', + /Unknown field/, + ); + }); + + it('throws INVALID_INPUT for unknown target fields', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run', extra: true } as never, + patch: { bold: true }, + }), + 'INVALID_INPUT', + /Unknown field/, + ); + }); + + // --- Options validation --- + + it('throws INVALID_INPUT for unknown options keys (including changeMode)', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesApply(adapter, VALID_INPUT, { changeMode: 'direct' } as never), + 'INVALID_INPUT', + /Unknown options key/, + ); + }); + + it('throws INVALID_INPUT when options.dryRun is not a boolean', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesApply(adapter, VALID_INPUT, { dryRun: 'yes' } as never), + 'INVALID_INPUT', + /boolean/, + ); + }); + + it('throws INVALID_INPUT when options.expectedRevision is not a string', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesApply(adapter, VALID_INPUT, { expectedRevision: 42 } as never), + 'INVALID_INPUT', + /string/, + ); + }); + + it('accepts valid options (dryRun and expectedRevision)', () => { + const adapter = makeAdapter(); + const options: StylesApplyOptions = { dryRun: true, expectedRevision: '3' }; + const result = executeStylesApply(adapter, VALID_INPUT, options); + expect(result.success).toBe(true); + }); + + it('accepts undefined/null options', () => { + const adapter = makeAdapter(); + expect(() => executeStylesApply(adapter, VALID_INPUT, undefined)).not.toThrow(); + expect(() => executeStylesApply(adapter, VALID_INPUT)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +describe('styles.apply execution', () => { + it('delegates to adapter with normalized options', () => { + const adapter = makeAdapter(); + executeStylesApply(adapter, VALID_INPUT, { dryRun: true, expectedRevision: '5' }); + expect(adapter.apply).toHaveBeenCalledWith(VALID_INPUT, { dryRun: true, expectedRevision: '5' }); + }); + + it('defaults dryRun to false and expectedRevision to undefined', () => { + const adapter = makeAdapter(); + executeStylesApply(adapter, VALID_INPUT); + expect(adapter.apply).toHaveBeenCalledWith(VALID_INPUT, { dryRun: false, expectedRevision: undefined }); + }); + + it('returns the receipt from the adapter', () => { + const adapter = makeAdapter({ changed: false, before: { bold: 'on' }, after: { bold: 'on' } }); + const receipt = executeStylesApply(adapter, VALID_INPUT); + expect(receipt.success).toBe(true); + if (receipt.success) { + expect(receipt.changed).toBe(false); + expect(receipt.before.bold).toBe('on'); + expect(receipt.after.bold).toBe('on'); + } + }); + + it('allows patch.bold: false (explicit off)', () => { + const adapter = makeAdapter(); + const input: StylesApplyInput = { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { bold: false }, + }; + expect(() => executeStylesApply(adapter, input)).not.toThrow(); + expect(adapter.apply).toHaveBeenCalledWith(input, { dryRun: false, expectedRevision: undefined }); + }); +}); diff --git a/packages/document-api/src/styles/styles.ts b/packages/document-api/src/styles/styles.ts new file mode 100644 index 0000000000..76edefe9ef --- /dev/null +++ b/packages/document-api/src/styles/styles.ts @@ -0,0 +1,281 @@ +/** + * `styles.apply` — stylesheet mutation for document-level defaults. + * + * This module defines the contract types, validation, and execution for the + * `styles.apply` operation. The operation mutates `word/styles.xml` (docDefaults) + * rather than inline run formatting in `word/document.xml`. + * + * Engine-agnostic: no ProseMirror, Yjs, or converter imports. + */ + +import type { ReceiptFailure } from '../types/receipt.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { isRecord } from '../validation-primitives.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Tri-state for OOXML boolean style properties. + * + * - `'on'` — property is explicitly enabled (e.g., ``) + * - `'off'` — property is explicitly disabled (e.g., ``) + * - `'inherit'` — property element is absent; value inherited from cascade + */ +export type StylesBooleanState = 'on' | 'off' | 'inherit'; + +/** + * Resolution metadata describing exactly where in the OOXML package the + * mutation was (or would be) applied. + */ +export interface StylesTargetResolution { + scope: 'docDefaults'; + channel: 'run'; + xmlPart: 'word/styles.xml'; + xmlPath: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr'; +} + +/** + * Input payload for `styles.apply`. + * + * `target` selects the stylesheet scope and channel. + * `patch` declares the desired end-state for each property (set semantics, not toggle). + */ +export interface StylesApplyInput { + target: { + scope: 'docDefaults'; + channel: 'run'; + }; + patch: { + bold?: boolean; + }; +} + +/** + * Options for `styles.apply`. + * + * Intentionally NOT `MutationOptions` — `changeMode` is structurally excluded + * because tracked mode is invalid for stylesheet mutations. + */ +export interface StylesApplyOptions { + dryRun?: boolean; + expectedRevision?: string; +} + +/** Success branch of the `styles.apply` receipt. */ +export interface StylesApplyReceiptSuccess { + success: true; + changed: boolean; + resolution: StylesTargetResolution; + dryRun: boolean; + before: { bold: StylesBooleanState }; + after: { bold: StylesBooleanState }; +} + +/** Failure branch of the `styles.apply` receipt. */ +export interface StylesApplyReceiptFailure { + success: false; + resolution: StylesTargetResolution; + failure: ReceiptFailure; +} + +/** + * Receipt returned by `styles.apply`. + * + * The `success: false` branch is forward-compatible for future operations + * that may fail at runtime. For MVP, all validated calls succeed. + */ +export type StylesApplyReceipt = StylesApplyReceiptSuccess | StylesApplyReceiptFailure; + +// --------------------------------------------------------------------------- +// Adapter interface +// --------------------------------------------------------------------------- + +/** Engine-specific adapter for stylesheet mutations. */ +export interface StylesAdapter { + apply(input: StylesApplyInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; +} + +/** + * Normalized options passed to the adapter after defaults are resolved. + * + * Unlike {@link StylesApplyOptions}, all fields are required — callers + * never see `undefined` for `dryRun`. + */ +export interface NormalizedStylesApplyOptions { + dryRun: boolean; + expectedRevision: string | undefined; +} + +// --------------------------------------------------------------------------- +// Public API surface +// --------------------------------------------------------------------------- + +/** Public API surface for stylesheet operations (docDefaults, style definitions). */ +export interface StylesApi { + apply(input: StylesApplyInput, options?: StylesApplyOptions): StylesApplyReceipt; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +const STYLES_APPLY_INPUT_ALLOWED_KEYS = new Set(['target', 'patch']); +const STYLES_APPLY_TARGET_ALLOWED_KEYS = new Set(['scope', 'channel']); +const STYLES_APPLY_PATCH_ALLOWED_KEYS = new Set(['bold']); +const STYLES_APPLY_OPTIONS_ALLOWED_KEYS = new Set(['dryRun', 'expectedRevision']); + +function validateStylesApplyInput(input: unknown): asserts input is StylesApplyInput { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'styles.apply input must be a non-null object.'); + } + + assertNoUnknownInputFields(input, STYLES_APPLY_INPUT_ALLOWED_KEYS); + + // --- Target validation --- + const { target, patch } = input; + + if (target === undefined || target === null) { + throw new DocumentApiValidationError('INVALID_TARGET', 'styles.apply requires a target object.'); + } + + if (!isRecord(target)) { + throw new DocumentApiValidationError('INVALID_TARGET', 'target must be a non-null object.', { + field: 'target', + value: target, + }); + } + + assertNoUnknownInputFields(target, STYLES_APPLY_TARGET_ALLOWED_KEYS, 'target'); + + if (target.scope !== 'docDefaults') { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `target.scope must be "docDefaults", got ${JSON.stringify(target.scope)}.`, + { field: 'target.scope', value: target.scope }, + ); + } + + if (target.channel !== 'run') { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `target.channel must be "run", got ${JSON.stringify(target.channel)}.`, + { field: 'target.channel', value: target.channel }, + ); + } + + // --- Patch validation --- + if (patch === undefined || patch === null) { + throw new DocumentApiValidationError('INVALID_INPUT', 'styles.apply requires a patch object.'); + } + + if (!isRecord(patch)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'patch must be a non-null object.', { + field: 'patch', + value: patch, + }); + } + + const patchKeys = Object.keys(patch); + + if (patchKeys.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', 'patch must include at least one property.'); + } + + for (const key of patchKeys) { + if (!STYLES_APPLY_PATCH_ALLOWED_KEYS.has(key)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown patch key "${key}". Allowed keys: ${[...STYLES_APPLY_PATCH_ALLOWED_KEYS].join(', ')}.`, + { field: 'patch', key }, + ); + } + if (typeof patch[key] !== 'boolean') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch.${key} must be a boolean, got ${typeof patch[key]}.`, + { field: 'patch', key, value: patch[key] }, + ); + } + } +} + +function validateStylesApplyOptions(options: unknown): void { + if (options === undefined || options === null) return; + + if (!isRecord(options)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'styles.apply options must be a non-null object.'); + } + + for (const key of Object.keys(options)) { + if (!STYLES_APPLY_OPTIONS_ALLOWED_KEYS.has(key)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown options key "${key}". Allowed keys: ${[...STYLES_APPLY_OPTIONS_ALLOWED_KEYS].join(', ')}.`, + { field: 'options', key }, + ); + } + } + + if (options.dryRun !== undefined && typeof options.dryRun !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', 'options.dryRun must be a boolean.', { + field: 'options.dryRun', + value: options.dryRun, + }); + } + + if (options.expectedRevision !== undefined && typeof options.expectedRevision !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'options.expectedRevision must be a string.', { + field: 'options.expectedRevision', + value: options.expectedRevision, + }); + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function assertNoUnknownInputFields( + obj: Record, + allowlist: ReadonlySet, + prefix?: string, +): void { + for (const key of Object.keys(obj)) { + if (!allowlist.has(key)) { + const location = prefix ? `${prefix}.${key}` : key; + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown field "${location}" on styles.apply input. Allowed fields: ${[...allowlist].join(', ')}.`, + { field: location }, + ); + } + } +} + +function normalizeStylesApplyOptions(options?: StylesApplyOptions): NormalizedStylesApplyOptions { + return { + dryRun: options?.dryRun ?? false, + expectedRevision: options?.expectedRevision, + }; +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +/** + * Executes `styles.apply` using the provided adapter. + * + * Validates input and options, then delegates to the adapter. + */ +export function executeStylesApply( + adapter: StylesAdapter, + input: StylesApplyInput, + options?: StylesApplyOptions, +): StylesApplyReceipt { + validateStylesApplyInput(input); + validateStylesApplyOptions(options); + return adapter.apply(input, normalizeStylesApplyOptions(options)); +} 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 fecee95560..0c4bae2a6e 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 @@ -24,6 +24,7 @@ import { formatColorWrapper, formatAlignWrapper, } from '../plan-engine/format-value-wrappers.js'; +import { stylesApplyAdapter } from '../styles-adapter.js'; import { getDocumentApiCapabilities } from '../capabilities-adapter.js'; import { listsExitWrapper, @@ -467,6 +468,65 @@ function makeCommentsEditor( } as unknown as Editor; } +/** + * Creates a mock editor with a valid `word/styles.xml` structure for styles.apply tests. + * Optionally omit the converter or styles part to test capability gates. + */ +function makeStylesEditor( + opts: { + hasConverter?: boolean; + hasStylesPart?: boolean; + boldElements?: Array<{ attributes?: Record }>; + } = {}, +): Editor { + const { hasConverter = true, hasStylesPart = true, boldElements = [] } = opts; + + const rPrElements = boldElements.map((el) => ({ + name: 'w:b', + ...(el.attributes ? { attributes: el.attributes } : {}), + })); + + const stylesXml = { + name: 'xml', + elements: [ + { + name: 'w:styles', + elements: [ + { + name: 'w:docDefaults', + elements: [ + { + name: 'w:rPrDefault', + elements: [ + { + name: 'w:rPr', + elements: rPrElements, + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const converter = hasConverter + ? { + convertedXml: hasStylesPart ? { 'word/styles.xml': stylesXml } : {}, + documentModified: false, + documentGuid: 'test-guid', + promoteToGuid: vi.fn(() => 'promoted-guid'), + } + : undefined; + + return { + converter, + options: {}, + on: vi.fn(), + } as unknown as Editor; +} + function setTrackChanges(changes: Array>): void { mockedDeps.getTrackChanges.mockReturnValue(changes as never); } @@ -1009,6 +1069,24 @@ const mutationVectors: Partial> = { ); }, }, + 'styles.apply': { + throwCase: () => { + const editor = makeStylesEditor({ hasConverter: false }); + return stylesApplyAdapter( + editor, + { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true } }, + { dryRun: false, expectedRevision: undefined }, + ); + }, + applyCase: () => { + const editor = makeStylesEditor(); + return stylesApplyAdapter( + editor, + { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true } }, + { dryRun: false, expectedRevision: undefined }, + ); + }, + }, }; const dryRunVectors: Partial unknown>> = { @@ -1200,6 +1278,17 @@ const dryRunVectors: Partial unknown>> = { expect(exitListItemAt).not.toHaveBeenCalled(); return result; }, + 'styles.apply': () => { + const editor = makeStylesEditor(); + const result = stylesApplyAdapter( + editor, + { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true } }, + { dryRun: true, expectedRevision: undefined }, + ); + // dryRun should not mark the document as modified + expect((editor as unknown as { converter: { documentModified: boolean } }).converter.documentModified).toBe(false); + return result; + }, }; beforeEach(() => { diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index 664e652a36..847690227d 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -7,6 +7,7 @@ import { infoAdapter } from './info-adapter.js'; import { getDocumentApiCapabilities } from './capabilities-adapter.js'; import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; import { writeWrapper, styleApplyWrapper } from './plan-engine/plan-wrappers.js'; +import { stylesApplyAdapter } from './styles-adapter.js'; import { formatFontSizeWrapper, formatFontFamilyWrapper, @@ -78,6 +79,9 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters color: (input, options) => formatColorWrapper(editor, input, options), align: (input, options) => formatAlignWrapper(editor, input, options), }, + styles: { + apply: (input, options) => stylesApplyAdapter(editor, input, options), + }, trackChanges: { list: (input) => trackChangesListWrapper(editor, input), get: (input) => trackChangesGetWrapper(editor, input), diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts index b3600d4e4b..9c6eb2c846 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -349,4 +349,83 @@ describe('getDocumentApiCapabilities', () => { expect(capabilities.operations['format.align'].tracked).toBe(false); }); }); + + // --- styles.apply capability tests --- + + it('marks styles.apply as available when converter has a valid styles part', () => { + const editor = makeEditor(); + (editor as unknown as Record).converter = { + convertedXml: { + 'word/styles.xml': { name: 'root', elements: [{ name: 'w:styles', elements: [] }] }, + }, + }; + + const capabilities = getDocumentApiCapabilities(editor); + expect(capabilities.operations['styles.apply'].available).toBe(true); + expect(capabilities.operations['styles.apply'].dryRun).toBe(true); + expect(capabilities.operations['styles.apply'].reasons).toBeUndefined(); + }); + + it('marks styles.apply unavailable with OPERATION_UNAVAILABLE when converter is missing', () => { + const editor = makeEditor(); + // No converter set on editor — default case + + const capabilities = getDocumentApiCapabilities(editor); + const reasons = capabilities.operations['styles.apply'].reasons ?? []; + expect(capabilities.operations['styles.apply'].available).toBe(false); + expect(reasons).toContain('OPERATION_UNAVAILABLE'); + expect(reasons).not.toContain('COMMAND_UNAVAILABLE'); + }); + + it('reports STYLES_PART_MISSING when converter exists but word/styles.xml is absent', () => { + const editor = makeEditor(); + (editor as unknown as Record).converter = { + convertedXml: {}, + }; + + const capabilities = getDocumentApiCapabilities(editor); + const reasons = capabilities.operations['styles.apply'].reasons ?? []; + expect(capabilities.operations['styles.apply'].available).toBe(false); + expect(reasons).toContain('STYLES_PART_MISSING'); + expect(reasons).toContain('OPERATION_UNAVAILABLE'); + }); + + it('reports STYLES_PART_MISSING when styles part has no w:styles root', () => { + const editor = makeEditor(); + (editor as unknown as Record).converter = { + convertedXml: { + 'word/styles.xml': { name: 'root', elements: [{ name: 'w:other' }] }, + }, + }; + + const capabilities = getDocumentApiCapabilities(editor); + const reasons = capabilities.operations['styles.apply'].reasons ?? []; + expect(capabilities.operations['styles.apply'].available).toBe(false); + expect(reasons).toContain('STYLES_PART_MISSING'); + }); + + it('reports COLLABORATION_ACTIVE when collaboration provider is synced', () => { + const editor = makeEditor(); + (editor as unknown as Record).converter = { + convertedXml: { + 'word/styles.xml': { name: 'root', elements: [{ name: 'w:styles', elements: [] }] }, + }, + }; + (editor as unknown as { options: Record }).options.collaborationProvider = { synced: true }; + + const capabilities = getDocumentApiCapabilities(editor); + const reasons = capabilities.operations['styles.apply'].reasons ?? []; + expect(capabilities.operations['styles.apply'].available).toBe(false); + expect(reasons).toContain('COLLABORATION_ACTIVE'); + expect(reasons).toContain('OPERATION_UNAVAILABLE'); + }); + + it('styles.apply never reports COMMAND_UNAVAILABLE', () => { + const editor = makeEditor(); + // No converter → unavailable, but should not use COMMAND_UNAVAILABLE + + const capabilities = getDocumentApiCapabilities(editor); + const reasons = capabilities.operations['styles.apply'].reasons ?? []; + expect(reasons).not.toContain('COMMAND_UNAVAILABLE'); + }); }); diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 4cf5bcef6a..dff3283ff0 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -11,6 +11,7 @@ import { OPERATION_IDS, } from '@superdoc/document-api'; import { TrackFormatMarkName } from '../extensions/track-changes/constants.js'; +import { isCollaborationActive } from './collaboration-detection.js'; type EditorCommandName = string; @@ -135,6 +136,37 @@ function pushReason(reasons: CapabilityReasonCode[], reason: CapabilityReasonCod if (!reasons.includes(reason)) reasons.push(reason); } +/** Operations that determine availability through non-command mechanisms. */ +function isNonCommandBackedOperation(operationId: OperationId): boolean { + return operationId === 'format.apply' || operationId === 'styles.apply' || INLINE_FORMAT_OPERATIONS.has(operationId); +} + +/** Checks whether the styles part has a valid w:styles root element. */ +function hasStylesRoot(stylesPart: unknown): boolean { + const part = stylesPart as { elements?: Array<{ name?: string }> } | undefined; + return part?.elements?.some((el) => el.name === 'w:styles') === true; +} + +function isStylesApplyAvailable(editor: Editor): boolean { + const converter = (editor as unknown as { converter?: { convertedXml?: Record } }).converter; + if (!converter?.convertedXml?.['word/styles.xml']) return false; + if (!hasStylesRoot(converter.convertedXml['word/styles.xml'])) return false; + if (isCollaborationActive(editor)) return false; + return true; +} + +/** + * Returns the reason code when `styles.apply` is unavailable, or `undefined` if available. + */ +function getStylesApplyUnavailableReason(editor: Editor): CapabilityReasonCode | undefined { + const converter = (editor as unknown as { converter?: { convertedXml?: Record } }).converter; + if (!converter) return 'OPERATION_UNAVAILABLE'; + if (!converter.convertedXml?.['word/styles.xml']) return 'STYLES_PART_MISSING'; + if (!hasStylesRoot(converter.convertedXml['word/styles.xml'])) return 'STYLES_PART_MISSING'; + if (isCollaborationActive(editor)) return 'COLLABORATION_ACTIVE'; + return undefined; +} + function isOperationAvailable(editor: Editor, operationId: OperationId): boolean { // format.apply is available if at least one mark type exists in the schema if (operationId === 'format.apply') { @@ -146,11 +178,16 @@ function isOperationAvailable(editor: Editor, operationId: OperationId): boolean return hasAllCommands(editor, operationId) && hasMarkCapability(editor, 'textStyle'); } + // styles.apply requires converter + styles part + no collaboration + if (operationId === 'styles.apply') { + return isStylesApplyAvailable(editor); + } + return hasAllCommands(editor, operationId) && hasRequiredHelpers(editor, operationId); } function isCommandBackedAvailability(operationId: OperationId): boolean { - return !isMarkBackedOperation(operationId) && !INLINE_FORMAT_OPERATIONS.has(operationId); + return !isNonCommandBackedOperation(operationId); } function buildOperationCapabilities(editor: Editor): DocumentApiCapabilities['operations'] { @@ -165,7 +202,10 @@ function buildOperationCapabilities(editor: Editor): DocumentApiCapabilities['op const reasons: CapabilityReasonCode[] = []; if (!available) { - if (isCommandBackedAvailability(operationId)) { + if (operationId === 'styles.apply') { + const stylesReason = getStylesApplyUnavailableReason(editor); + if (stylesReason) pushReason(reasons, stylesReason); + } else if (isCommandBackedAvailability(operationId)) { if (!hasAllCommands(editor, operationId)) { pushReason(reasons, 'COMMAND_UNAVAILABLE'); } diff --git a/packages/super-editor/src/document-api-adapters/collaboration-detection.test.ts b/packages/super-editor/src/document-api-adapters/collaboration-detection.test.ts new file mode 100644 index 0000000000..9d9fc0ac0d --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/collaboration-detection.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { isCollaborationActive } from './collaboration-detection.js'; + +function makeEditor(collaborationProvider: unknown) { + return { options: { collaborationProvider } } as Parameters[0]; +} + +describe('isCollaborationActive', () => { + it('returns false when no provider is registered', () => { + expect(isCollaborationActive(makeEditor(null))).toBe(false); + expect(isCollaborationActive(makeEditor(undefined))).toBe(false); + }); + + it('returns false when provider is registered but pre-initial-sync (synced: false)', () => { + expect(isCollaborationActive(makeEditor({ synced: false }))).toBe(false); + }); + + it('returns true when provider is connected and synced (synced: true)', () => { + expect(isCollaborationActive(makeEditor({ synced: true }))).toBe(true); + }); + + it('returns true when provider uses isSynced: true', () => { + expect(isCollaborationActive(makeEditor({ isSynced: true }))).toBe(true); + }); + + it('returns false when provider uses isSynced: false', () => { + expect(isCollaborationActive(makeEditor({ isSynced: false }))).toBe(false); + }); + + it('returns false when provider is an empty object (no sync flags)', () => { + expect(isCollaborationActive(makeEditor({}))).toBe(false); + }); + + // --- "has ever synced" latch behavior --- + + it('stays true after provider synced then disconnected (synced flips to false)', () => { + const provider = { synced: true }; + const editor = makeEditor(provider); + + // First call: synced, latches the provider + expect(isCollaborationActive(editor)).toBe(true); + + // Simulate transient disconnect + provider.synced = false; + + // Should still report active due to latch + expect(isCollaborationActive(editor)).toBe(true); + }); + + it('stays true after provider synced then disconnected (isSynced flips to false)', () => { + const provider = { isSynced: true } as { isSynced: boolean }; + const editor = makeEditor(provider); + + expect(isCollaborationActive(editor)).toBe(true); + + provider.isSynced = false; + expect(isCollaborationActive(editor)).toBe(true); + }); + + it('returns false when provider is removed after previously syncing', () => { + const provider = { synced: true }; + const editor = makeEditor(provider); + + expect(isCollaborationActive(editor)).toBe(true); + + // Simulate provider removal (collaboration fully torn down) + (editor.options as { collaborationProvider: unknown }).collaborationProvider = null; + expect(isCollaborationActive(editor)).toBe(false); + }); + + it('does not latch a provider that never synced', () => { + const provider = { synced: false }; + const editor = makeEditor(provider); + + expect(isCollaborationActive(editor)).toBe(false); + expect(isCollaborationActive(editor)).toBe(false); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/collaboration-detection.ts b/packages/super-editor/src/document-api-adapters/collaboration-detection.ts new file mode 100644 index 0000000000..43bb1d1dc7 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/collaboration-detection.ts @@ -0,0 +1,60 @@ +/** + * Deterministic collaboration detection for out-of-band mutation gating. + * + * Both the capability checker and execution-time gates call this single + * helper to determine whether collaboration is active. This prevents drift + * between the two call sites. + * + * Decision rule: returns `true` if a Yjs provider **exists and has ever + * synced**. Once collaboration has been established, stylesheet mutations + * are blocked even during transient disconnects. + * + * | Provider state | Result | Rationale | + * |----------------------------------------|---------|------------------------------------------| + * | No provider registered | `false` | No collaboration possible. | + * | Provider registered, pre-initial-sync | `false` | No peers have synced yet. | + * | Provider connected and synced | `true` | Peers are active. | + * | Provider disconnected (temporary) | `true` | Provider will reconnect; would diverge. | + * | Provider destroyed/removed | `false` | Collaboration fully torn down. | + */ + +import type { Editor } from '../core/Editor.js'; + +/** Minimal shape of a Yjs collaboration provider as seen on editor.options. */ +interface CollaborationProvider { + synced?: boolean; + isSynced?: boolean; + destroy?: () => void; +} + +/** + * Tracks providers that have ever reached a synced state. + * + * Once a provider syncs, it stays "has ever synced" even if `synced` flips + * back to `false` during a transient disconnect. This prevents a race where + * a temporary network blip re-opens the mutation gate. + */ +const everSyncedProviders = new WeakSet(); + +/** Returns `true` if the provider is currently reporting a synced state. */ +function isProviderCurrentlySynced(provider: CollaborationProvider): boolean { + return provider.synced === true || provider.isSynced === true; +} + +/** + * Returns `true` when collaboration is active and out-of-band mutations + * should be blocked. + */ +export function isCollaborationActive(editor: Editor): boolean { + const provider = (editor.options as { collaborationProvider?: CollaborationProvider | null }).collaborationProvider; + + if (!provider) return false; + + // Latch: once synced, always considered active for this provider instance. + if (isProviderCurrentlySynced(provider)) { + everSyncedProviders.add(provider); + return true; + } + + return everSyncedProviders.has(provider); +} diff --git a/packages/super-editor/src/document-api-adapters/index.ts b/packages/super-editor/src/document-api-adapters/index.ts index 51885a222f..6453611cb2 100644 --- a/packages/super-editor/src/document-api-adapters/index.ts +++ b/packages/super-editor/src/document-api-adapters/index.ts @@ -19,6 +19,7 @@ import { formatColorWrapper, formatAlignWrapper, } from './plan-engine/format-value-wrappers.js'; +import { stylesApplyAdapter } from './styles-adapter.js'; import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; import { getTextAdapter } from './get-text-adapter.js'; import { infoAdapter } from './info-adapter.js'; @@ -87,6 +88,9 @@ export function getDocumentApiAdapters(editor: Editor): DocumentApiAdapters { color: (input, options) => formatColorWrapper(editor, input, options), align: (input, options) => formatAlignWrapper(editor, input, options), }, + styles: { + apply: (input, options) => stylesApplyAdapter(editor, input, options), + }, trackChanges: { list: (query) => trackChangesListWrapper(editor, query), get: (input) => trackChangesGetWrapper(editor, input), diff --git a/packages/super-editor/src/document-api-adapters/out-of-band-mutation.test.ts b/packages/super-editor/src/document-api-adapters/out-of-band-mutation.test.ts new file mode 100644 index 0000000000..1727306412 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/out-of-band-mutation.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { executeOutOfBandMutation, type OutOfBandMutationResult } from './out-of-band-mutation.js'; + +// --------------------------------------------------------------------------- +// Mock editor with revision tracking support +// --------------------------------------------------------------------------- + +function createMockEditor(opts: { initialRevision?: number; guid?: string | null } = {}) { + const converter = { + documentModified: false, + documentGuid: 'guid' in opts ? opts.guid : 'test-guid', + promoteToGuid: vi.fn(() => 'promoted-guid'), + }; + + // Simulate the revision tracker's WeakMap by storing revision on the object + const editor = { + converter, + options: {}, + on: vi.fn(), + _revision: opts.initialRevision ?? 0, + }; + + return editor; +} + +// --------------------------------------------------------------------------- +// We need to mock the revision tracker module +// --------------------------------------------------------------------------- + +vi.mock('./plan-engine/revision-tracker.js', () => { + return { + checkRevision: vi.fn((editor: { _revision: number }, expected: string | undefined) => { + if (expected === undefined) return; + if (expected !== String(editor._revision)) { + throw Object.assign(new Error(`REVISION_MISMATCH — expected "${expected}" but at "${editor._revision}"`), { + code: 'REVISION_MISMATCH', + }); + } + }), + incrementRevision: vi.fn((editor: { _revision: number }) => { + editor._revision += 1; + return String(editor._revision); + }), + }; +}); + +describe('executeOutOfBandMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('runs revision guard before mutateFn', () => { + const editor = createMockEditor({ initialRevision: 5 }); + const mutateFn = vi.fn((): OutOfBandMutationResult => ({ changed: true, payload: 'ok' })); + + expect(() => executeOutOfBandMutation(editor as never, mutateFn, { dryRun: false, expectedRevision: '3' })).toThrow( + /REVISION_MISMATCH/, + ); + + // mutateFn should NOT have been called + expect(mutateFn).not.toHaveBeenCalled(); + }); + + it('passes dryRun flag to mutateFn', () => { + const editor = createMockEditor(); + const mutateFn = vi.fn((dryRun: boolean): OutOfBandMutationResult => ({ changed: true, payload: dryRun })); + + const result = executeOutOfBandMutation(editor as never, mutateFn, { dryRun: true, expectedRevision: undefined }); + expect(result).toBe(true); // payload is the dryRun flag + expect(mutateFn).toHaveBeenCalledWith(true); + }); + + it('skips dirty/GUID/revision on dryRun even when changed: true', () => { + const editor = createMockEditor(); + const mutateFn = vi.fn((): OutOfBandMutationResult => ({ changed: true, payload: 'ok' })); + + executeOutOfBandMutation(editor as never, mutateFn, { dryRun: true, expectedRevision: undefined }); + + expect(editor.converter.documentModified).toBe(false); + expect(editor._revision).toBe(0); + }); + + it('marks dirty, promotes GUID, increments revision on real mutation with changed: true', () => { + const editor = createMockEditor({ guid: null }); + const mutateFn = vi.fn((): OutOfBandMutationResult => ({ changed: true, payload: 'ok' })); + + executeOutOfBandMutation(editor as never, mutateFn, { dryRun: false, expectedRevision: undefined }); + + expect(editor.converter.documentModified).toBe(true); + expect(editor.converter.promoteToGuid).toHaveBeenCalled(); + expect(editor._revision).toBe(1); + }); + + it('does not promote GUID when one already exists', () => { + const editor = createMockEditor({ guid: 'existing' }); + const mutateFn = vi.fn((): OutOfBandMutationResult => ({ changed: true, payload: 'ok' })); + + executeOutOfBandMutation(editor as never, mutateFn, { dryRun: false, expectedRevision: undefined }); + + expect(editor.converter.promoteToGuid).not.toHaveBeenCalled(); + expect(editor.converter.documentModified).toBe(true); + }); + + it('skips dirty/GUID/revision when mutateFn returns changed: false', () => { + const editor = createMockEditor({ guid: null }); + const mutateFn = vi.fn((): OutOfBandMutationResult => ({ changed: false, payload: 'no-op' })); + + const result = executeOutOfBandMutation(editor as never, mutateFn, { + dryRun: false, + expectedRevision: undefined, + }); + + expect(result).toBe('no-op'); + expect(editor.converter.documentModified).toBe(false); + expect(editor.converter.promoteToGuid).not.toHaveBeenCalled(); + expect(editor._revision).toBe(0); + }); + + it('returns the payload from mutateFn', () => { + const editor = createMockEditor(); + const mutateFn = vi.fn( + (): OutOfBandMutationResult<{ receipt: string }> => ({ + changed: true, + payload: { receipt: 'data' }, + }), + ); + + const result = executeOutOfBandMutation(editor as never, mutateFn, { + dryRun: false, + expectedRevision: undefined, + }); + + expect(result).toEqual({ receipt: 'data' }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/out-of-band-mutation.ts b/packages/super-editor/src/document-api-adapters/out-of-band-mutation.ts new file mode 100644 index 0000000000..4e4e40f85b --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/out-of-band-mutation.ts @@ -0,0 +1,73 @@ +/** + * Shared lifecycle primitive for non-PM-command mutations. + * + * Operations that mutate OOXML parts directly (e.g., `styles.apply` on + * `word/styles.xml`) cannot dispatch PM transactions. This primitive + * ensures the required lifecycle steps execute in the correct order: + * + * 1. Revision guard (`checkRevision`) + * 2. Execute `mutateFn` (which reads/writes XML and returns a result + changed flag) + * 3. If `changed` and not `dryRun`: mark dirty, promote GUID, increment revision + * + * Future non-PM operations (e.g., numbering-part mutations) reuse this + * primitive without reimplementing lifecycle steps. + */ + +import type { Editor } from '../core/Editor.js'; +import { checkRevision, incrementRevision } from './plan-engine/revision-tracker.js'; + +/** Converter shape accessed from the editor for dirty marking and GUID promotion. */ +interface ConverterForMutation { + documentModified: boolean; + documentGuid: string | null; + promoteToGuid(): string; +} + +/** Result returned by the mutation function passed to `executeOutOfBandMutation`. */ +export interface OutOfBandMutationResult { + /** Whether the XML was actually changed (false = no-op, skip lifecycle side effects). */ + changed: boolean; + /** The operation-specific payload (e.g., receipt data). */ + payload: T; +} + +/** Options passed to `executeOutOfBandMutation`. */ +export interface OutOfBandMutationOptions { + dryRun: boolean; + expectedRevision: string | undefined; +} + +/** + * Executes a non-PM mutation with correct lifecycle ordering. + * + * @param editor - The editor instance providing converter access. + * @param mutateFn - The mutation function. Called with `dryRun` so it can + * skip writes when previewing. Must return `changed` flag. + * @param options - dryRun and expectedRevision. + * @returns - The payload from `mutateFn`. + */ +export function executeOutOfBandMutation( + editor: Editor, + mutateFn: (dryRun: boolean) => OutOfBandMutationResult, + options: OutOfBandMutationOptions, +): T { + // Step 1: Revision guard (throws REVISION_MISMATCH if stale) + checkRevision(editor, options.expectedRevision); + + // Step 2: Execute the mutation (or read-only preview for dryRun) + const result = mutateFn(options.dryRun); + + // Step 3: Lifecycle side effects (only on real, state-changing mutations) + if (result.changed && !options.dryRun) { + const converter = (editor as unknown as { converter?: ConverterForMutation }).converter; + if (converter) { + converter.documentModified = true; + if (!converter.documentGuid) { + converter.promoteToGuid(); + } + } + incrementRevision(editor); + } + + return result.payload; +} diff --git a/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts new file mode 100644 index 0000000000..f72e4cf8ea --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts @@ -0,0 +1,450 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { StylesApplyInput, NormalizedStylesApplyOptions } from '@superdoc/document-api'; +import { stylesApplyAdapter } from './styles-adapter.js'; +import { DocumentApiAdapterError } from './errors.js'; + +// --------------------------------------------------------------------------- +// Mock editor factory +// --------------------------------------------------------------------------- + +interface XmlElement { + name: string; + elements?: XmlElement[]; + attributes?: Record; +} + +interface MockEditorOptions { + stylesXml?: XmlElement; + noConverter?: boolean; + collaborationProvider?: { synced?: boolean; isSynced?: boolean } | null; +} + +function createMockEditor(opts: MockEditorOptions = {}) { + const convertedXml: Record = {}; + if (opts.stylesXml) { + convertedXml['word/styles.xml'] = opts.stylesXml; + } + + const converter = opts.noConverter + ? undefined + : { + convertedXml, + documentModified: false, + documentGuid: 'existing-guid', + promoteToGuid: vi.fn(() => 'new-guid'), + }; + + return { + converter, + options: { + collaborationProvider: opts.collaborationProvider ?? null, + }, + on: vi.fn(), + } as unknown as Parameters[0]; +} + +function makeStylesXml(...rPrChildren: XmlElement[]): XmlElement { + return { + name: 'root', + elements: [ + { + name: 'w:styles', + elements: [ + { + name: 'w:docDefaults', + elements: [ + { + name: 'w:rPrDefault', + elements: [ + { + name: 'w:rPr', + elements: rPrChildren, + }, + ], + }, + ], + }, + ], + }, + ], + }; +} + +function makeMinimalStylesXml(): XmlElement { + return { + name: 'root', + elements: [{ name: 'w:styles', elements: [] }], + }; +} + +const VALID_INPUT: StylesApplyInput = { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { bold: true }, +}; + +const DEFAULT_OPTIONS: NormalizedStylesApplyOptions = { + dryRun: false, + expectedRevision: undefined, +}; + +// --------------------------------------------------------------------------- +// Helper to get rPr from the mock styles XML +// --------------------------------------------------------------------------- + +function getRPrElements(editor: ReturnType): XmlElement[] | undefined { + const converter = (editor as unknown as { converter?: { convertedXml: Record } }).converter; + const stylesRoot = converter?.convertedXml['word/styles.xml']?.elements?.find( + (el: XmlElement) => el.name === 'w:styles', + ); + const docDefaults = stylesRoot?.elements?.find((el: XmlElement) => el.name === 'w:docDefaults'); + const rPrDefault = docDefaults?.elements?.find((el: XmlElement) => el.name === 'w:rPrDefault'); + const rPr = rPrDefault?.elements?.find((el: XmlElement) => el.name === 'w:rPr'); + return rPr?.elements; +} + +// --------------------------------------------------------------------------- +// Capability gate tests +// --------------------------------------------------------------------------- + +describe('styles adapter: capability gates', () => { + it('throws CAPABILITY_UNAVAILABLE when converter is missing', () => { + const editor = createMockEditor({ noConverter: true }); + expect(() => stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS)).toThrow(DocumentApiAdapterError); + try { + stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + } catch (e) { + expect((e as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); + } + }); + + it('throws CAPABILITY_UNAVAILABLE when word/styles.xml is missing', () => { + const editor = createMockEditor(); + expect(() => stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS)).toThrow(DocumentApiAdapterError); + try { + stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + } catch (e) { + expect((e as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); + expect((e as DocumentApiAdapterError).message).toMatch(/word\/styles\.xml/); + } + }); + + it('throws CAPABILITY_UNAVAILABLE when collaboration is active', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + collaborationProvider: { synced: true }, + }); + expect(() => stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS)).toThrow(DocumentApiAdapterError); + try { + stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + } catch (e) { + expect((e as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); + expect((e as DocumentApiAdapterError).message).toMatch(/collaboration/); + } + }); + + it('allows mutation when collaboration provider is not synced (pre-initial-sync)', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + collaborationProvider: { synced: false }, + }); + const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + expect(result.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Bold write tests +// --------------------------------------------------------------------------- + +describe('styles adapter: bold mutation', () => { + it('writes for patch.bold: true', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(true); + expect(result.before.bold).toBe('inherit'); + expect(result.after.bold).toBe('on'); + } + + const elements = getRPrElements(editor); + const boldEl = elements?.find((el) => el.name === 'w:b'); + expect(boldEl).toBeDefined(); + expect(boldEl?.attributes).toBeUndefined(); + }); + + it('writes for patch.bold: false', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const input: StylesApplyInput = { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { bold: false }, + }; + const result = stylesApplyAdapter(editor, input, DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(true); + expect(result.before.bold).toBe('inherit'); + expect(result.after.bold).toBe('off'); + } + + const elements = getRPrElements(editor); + const boldEl = elements?.find((el) => el.name === 'w:b'); + expect(boldEl).toBeDefined(); + expect(boldEl?.attributes?.['w:val']).toBe('0'); + }); + + it('detects inherit state when is absent', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml({ name: 'w:i' }), // italic only, no bold + }); + const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + if (result.success) { + expect(result.before.bold).toBe('inherit'); + } + }); +}); + +// --------------------------------------------------------------------------- +// OOXML boolean read normalization +// --------------------------------------------------------------------------- + +describe('styles adapter: OOXML boolean normalization', () => { + const normalizeTestCases: Array<{ desc: string; element: XmlElement; expected: 'on' | 'off' }> = [ + { desc: 'bare ', element: { name: 'w:b' }, expected: 'on' }, + { desc: '', element: { name: 'w:b', attributes: { 'w:val': '1' } }, expected: 'on' }, + { desc: '', element: { name: 'w:b', attributes: { 'w:val': 'true' } }, expected: 'on' }, + { desc: '', element: { name: 'w:b', attributes: { 'w:val': 'on' } }, expected: 'on' }, + { desc: '', element: { name: 'w:b', attributes: { 'w:val': '0' } }, expected: 'off' }, + { desc: '', element: { name: 'w:b', attributes: { 'w:val': 'false' } }, expected: 'off' }, + { desc: '', element: { name: 'w:b', attributes: { 'w:val': 'off' } }, expected: 'off' }, + ]; + + for (const { desc, element, expected } of normalizeTestCases) { + it(`reads ${desc} as "${expected}"`, () => { + const editor = createMockEditor({ stylesXml: makeStylesXml(element) }); + // Read with dryRun to avoid mutation + const result = stylesApplyAdapter( + editor, + { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true } }, + { dryRun: true, expectedRevision: undefined }, + ); + if (result.success) { + expect(result.before.bold).toBe(expected); + } + }); + } +}); + +// --------------------------------------------------------------------------- +// No-op semantics +// --------------------------------------------------------------------------- + +describe('styles adapter: no-op semantics', () => { + it('returns changed: false when patch.bold: true and already exists', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml({ name: 'w:b' }) }); + const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(false); + expect(result.before.bold).toBe('on'); + expect(result.after.bold).toBe('on'); + } + }); + + it('returns changed: false when patch.bold: false and already exists', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml({ name: 'w:b', attributes: { 'w:val': '0' } }), + }); + const input: StylesApplyInput = { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { bold: false }, + }; + const result = stylesApplyAdapter(editor, input, DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(false); + expect(result.before.bold).toBe('off'); + expect(result.after.bold).toBe('off'); + } + }); + + it('does not mark converter as modified on no-op', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml({ name: 'w:b' }) }); + const converter = (editor as unknown as { converter: { documentModified: boolean } }).converter; + stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + expect(converter.documentModified).toBe(false); + }); + + it('repeated identical calls produce identical receipts', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const r1 = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + const r2 = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + // Second call is a no-op + expect(r2.success).toBe(true); + if (r1.success && r2.success) { + expect(r2.changed).toBe(false); + expect(r2.before).toEqual(r2.after); + } + }); +}); + +// --------------------------------------------------------------------------- +// dryRun semantics +// --------------------------------------------------------------------------- + +describe('styles adapter: dryRun', () => { + it('returns predicted after state without mutating XML', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const options: NormalizedStylesApplyOptions = { dryRun: true, expectedRevision: undefined }; + const result = stylesApplyAdapter(editor, VALID_INPUT, options); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.dryRun).toBe(true); + expect(result.before.bold).toBe('inherit'); + expect(result.after.bold).toBe('on'); + expect(result.changed).toBe(true); + } + + // Verify XML was not changed + const elements = getRPrElements(editor); + const boldEl = elements?.find((el) => el.name === 'w:b'); + expect(boldEl).toBeUndefined(); + }); + + it('does not mark converter as modified on dryRun', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const converter = (editor as unknown as { converter: { documentModified: boolean } }).converter; + const options: NormalizedStylesApplyOptions = { dryRun: true, expectedRevision: undefined }; + stylesApplyAdapter(editor, VALID_INPUT, options); + expect(converter.documentModified).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Create-if-missing paths +// --------------------------------------------------------------------------- + +describe('styles adapter: create-if-missing nodes', () => { + it('creates w:docDefaults, w:rPrDefault, w:rPr when missing', () => { + const editor = createMockEditor({ stylesXml: makeMinimalStylesXml() }); + const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(true); + expect(result.after.bold).toBe('on'); + } + + // Verify the full path was created + const elements = getRPrElements(editor); + expect(elements).toBeDefined(); + const boldEl = elements?.find((el) => el.name === 'w:b'); + expect(boldEl).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Malformed XML canonicalization +// --------------------------------------------------------------------------- + +describe('styles adapter: malformed XML canonicalization', () => { + it('reads last when duplicates exist', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml( + { name: 'w:b', attributes: { 'w:val': '0' } }, // first: off + { name: 'w:b' }, // last: on (wins) + ), + }); + const result = stylesApplyAdapter( + editor, + { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true } }, + { dryRun: true, expectedRevision: undefined }, + ); + if (result.success) { + expect(result.before.bold).toBe('on'); + } + }); + + it('normalizes to exactly one on write (removes duplicates)', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml({ name: 'w:b', attributes: { 'w:val': '0' } }, { name: 'w:b' }), + }); + stylesApplyAdapter( + editor, + { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: false } }, + DEFAULT_OPTIONS, + ); + + const elements = getRPrElements(editor); + const boldElements = elements?.filter((el) => el.name === 'w:b'); + expect(boldElements?.length).toBe(1); + expect(boldElements?.[0].attributes?.['w:val']).toBe('0'); + }); + + it('normalizes mixed val form (w:val="true") to canonical form on mutation', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml({ name: 'w:b', attributes: { 'w:val': 'true' } }), + }); + // Applying bold: true should not change state but should canonicalize + const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + if (result.success) { + expect(result.before.bold).toBe('on'); + // Same state — no change needed + expect(result.changed).toBe(false); + } + }); + + it('preserves unknown sibling elements in w:rPr', () => { + const italicEl: XmlElement = { name: 'w:i' }; + const szEl: XmlElement = { name: 'w:sz', attributes: { 'w:val': '24' } }; + const editor = createMockEditor({ + stylesXml: makeStylesXml(italicEl, szEl), + }); + stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + + const elements = getRPrElements(editor); + const names = elements?.map((el) => el.name); + expect(names).toContain('w:i'); + expect(names).toContain('w:sz'); + expect(names).toContain('w:b'); + }); + + it('produces deterministic ordering on repeated calls', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml({ name: 'w:i' }), + }); + stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + const elements1 = getRPrElements(editor)?.map((el) => el.name); + + // Call again (no-op) + stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + const elements2 = getRPrElements(editor)?.map((el) => el.name); + + expect(elements1).toEqual(elements2); + }); +}); + +// --------------------------------------------------------------------------- +// Resolution metadata +// --------------------------------------------------------------------------- + +describe('styles adapter: resolution metadata', () => { + it('returns correct resolution on success', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + expect(result.success).toBe(true); + if (result.success) { + expect(result.resolution).toEqual({ + scope: 'docDefaults', + channel: 'run', + xmlPart: 'word/styles.xml', + xmlPath: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', + }); + } + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/styles-adapter.ts b/packages/super-editor/src/document-api-adapters/styles-adapter.ts new file mode 100644 index 0000000000..b11352d21c --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.ts @@ -0,0 +1,233 @@ +/** + * Engine-specific adapter for `styles.apply`. + * + * Mutates `word/styles.xml` (docDefaults run properties) directly via the + * converter's in-memory XML-JS representation. Does NOT use PM commands or + * transactions — lifecycle is handled by `executeOutOfBandMutation`. + */ + +import type { + StylesApplyInput, + StylesApplyReceipt, + StylesBooleanState, + StylesTargetResolution, + NormalizedStylesApplyOptions, +} from '@superdoc/document-api'; +import type { Editor } from '../core/Editor.js'; +import { DocumentApiAdapterError } from './errors.js'; +import { isCollaborationActive } from './collaboration-detection.js'; +import { executeOutOfBandMutation } from './out-of-band-mutation.js'; + +// --------------------------------------------------------------------------- +// XML-JS element shape (subset used by this adapter) +// --------------------------------------------------------------------------- + +interface XmlElement { + name: string; + elements?: XmlElement[]; + attributes?: Record; +} + +/** Converter shape accessed from the editor. */ +interface ConverterForStyles { + convertedXml: Record; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const STYLES_PART = 'word/styles.xml'; + +const DOC_DEFAULTS_RESOLUTION: StylesTargetResolution = { + scope: 'docDefaults', + channel: 'run', + xmlPart: STYLES_PART, + xmlPath: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', +}; + +/** + * OOXML boolean "truthy" values (element present with no val, or val = 1/true/on). + * All other val values are treated as "off". + */ +const OOXML_BOOLEAN_ON_VALUES = new Set(['1', 'true', 'on']); + +// --------------------------------------------------------------------------- +// OOXML boolean read/write helpers +// --------------------------------------------------------------------------- + +/** + * Reads the tri-state of a boolean OOXML element within `w:rPr`. + * + * Per § Malformed-XML Canonicalization, when duplicate elements exist the + * **last** one wins (matching Word's behavior). + */ +function readBooleanState(rPr: XmlElement, elementName: string): StylesBooleanState { + if (!rPr.elements) return 'inherit'; + + // Find all matching elements; last one wins. + let lastMatch: XmlElement | undefined; + for (const el of rPr.elements) { + if (el.name === elementName) lastMatch = el; + } + + if (!lastMatch) return 'inherit'; + return normalizeBooleanElement(lastMatch); +} + +/** + * Normalizes a single OOXML boolean element to a `StylesBooleanState`. + */ +function normalizeBooleanElement(el: XmlElement): StylesBooleanState { + const val = el.attributes?.['w:val']; + // Bare element (no w:val attribute) means "on" + if (val === undefined) return 'on'; + return OOXML_BOOLEAN_ON_VALUES.has(val) ? 'on' : 'off'; +} + +/** + * Writes a boolean property to `w:rPr`, replacing any existing instances. + * + * - `true` → single `` (removes duplicates, normalizes val) + * - `false` → single `` (removes duplicates, normalizes val) + * + * Unknown sibling elements are preserved and not reordered. + */ +function writeBooleanProperty(rPr: XmlElement, elementName: string, value: boolean): void { + if (!rPr.elements) rPr.elements = []; + + // Remove all existing instances of this element + rPr.elements = rPr.elements.filter((el) => el.name !== elementName); + + // Build the canonical element + const newElement: XmlElement = { name: elementName }; + if (!value) { + newElement.attributes = { 'w:val': '0' }; + } + + // Insert at the beginning (deterministic position for repeated calls) + rPr.elements.unshift(newElement); +} + +// --------------------------------------------------------------------------- +// XML traversal helpers +// --------------------------------------------------------------------------- + +/** + * Finds a direct child element by name, optionally creating it if missing. + */ +function findOrCreateChild(parent: XmlElement, childName: string): XmlElement { + if (!parent.elements) parent.elements = []; + + const existing = parent.elements.find((el) => el.name === childName); + if (existing) return existing; + + const child: XmlElement = { name: childName }; + parent.elements.push(child); + return child; +} + +/** + * Resolves the `w:rPr` element inside `w:styles/w:docDefaults/w:rPrDefault`, + * creating intermediate nodes as needed within the existing styles part. + */ +function resolveDocDefaultsRunProperties(stylesRoot: XmlElement): XmlElement { + const docDefaults = findOrCreateChild(stylesRoot, 'w:docDefaults'); + const rPrDefault = findOrCreateChild(docDefaults, 'w:rPrDefault'); + return findOrCreateChild(rPrDefault, 'w:rPr'); +} + +// --------------------------------------------------------------------------- +// Adapter entry point +// --------------------------------------------------------------------------- + +/** + * Adapter function for `styles.apply` bound to a specific editor instance. + * + * Called by the document-api dispatch layer after input validation. + */ +export function stylesApplyAdapter( + editor: Editor, + input: StylesApplyInput, + options: NormalizedStylesApplyOptions, +): StylesApplyReceipt { + // --- Capability gates (throw before mutation) --- + const converter = (editor as unknown as { converter?: ConverterForStyles }).converter; + if (!converter) { + throw new DocumentApiAdapterError('CAPABILITY_UNAVAILABLE', 'styles.apply requires a document converter.', { + reason: 'converter_missing', + }); + } + + const stylesPart = converter.convertedXml[STYLES_PART]; + if (!stylesPart) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'styles.apply requires word/styles.xml to be present in the document package.', + { reason: 'styles_part_missing' }, + ); + } + + if (isCollaborationActive(editor)) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'styles.apply is unavailable during active collaboration. Stylesheet mutations cannot be synced via Yjs.', + { reason: 'collaboration_active' }, + ); + } + + // --- Resolve the XML target --- + const stylesRoot = stylesPart.elements?.find((el: XmlElement) => el.name === 'w:styles'); + if (!stylesRoot) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'word/styles.xml does not contain a w:styles root element.', + { reason: 'styles_root_missing' }, + ); + } + + // --- Execute via out-of-band lifecycle --- + return executeOutOfBandMutation( + editor, + (dryRun) => { + const rPr = resolveDocDefaultsRunProperties(stylesRoot); + + // Read before-state + const beforeBold = readBooleanState(rPr, 'w:b'); + const before = { bold: beforeBold }; + + // Compute after-state from patch + const afterBold = computeAfterState(beforeBold, input.patch.bold); + const after = { bold: afterBold }; + + const changed = beforeBold !== afterBold; + + // Apply mutation (skip on dryRun or no-op) + if (changed && !dryRun) { + if (input.patch.bold !== undefined) { + writeBooleanProperty(rPr, 'w:b', input.patch.bold); + } + } + + const receipt: StylesApplyReceipt = { + success: true, + changed, + resolution: DOC_DEFAULTS_RESOLUTION, + dryRun, + before, + after, + }; + + return { changed, payload: receipt }; + }, + options, + ); +} + +/** + * Computes the predicted after-state for a single boolean property. + */ +function computeAfterState(currentState: StylesBooleanState, patchValue: boolean | undefined): StylesBooleanState { + if (patchValue === undefined) return currentState; + return patchValue ? 'on' : 'off'; +} From 66268ef0d32b715b71518af7855e8fa0c49cf1e4 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 20:01:29 -0800 Subject: [PATCH 2/5] fix(document-api): prevent XML node creation during dry-run styles.apply --- .../styles-adapter.test.ts | 22 +++++++++++++++ .../document-api-adapters/styles-adapter.ts | 27 ++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts index f72e4cf8ea..0ce738f067 100644 --- a/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts @@ -323,6 +323,28 @@ describe('styles adapter: dryRun', () => { stylesApplyAdapter(editor, VALID_INPUT, options); expect(converter.documentModified).toBe(false); }); + + it('does not create scaffolding nodes when docDefaults path is absent', () => { + const editor = createMockEditor({ stylesXml: makeMinimalStylesXml() }); + const options: NormalizedStylesApplyOptions = { dryRun: true, expectedRevision: undefined }; + const result = stylesApplyAdapter(editor, VALID_INPUT, options); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.dryRun).toBe(true); + expect(result.before.bold).toBe('inherit'); + expect(result.after.bold).toBe('on'); + expect(result.changed).toBe(true); + } + + // The XML tree must remain untouched — no w:docDefaults should have been created. + const converter = (editor as unknown as { converter: { convertedXml: Record } }).converter; + const stylesRoot = converter.convertedXml['word/styles.xml'].elements?.find( + (el: XmlElement) => el.name === 'w:styles', + ); + const docDefaults = stylesRoot?.elements?.find((el: XmlElement) => el.name === 'w:docDefaults'); + expect(docDefaults).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/document-api-adapters/styles-adapter.ts b/packages/super-editor/src/document-api-adapters/styles-adapter.ts index b11352d21c..935f77564a 100644 --- a/packages/super-editor/src/document-api-adapters/styles-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.ts @@ -127,6 +127,11 @@ function findOrCreateChild(parent: XmlElement, childName: string): XmlElement { return child; } +/** Finds a direct child element by name, returning `undefined` if missing. */ +function findChild(parent: XmlElement, childName: string): XmlElement | undefined { + return parent.elements?.find((el) => el.name === childName); +} + /** * Resolves the `w:rPr` element inside `w:styles/w:docDefaults/w:rPrDefault`, * creating intermediate nodes as needed within the existing styles part. @@ -137,6 +142,18 @@ function resolveDocDefaultsRunProperties(stylesRoot: XmlElement): XmlElement { return findOrCreateChild(rPrDefault, 'w:rPr'); } +/** + * Read-only traversal of `w:styles/w:docDefaults/w:rPrDefault/w:rPr`. + * Returns `undefined` when any node in the path is absent — no mutation. + */ +function findDocDefaultsRunProperties(stylesRoot: XmlElement): XmlElement | undefined { + const docDefaults = findChild(stylesRoot, 'w:docDefaults'); + if (!docDefaults) return undefined; + const rPrDefault = findChild(docDefaults, 'w:rPrDefault'); + if (!rPrDefault) return undefined; + return findChild(rPrDefault, 'w:rPr'); +} + // --------------------------------------------------------------------------- // Adapter entry point // --------------------------------------------------------------------------- @@ -190,10 +207,10 @@ export function stylesApplyAdapter( return executeOutOfBandMutation( editor, (dryRun) => { - const rPr = resolveDocDefaultsRunProperties(stylesRoot); - - // Read before-state - const beforeBold = readBooleanState(rPr, 'w:b'); + // Read before-state. Use the non-mutating traversal so dry-run + // never creates scaffolding nodes in the XML tree. + const existingRPr = findDocDefaultsRunProperties(stylesRoot); + const beforeBold = existingRPr ? readBooleanState(existingRPr, 'w:b') : 'inherit'; const before = { bold: beforeBold }; // Compute after-state from patch @@ -204,6 +221,8 @@ export function stylesApplyAdapter( // Apply mutation (skip on dryRun or no-op) if (changed && !dryRun) { + // Only now create scaffolding — we know a real write will follow. + const rPr = resolveDocDefaultsRunProperties(stylesRoot); if (input.patch.bold !== undefined) { writeBooleanProperty(rPr, 'w:b', input.patch.bold); } From a59fb4a4b0a488f534b71b7c345a4ba5a530bf8d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 22:01:22 -0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(document-api):=20wave=201=20styles.app?= =?UTF-8?q?ly=20=E2=80=94=20adapter=20pivot,=20paragraph=20channel,=20all?= =?UTF-8?q?=20properties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reference/_generated-manifest.json | 2 +- .../document-api/reference/styles/apply.mdx | 644 ++++++++++++++++-- packages/document-api/src/contract/schemas.ts | 165 ++++- packages/document-api/src/index.ts | 15 +- .../document-api/src/styles/styles.test.ts | 397 +++++++++-- packages/document-api/src/styles/styles.ts | 356 +++++++++- .../presentation-editor/PresentationEditor.ts | 13 + .../contract-conformance.test.ts | 2 + .../styles-adapter.test.ts | 608 ++++++++++------- .../document-api-adapters/styles-adapter.ts | 261 +++---- .../document-api-adapters/styles-xml-sync.ts | 80 +++ 11 files changed, 2051 insertions(+), 492 deletions(-) create mode 100644 packages/super-editor/src/document-api-adapters/styles-xml-sync.ts diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 4df8a3efb7..ac363cd00f 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -141,5 +141,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "f9d12120c152cb1bfc33ab599262bbc34d6f1b55a803242c2dceb5d4e09022e5" + "sourceHash": "d5b584c9762004cd89a0af9a3068c83a36049453f26a0e80232cfd51654e32a3" } diff --git a/apps/docs/document-api/reference/styles/apply.mdx b/apps/docs/document-api/reference/styles/apply.mdx index 47f40cef0b..8efef68a3c 100644 --- a/apps/docs/document-api/reference/styles/apply.mdx +++ b/apps/docs/document-api/reference/styles/apply.mdx @@ -20,17 +20,15 @@ description: Reference for styles.apply ## Input fields -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `patch` | object | yes | | -| `target` | object(scope="docDefaults") | yes | | +_No fields._ ### Example request ```json { "patch": { - "bold": true + "bold": true, + "italic": true }, "target": { "channel": "run", @@ -48,10 +46,12 @@ _No fields._ ```json { "after": { - "bold": "on" + "bold": "on", + "italic": "on" }, "before": { - "bold": "on" + "bold": "on", + "italic": "on" }, "changed": true, "dryRun": true, @@ -81,40 +81,232 @@ _No fields._ ```json { - "additionalProperties": false, - "properties": { - "patch": { + "oneOf": [ + { "additionalProperties": false, - "minProperties": 1, "properties": { - "bold": { - "type": "boolean" + "patch": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bold": { + "type": "boolean" + }, + "color": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "themeColor": { + "type": "string" + }, + "themeShade": { + "type": "string" + }, + "themeTint": { + "type": "string" + }, + "val": { + "type": "string" + } + }, + "type": "object" + }, + "fontFamily": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "ascii": { + "type": "string" + }, + "asciiTheme": { + "type": "string" + }, + "cs": { + "type": "string" + }, + "cstheme": { + "type": "string" + }, + "eastAsia": { + "type": "string" + }, + "eastAsiaTheme": { + "type": "string" + }, + "hAnsi": { + "type": "string" + }, + "hAnsiTheme": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "val": { + "type": "string" + } + }, + "type": "object" + }, + "fontSize": { + "type": "integer" + }, + "fontSizeCs": { + "type": "integer" + }, + "italic": { + "type": "boolean" + }, + "letterSpacing": { + "type": "integer" + } + }, + "type": "object" + }, + "target": { + "additionalProperties": false, + "properties": { + "channel": { + "const": "run" + }, + "scope": { + "const": "docDefaults" + } + }, + "required": [ + "scope", + "channel" + ], + "type": "object" } }, + "required": [ + "target", + "patch" + ], "type": "object" }, - "target": { + { "additionalProperties": false, "properties": { - "channel": { - "const": "run" + "patch": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "indent": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "end": { + "type": "integer" + }, + "endChars": { + "type": "integer" + }, + "firstLine": { + "type": "integer" + }, + "firstLineChars": { + "type": "integer" + }, + "hanging": { + "type": "integer" + }, + "hangingChars": { + "type": "integer" + }, + "left": { + "type": "integer" + }, + "leftChars": { + "type": "integer" + }, + "right": { + "type": "integer" + }, + "rightChars": { + "type": "integer" + }, + "start": { + "type": "integer" + }, + "startChars": { + "type": "integer" + } + }, + "type": "object" + }, + "justification": { + "enum": [ + "left", + "center", + "right", + "justify", + "distribute" + ] + }, + "spacing": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "after": { + "type": "integer" + }, + "afterAutospacing": { + "type": "boolean" + }, + "afterLines": { + "type": "integer" + }, + "before": { + "type": "integer" + }, + "beforeAutospacing": { + "type": "boolean" + }, + "beforeLines": { + "type": "integer" + }, + "line": { + "type": "integer" + }, + "lineRule": { + "enum": [ + "auto", + "exact", + "atLeast" + ] + } + }, + "type": "object" + } + }, + "type": "object" }, - "scope": { - "const": "docDefaults" + "target": { + "additionalProperties": false, + "properties": { + "channel": { + "const": "paragraph" + }, + "scope": { + "const": "docDefaults" + } + }, + "required": [ + "scope", + "channel" + ], + "type": "object" } }, "required": [ - "scope", - "channel" + "target", + "patch" ], "type": "object" } - }, - "required": [ - "target", - "patch" - ], - "type": "object" + ] } ``` @@ -135,11 +327,95 @@ _No fields._ "off", "inherit" ] + }, + "color": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontFamily": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "indent": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "italic": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "justification": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "spacing": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] } }, - "required": [ - "bold" - ], "type": "object" }, "before": { @@ -151,11 +427,95 @@ _No fields._ "off", "inherit" ] + }, + "color": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontFamily": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "indent": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "italic": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "justification": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "spacing": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] } }, - "required": [ - "bold" - ], "type": "object" }, "changed": { @@ -168,7 +528,10 @@ _No fields._ "additionalProperties": false, "properties": { "channel": { - "const": "run" + "enum": [ + "run", + "paragraph" + ] }, "scope": { "const": "docDefaults" @@ -177,7 +540,10 @@ _No fields._ "const": "word/styles.xml" }, "xmlPath": { - "const": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + "enum": [ + "w:styles/w:docDefaults/w:rPrDefault/w:rPr", + "w:styles/w:docDefaults/w:pPrDefault/w:pPr" + ] } }, "required": [ @@ -226,7 +592,10 @@ _No fields._ "additionalProperties": false, "properties": { "channel": { - "const": "run" + "enum": [ + "run", + "paragraph" + ] }, "scope": { "const": "docDefaults" @@ -235,7 +604,10 @@ _No fields._ "const": "word/styles.xml" }, "xmlPath": { - "const": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + "enum": [ + "w:styles/w:docDefaults/w:rPrDefault/w:rPr", + "w:styles/w:docDefaults/w:pPrDefault/w:pPr" + ] } }, "required": [ @@ -276,11 +648,95 @@ _No fields._ "off", "inherit" ] + }, + "color": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontFamily": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "indent": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "italic": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "justification": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "spacing": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] } }, - "required": [ - "bold" - ], "type": "object" }, "before": { @@ -292,11 +748,95 @@ _No fields._ "off", "inherit" ] + }, + "color": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontFamily": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "indent": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "italic": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "justification": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "spacing": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] } }, - "required": [ - "bold" - ], "type": "object" }, "changed": { @@ -309,7 +849,10 @@ _No fields._ "additionalProperties": false, "properties": { "channel": { - "const": "run" + "enum": [ + "run", + "paragraph" + ] }, "scope": { "const": "docDefaults" @@ -318,7 +861,10 @@ _No fields._ "const": "word/styles.xml" }, "xmlPath": { - "const": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + "enum": [ + "w:styles/w:docDefaults/w:rPrDefault/w:rPr", + "w:styles/w:docDefaults/w:pPrDefault/w:pPr" + ] } }, "required": [ @@ -372,7 +918,10 @@ _No fields._ "additionalProperties": false, "properties": { "channel": { - "const": "run" + "enum": [ + "run", + "paragraph" + ] }, "scope": { "const": "docDefaults" @@ -381,7 +930,10 @@ _No fields._ "const": "word/styles.xml" }, "xmlPath": { - "const": "w:styles/w:docDefaults/w:rPrDefault/w:rPr" + "enum": [ + "w:styles/w:docDefaults/w:rPrDefault/w:rPr", + "w:styles/w:docDefaults/w:pPrDefault/w:pPr" + ] } }, "required": [ diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index e817bfa4c4..21263149a8 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1032,16 +1032,152 @@ const operationSchemas: Record = { failure: preApplyFailureResultSchemaFor('blocks.delete'), }, 'styles.apply': (() => { + // --- Sub-schemas for object properties (all require minProperties: 1) --- + const fontFamilySchema = { + ...objectSchema( + { + hint: { type: 'string' }, + ascii: { type: 'string' }, + hAnsi: { type: 'string' }, + eastAsia: { type: 'string' }, + cs: { type: 'string' }, + val: { type: 'string' }, + asciiTheme: { type: 'string' }, + hAnsiTheme: { type: 'string' }, + eastAsiaTheme: { type: 'string' }, + cstheme: { type: 'string' }, + }, + [], + ), + minProperties: 1, + }; + const colorSchema = { + ...objectSchema( + { + val: { type: 'string' }, + themeColor: { type: 'string' }, + themeTint: { type: 'string' }, + themeShade: { type: 'string' }, + }, + [], + ), + minProperties: 1, + }; + const spacingSchema = { + ...objectSchema( + { + after: { type: 'integer' }, + afterAutospacing: { type: 'boolean' }, + afterLines: { type: 'integer' }, + before: { type: 'integer' }, + beforeAutospacing: { type: 'boolean' }, + beforeLines: { type: 'integer' }, + line: { type: 'integer' }, + lineRule: { enum: ['auto', 'exact', 'atLeast'] }, + }, + [], + ), + minProperties: 1, + }; + const indentSchema = { + ...objectSchema( + { + end: { type: 'integer' }, + endChars: { type: 'integer' }, + firstLine: { type: 'integer' }, + firstLineChars: { type: 'integer' }, + hanging: { type: 'integer' }, + hangingChars: { type: 'integer' }, + left: { type: 'integer' }, + leftChars: { type: 'integer' }, + right: { type: 'integer' }, + rightChars: { type: 'integer' }, + start: { type: 'integer' }, + startChars: { type: 'integer' }, + }, + [], + ), + minProperties: 1, + }; + + // --- Run-channel input (channel: "run" → run patch) --- + const runInputSchema = objectSchema( + { + target: objectSchema({ scope: { const: 'docDefaults' }, channel: { const: 'run' } }, ['scope', 'channel']), + patch: { + ...objectSchema( + { + bold: { type: 'boolean' }, + italic: { type: 'boolean' }, + fontSize: { type: 'integer' }, + fontSizeCs: { type: 'integer' }, + letterSpacing: { type: 'integer' }, + fontFamily: fontFamilySchema, + color: colorSchema, + }, + [], + ), + minProperties: 1, + }, + }, + ['target', 'patch'], + ); + + // --- Paragraph-channel input (channel: "paragraph" → paragraph patch) --- + const paragraphInputSchema = objectSchema( + { + target: objectSchema({ scope: { const: 'docDefaults' }, channel: { const: 'paragraph' } }, [ + 'scope', + 'channel', + ]), + patch: { + ...objectSchema( + { + justification: { enum: ['left', 'center', 'right', 'justify', 'distribute'] }, + spacing: spacingSchema, + indent: indentSchema, + }, + [], + ), + minProperties: 1, + }, + }, + ['target', 'patch'], + ); + + // --- Resolution: discriminated by channel with concrete xmlPath values --- const stylesTargetResolutionSchema = objectSchema( { scope: { const: 'docDefaults' }, - channel: { const: 'run' }, + channel: { enum: ['run', 'paragraph'] }, xmlPart: { const: 'word/styles.xml' }, - xmlPath: { const: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr' }, + xmlPath: { enum: ['w:styles/w:docDefaults/w:rPrDefault/w:rPr', 'w:styles/w:docDefaults/w:pPrDefault/w:pPr'] }, }, ['scope', 'channel', 'xmlPart', 'xmlPath'], ); - const stylesStateSchema = objectSchema({ bold: { enum: ['on', 'off', 'inherit'] } }, ['bold']); + + // --- Before/after state map for receipts --- + const booleanStateSchema = { enum: ['on', 'off', 'inherit'] }; + const numberOrInheritSchema = { oneOf: [{ type: 'number' }, { const: 'inherit' }] }; + const stringOrInheritSchema = { oneOf: [{ type: 'string' }, { const: 'inherit' }] }; + const objectOrInheritSchema = { oneOf: [{ type: 'object' }, { const: 'inherit' }] }; + const stylesStateSchema = { + type: 'object' as const, + properties: { + bold: booleanStateSchema, + italic: booleanStateSchema, + fontSize: numberOrInheritSchema, + fontSizeCs: numberOrInheritSchema, + letterSpacing: numberOrInheritSchema, + fontFamily: objectOrInheritSchema, + color: objectOrInheritSchema, + justification: stringOrInheritSchema, + spacing: objectOrInheritSchema, + indent: objectOrInheritSchema, + }, + additionalProperties: false, + }; + const stylesSuccessSchema = objectSchema( { success: { const: true }, @@ -1069,27 +1205,8 @@ const operationSchemas: Record = { ['success', 'resolution', 'failure'], ); return { - input: objectSchema( - { - target: objectSchema( - { - scope: { const: 'docDefaults' }, - channel: { const: 'run' }, - }, - ['scope', 'channel'], - ), - patch: { - ...objectSchema( - { - bold: { type: 'boolean' }, - }, - [], - ), - minProperties: 1, - }, - }, - ['target', 'patch'], - ), + // Discriminated input: oneOf with channel as the discriminator + input: { oneOf: [runInputSchema, paragraphInputSchema] }, output: { oneOf: [stylesSuccessSchema, stylesFailureSchema] }, success: stylesSuccessSchema, failure: stylesFailureSchema, diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index b21176696e..cf4c573793 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -65,7 +65,7 @@ import type { StylesApplyOptions, StylesApplyReceipt, } from './styles/styles.js'; -import { executeStylesApply } from './styles/styles.js'; +import { executeStylesApply, PROPERTY_REGISTRY } from './styles/styles.js'; import type { GetNodeAdapter, GetNodeByIdInput } from './get-node/get-node.js'; import { executeGetNode, executeGetNodeById } from './get-node/get-node.js'; import { executeGetText, type GetTextAdapter, type GetTextInput } from './get-text/get-text.js'; @@ -144,12 +144,25 @@ export type { FormatAlignInput, } from './format/format.js'; export { ALIGNMENTS, type Alignment } from './format/format.js'; +export { PROPERTY_REGISTRY } from './styles/styles.js'; export type { + PropertyDefinition, + ObjectSchema, StylesAdapter, StylesApplyInput, + StylesApplyRunInput, + StylesApplyParagraphInput, StylesApplyOptions, StylesApplyReceipt, StylesBooleanState, + StylesNumberState, + StylesEnumState, + StylesObjectState, + StylesStateMap, + StylesChannel, + StylesJustification, + StylesRunPatch, + StylesParagraphPatch, StylesTargetResolution, StylesApplyReceiptSuccess, StylesApplyReceiptFailure, diff --git a/packages/document-api/src/styles/styles.test.ts b/packages/document-api/src/styles/styles.test.ts index 5418026dbe..6d3da60f81 100644 --- a/packages/document-api/src/styles/styles.test.ts +++ b/packages/document-api/src/styles/styles.test.ts @@ -33,11 +33,16 @@ function makeAdapter(receipt?: Partial): StylesAdapter { }; } -const VALID_INPUT: StylesApplyInput = { +const VALID_RUN_INPUT: StylesApplyInput = { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true }, }; +const VALID_PARAGRAPH_INPUT: StylesApplyInput = { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { justification: 'center' }, +}; + function expectValidationError(fn: () => void, code: string, messagePattern?: RegExp) { try { fn(); @@ -52,12 +57,10 @@ function expectValidationError(fn: () => void, code: string, messagePattern?: Re } // --------------------------------------------------------------------------- -// Validation matrix — all locked error mappings +// Input shape validation // --------------------------------------------------------------------------- -describe('styles.apply validation', () => { - // --- Input shape --- - +describe('styles.apply validation: input shape', () => { it('throws INVALID_INPUT for non-object input', () => { const adapter = makeAdapter(); expectValidationError(() => executeStylesApply(adapter, null as never), 'INVALID_INPUT'); @@ -65,8 +68,21 @@ describe('styles.apply validation', () => { expectValidationError(() => executeStylesApply(adapter, 'string' as never), 'INVALID_INPUT'); }); - // --- Target validation --- + it('throws INVALID_INPUT for unknown top-level fields', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesApply(adapter, { ...VALID_RUN_INPUT, extra: true } as never), + 'INVALID_INPUT', + /Unknown field/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Target validation +// --------------------------------------------------------------------------- +describe('styles.apply validation: target', () => { it('throws INVALID_TARGET when target is missing', () => { const adapter = makeAdapter(); expectValidationError(() => executeStylesApply(adapter, { patch: { bold: true } } as never), 'INVALID_TARGET'); @@ -93,21 +109,48 @@ describe('styles.apply validation', () => { ); }); - it('throws INVALID_TARGET when target.channel is not run', () => { + it('throws INVALID_TARGET for unknown target fields', () => { const adapter = makeAdapter(); expectValidationError( () => executeStylesApply(adapter, { - target: { scope: 'docDefaults', channel: 'paragraph' as never }, + target: { scope: 'docDefaults', channel: 'run', extra: true } as never, + patch: { bold: true }, + }), + 'INVALID_INPUT', + /Unknown field/, + ); + }); + + it('accepts channel "run"', () => { + const adapter = makeAdapter(); + expect(() => executeStylesApply(adapter, VALID_RUN_INPUT)).not.toThrow(); + }); + + it('accepts channel "paragraph"', () => { + const adapter = makeAdapter(); + expect(() => executeStylesApply(adapter, VALID_PARAGRAPH_INPUT)).not.toThrow(); + }); + + it('throws INVALID_TARGET for invalid channel value', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'table' as never }, patch: { bold: true }, }), 'INVALID_TARGET', /channel/, ); }); +}); - // --- Patch validation --- +// --------------------------------------------------------------------------- +// Patch validation — run channel +// --------------------------------------------------------------------------- +describe('styles.apply validation: run patch', () => { it('throws INVALID_INPUT when patch is missing', () => { const adapter = makeAdapter(); expectValidationError( @@ -117,90 +160,356 @@ describe('styles.apply validation', () => { }); it('throws INVALID_INPUT when patch is not an object', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, patch: 'bad' as never }), + 'INVALID_INPUT', + ); + }); + + it('throws INVALID_INPUT when patch is empty', () => { + const adapter = makeAdapter(); + expectValidationError( + () => executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, patch: {} }), + 'INVALID_INPUT', + /at least one/, + ); + }); + + it('throws INVALID_INPUT for paragraph keys on run channel', () => { const adapter = makeAdapter(); expectValidationError( () => executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, - patch: 'bad' as never, + patch: { justification: 'center' } as never, }), 'INVALID_INPUT', + /paragraph-channel/, ); }); - it('throws INVALID_INPUT when patch is empty', () => { + it('throws INVALID_INPUT for completely unknown patch keys', () => { const adapter = makeAdapter(); expectValidationError( () => executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, - patch: {}, + patch: { fakeProperty: true } as never, }), 'INVALID_INPUT', - /at least one/, ); }); - it('throws INVALID_INPUT for unknown patch keys', () => { + // Boolean properties + it('accepts bold: true/false', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true } }), + ).not.toThrow(); + expect(() => + executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: false } }), + ).not.toThrow(); + }); + + it('throws INVALID_INPUT when bold is not a boolean', () => { const adapter = makeAdapter(); expectValidationError( () => executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, - patch: { italic: true } as never, + patch: { bold: 'yes' as never }, }), 'INVALID_INPUT', - /Unknown patch key/, + /boolean/, ); }); - it('throws INVALID_INPUT when patch.bold is not a boolean', () => { + it('accepts italic: true/false', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, patch: { italic: true } }), + ).not.toThrow(); + expect(() => + executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, patch: { italic: false } }), + ).not.toThrow(); + }); + + it('throws INVALID_INPUT when italic is not a boolean', () => { const adapter = makeAdapter(); expectValidationError( () => executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, - patch: { bold: 'yes' as never }, + patch: { italic: 'yes' as never }, }), 'INVALID_INPUT', /boolean/, ); }); - // --- Unknown input fields --- + // Number properties + it('accepts valid integer for fontSize', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, patch: { fontSize: 24 } }), + ).not.toThrow(); + }); - it('throws INVALID_INPUT for unknown top-level fields', () => { + it('rejects NaN for fontSize', () => { const adapter = makeAdapter(); expectValidationError( () => executeStylesApply(adapter, { - ...VALID_INPUT, - extra: true, - } as never), + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontSize: NaN } as never, + }), 'INVALID_INPUT', - /Unknown field/, + /finite integer/, ); }); - it('throws INVALID_INPUT for unknown target fields', () => { + it('rejects Infinity for fontSize', () => { const adapter = makeAdapter(); expectValidationError( () => executeStylesApply(adapter, { - target: { scope: 'docDefaults', channel: 'run', extra: true } as never, - patch: { bold: true }, + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontSize: Infinity } as never, }), 'INVALID_INPUT', - /Unknown field/, + /finite integer/, + ); + }); + + it('rejects non-integer for fontSize', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontSize: 1.5 } as never, + }), + 'INVALID_INPUT', + /finite integer/, + ); + }); + + it('accepts negative integers for letterSpacing', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { target: { scope: 'docDefaults', channel: 'run' }, patch: { letterSpacing: -20 } }), + ).not.toThrow(); + }); + + // Object properties + it('accepts valid fontFamily object', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontFamily: { ascii: 'Arial', hAnsi: 'Arial' } }, + }), + ).not.toThrow(); + }); + + it('rejects unknown sub-keys on fontFamily', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontFamily: { unknown: 'val' } as never }, + }), + 'INVALID_INPUT', + /Unknown key/, + ); + }); + + it('rejects non-string sub-values on fontFamily', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontFamily: { ascii: 42 } as never }, + }), + 'INVALID_INPUT', + /string/, + ); + }); + + it('rejects empty fontFamily object', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontFamily: {} as never }, + }), + 'INVALID_INPUT', + /at least one/, + ); + }); + + it('accepts valid color object', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { color: { val: 'FF0000' } }, + }), + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Patch validation — paragraph channel +// --------------------------------------------------------------------------- + +describe('styles.apply validation: paragraph patch', () => { + it('throws INVALID_INPUT for run keys on paragraph channel', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { bold: true } as never, + }), + 'INVALID_INPUT', + /run-channel/, + ); + }); + + // Enum properties + it('accepts valid justification values', () => { + const adapter = makeAdapter(); + for (const val of ['left', 'center', 'right', 'justify', 'distribute']) { + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { justification: val }, + }), + ).not.toThrow(); + } + }); + + it('rejects OOXML "both" alias for justification', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { justification: 'both' as never }, + }), + 'INVALID_INPUT', + /must be one of/, + ); + }); + + it('rejects invalid justification', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { justification: 'invalid' as never }, + }), + 'INVALID_INPUT', + /must be one of/, ); }); - // --- Options validation --- + // Object properties — spacing + it('accepts valid spacing object', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { spacing: { before: 240, lineRule: 'exact' } }, + }), + ).not.toThrow(); + }); + + it('rejects invalid lineRule in spacing', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { spacing: { lineRule: 'invalid' } as never }, + }), + 'INVALID_INPUT', + /must be one of/, + ); + }); + it('rejects non-integer spacing.before', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { spacing: { before: 1.5 } as never }, + }), + 'INVALID_INPUT', + /finite integer/, + ); + }); + + it('validates boolean sub-keys in spacing', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { spacing: { afterAutospacing: 'yes' } as never }, + }), + 'INVALID_INPUT', + /boolean/, + ); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { spacing: { afterAutospacing: true } }, + }), + ).not.toThrow(); + }); + + // Object properties — indent + it('accepts valid indent object', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { indent: { firstLine: 720 } }, + }), + ).not.toThrow(); + }); + + it('rejects unknown indent sub-keys', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { indent: { unknown: 42 } as never }, + }), + 'INVALID_INPUT', + /Unknown key/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Options validation +// --------------------------------------------------------------------------- + +describe('styles.apply validation: options', () => { it('throws INVALID_INPUT for unknown options keys (including changeMode)', () => { const adapter = makeAdapter(); expectValidationError( - () => executeStylesApply(adapter, VALID_INPUT, { changeMode: 'direct' } as never), + () => executeStylesApply(adapter, VALID_RUN_INPUT, { changeMode: 'direct' } as never), 'INVALID_INPUT', /Unknown options key/, ); @@ -209,7 +518,7 @@ describe('styles.apply validation', () => { it('throws INVALID_INPUT when options.dryRun is not a boolean', () => { const adapter = makeAdapter(); expectValidationError( - () => executeStylesApply(adapter, VALID_INPUT, { dryRun: 'yes' } as never), + () => executeStylesApply(adapter, VALID_RUN_INPUT, { dryRun: 'yes' } as never), 'INVALID_INPUT', /boolean/, ); @@ -218,7 +527,7 @@ describe('styles.apply validation', () => { it('throws INVALID_INPUT when options.expectedRevision is not a string', () => { const adapter = makeAdapter(); expectValidationError( - () => executeStylesApply(adapter, VALID_INPUT, { expectedRevision: 42 } as never), + () => executeStylesApply(adapter, VALID_RUN_INPUT, { expectedRevision: 42 } as never), 'INVALID_INPUT', /string/, ); @@ -227,37 +536,37 @@ describe('styles.apply validation', () => { it('accepts valid options (dryRun and expectedRevision)', () => { const adapter = makeAdapter(); const options: StylesApplyOptions = { dryRun: true, expectedRevision: '3' }; - const result = executeStylesApply(adapter, VALID_INPUT, options); + const result = executeStylesApply(adapter, VALID_RUN_INPUT, options); expect(result.success).toBe(true); }); it('accepts undefined/null options', () => { const adapter = makeAdapter(); - expect(() => executeStylesApply(adapter, VALID_INPUT, undefined)).not.toThrow(); - expect(() => executeStylesApply(adapter, VALID_INPUT)).not.toThrow(); + expect(() => executeStylesApply(adapter, VALID_RUN_INPUT, undefined)).not.toThrow(); + expect(() => executeStylesApply(adapter, VALID_RUN_INPUT)).not.toThrow(); }); }); // --------------------------------------------------------------------------- -// Execution +// Execution delegation // --------------------------------------------------------------------------- describe('styles.apply execution', () => { it('delegates to adapter with normalized options', () => { const adapter = makeAdapter(); - executeStylesApply(adapter, VALID_INPUT, { dryRun: true, expectedRevision: '5' }); - expect(adapter.apply).toHaveBeenCalledWith(VALID_INPUT, { dryRun: true, expectedRevision: '5' }); + executeStylesApply(adapter, VALID_RUN_INPUT, { dryRun: true, expectedRevision: '5' }); + expect(adapter.apply).toHaveBeenCalledWith(VALID_RUN_INPUT, { dryRun: true, expectedRevision: '5' }); }); it('defaults dryRun to false and expectedRevision to undefined', () => { const adapter = makeAdapter(); - executeStylesApply(adapter, VALID_INPUT); - expect(adapter.apply).toHaveBeenCalledWith(VALID_INPUT, { dryRun: false, expectedRevision: undefined }); + executeStylesApply(adapter, VALID_RUN_INPUT); + expect(adapter.apply).toHaveBeenCalledWith(VALID_RUN_INPUT, { dryRun: false, expectedRevision: undefined }); }); it('returns the receipt from the adapter', () => { const adapter = makeAdapter({ changed: false, before: { bold: 'on' }, after: { bold: 'on' } }); - const receipt = executeStylesApply(adapter, VALID_INPUT); + const receipt = executeStylesApply(adapter, VALID_RUN_INPUT); expect(receipt.success).toBe(true); if (receipt.success) { expect(receipt.changed).toBe(false); @@ -275,4 +584,10 @@ describe('styles.apply execution', () => { expect(() => executeStylesApply(adapter, input)).not.toThrow(); expect(adapter.apply).toHaveBeenCalledWith(input, { dryRun: false, expectedRevision: undefined }); }); + + it('delegates paragraph channel to adapter', () => { + const adapter = makeAdapter(); + executeStylesApply(adapter, VALID_PARAGRAPH_INPUT); + expect(adapter.apply).toHaveBeenCalledWith(VALID_PARAGRAPH_INPUT, { dryRun: false, expectedRevision: undefined }); + }); }); diff --git a/packages/document-api/src/styles/styles.ts b/packages/document-api/src/styles/styles.ts index 76edefe9ef..c5591247f9 100644 --- a/packages/document-api/src/styles/styles.ts +++ b/packages/document-api/src/styles/styles.ts @@ -13,7 +13,7 @@ import { DocumentApiValidationError } from '../errors.js'; import { isRecord } from '../validation-primitives.js'; // --------------------------------------------------------------------------- -// Types +// Property State Types // --------------------------------------------------------------------------- /** @@ -25,32 +25,193 @@ import { isRecord } from '../validation-primitives.js'; */ export type StylesBooleanState = 'on' | 'off' | 'inherit'; +/** State representation for number properties in before/after receipts. */ +export type StylesNumberState = number | 'inherit'; + +/** State representation for enum (string) properties in before/after receipts. */ +export type StylesEnumState = string | 'inherit'; + +/** State representation for object properties in before/after receipts. */ +export type StylesObjectState = Record | 'inherit'; + +// --------------------------------------------------------------------------- +// Channels and Patch Types +// --------------------------------------------------------------------------- + +export type StylesChannel = 'run' | 'paragraph'; + +/** Allowed justification values (JS-level vocabulary, not raw OOXML). */ +export type StylesJustification = 'left' | 'center' | 'right' | 'justify' | 'distribute'; + +/** Patch for run-channel properties (docDefaults/w:rPrDefault/w:rPr). */ +export interface StylesRunPatch { + bold?: boolean; + italic?: boolean; + fontSize?: number; + fontSizeCs?: number; + fontFamily?: Record; + color?: Record; + letterSpacing?: number; +} + +/** Patch for paragraph-channel properties (docDefaults/w:pPrDefault/w:pPr). */ +export interface StylesParagraphPatch { + spacing?: Record; + justification?: StylesJustification; + indent?: Record; +} + +// --------------------------------------------------------------------------- +// Declarative Property Registry +// --------------------------------------------------------------------------- + +/** Sub-key type descriptor for object property validation. */ +type SubKeyType = 'string' | 'integer' | 'boolean' | `enum:${string}`; + +/** Schema describing allowed sub-keys and their types for object properties. */ +export type ObjectSchema = Record; + +/** Discriminated union of property type definitions in the registry. */ +export type PropertyDefinition = + | { key: string; channel: StylesChannel; type: 'boolean' } + | { key: string; channel: StylesChannel; type: 'integer' } + | { key: string; channel: StylesChannel; type: 'enum'; values: string[] } + | { key: string; channel: StylesChannel; type: 'object'; schema: ObjectSchema }; + +// --- Object sub-key schemas --- + +const FONT_FAMILY_SCHEMA: ObjectSchema = { + hint: 'string', + ascii: 'string', + hAnsi: 'string', + eastAsia: 'string', + cs: 'string', + val: 'string', + asciiTheme: 'string', + hAnsiTheme: 'string', + eastAsiaTheme: 'string', + cstheme: 'string', +}; + +const COLOR_SCHEMA: ObjectSchema = { + val: 'string', + themeColor: 'string', + themeTint: 'string', + themeShade: 'string', +}; + +const SPACING_SCHEMA: ObjectSchema = { + after: 'integer', + afterAutospacing: 'boolean', + afterLines: 'integer', + before: 'integer', + beforeAutospacing: 'boolean', + beforeLines: 'integer', + line: 'integer', + lineRule: 'enum:auto,exact,atLeast', +}; + +const INDENT_SCHEMA: ObjectSchema = { + end: 'integer', + endChars: 'integer', + firstLine: 'integer', + firstLineChars: 'integer', + hanging: 'integer', + hangingChars: 'integer', + left: 'integer', + leftChars: 'integer', + right: 'integer', + rightChars: 'integer', + start: 'integer', + startChars: 'integer', +}; + +/** + * Declarative registry of all supported style properties. + * + * Adding a property to wave 2/3 = one entry here + zero validation code. + */ +export const PROPERTY_REGISTRY: PropertyDefinition[] = [ + // Run channel — booleans + { key: 'bold', channel: 'run', type: 'boolean' }, + { key: 'italic', channel: 'run', type: 'boolean' }, + + // Run channel — numbers (finite integers, no ad-hoc ranges) + { key: 'fontSize', channel: 'run', type: 'integer' }, + { key: 'fontSizeCs', channel: 'run', type: 'integer' }, + { key: 'letterSpacing', channel: 'run', type: 'integer' }, + + // Run channel — objects + { key: 'fontFamily', channel: 'run', type: 'object', schema: FONT_FAMILY_SCHEMA }, + { key: 'color', channel: 'run', type: 'object', schema: COLOR_SCHEMA }, + + // Paragraph channel — enum + { + key: 'justification', + channel: 'paragraph', + type: 'enum', + values: ['left', 'center', 'right', 'justify', 'distribute'], + }, + + // Paragraph channel — objects + { key: 'spacing', channel: 'paragraph', type: 'object', schema: SPACING_SCHEMA }, + { key: 'indent', channel: 'paragraph', type: 'object', schema: INDENT_SCHEMA }, +]; + +/** Allowed patch keys per channel, derived from the registry. */ +const ALLOWED_KEYS_BY_CHANNEL: Record> = { + run: new Set(PROPERTY_REGISTRY.filter((d) => d.channel === 'run').map((d) => d.key)), + paragraph: new Set(PROPERTY_REGISTRY.filter((d) => d.channel === 'paragraph').map((d) => d.key)), +}; + +/** Lookup a property definition by key and channel. */ +function getPropertyDefinition(key: string, channel: StylesChannel): PropertyDefinition | undefined { + return PROPERTY_REGISTRY.find((d) => d.key === key && d.channel === channel); +} + +// --------------------------------------------------------------------------- +// Target Resolution +// --------------------------------------------------------------------------- + +const XML_PATH_BY_CHANNEL: Record = { + run: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', + paragraph: 'w:styles/w:docDefaults/w:pPrDefault/w:pPr', +}; + /** * Resolution metadata describing exactly where in the OOXML package the * mutation was (or would be) applied. */ export interface StylesTargetResolution { scope: 'docDefaults'; - channel: 'run'; + channel: StylesChannel; xmlPart: 'word/styles.xml'; - xmlPath: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr'; + xmlPath: string; +} + +// --------------------------------------------------------------------------- +// Input / Output Types +// --------------------------------------------------------------------------- + +/** Input for run-channel mutations. */ +export interface StylesApplyRunInput { + target: { scope: 'docDefaults'; channel: 'run' }; + patch: StylesRunPatch; +} + +/** Input for paragraph-channel mutations. */ +export interface StylesApplyParagraphInput { + target: { scope: 'docDefaults'; channel: 'paragraph' }; + patch: StylesParagraphPatch; } /** * Input payload for `styles.apply`. * - * `target` selects the stylesheet scope and channel. + * Discriminated union: the `target.channel` value determines which patch type is valid. * `patch` declares the desired end-state for each property (set semantics, not toggle). */ -export interface StylesApplyInput { - target: { - scope: 'docDefaults'; - channel: 'run'; - }; - patch: { - bold?: boolean; - }; -} +export type StylesApplyInput = StylesApplyRunInput | StylesApplyParagraphInput; /** * Options for `styles.apply`. @@ -63,14 +224,20 @@ export interface StylesApplyOptions { expectedRevision?: string; } +/** Before/after state map — only keys addressed in the patch are present. */ +export type StylesStateMap = Record< + string, + StylesBooleanState | StylesNumberState | StylesEnumState | StylesObjectState +>; + /** Success branch of the `styles.apply` receipt. */ export interface StylesApplyReceiptSuccess { success: true; changed: boolean; resolution: StylesTargetResolution; dryRun: boolean; - before: { bold: StylesBooleanState }; - after: { bold: StylesBooleanState }; + before: StylesStateMap; + after: StylesStateMap; } /** Failure branch of the `styles.apply` receipt. */ @@ -94,6 +261,8 @@ export type StylesApplyReceipt = StylesApplyReceiptSuccess | StylesApplyReceiptF /** Engine-specific adapter for stylesheet mutations. */ export interface StylesAdapter { + apply(input: StylesApplyRunInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyParagraphInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; apply(input: StylesApplyInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; } @@ -114,17 +283,143 @@ export interface NormalizedStylesApplyOptions { /** Public API surface for stylesheet operations (docDefaults, style definitions). */ export interface StylesApi { + apply(input: StylesApplyRunInput, options?: StylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyParagraphInput, options?: StylesApplyOptions): StylesApplyReceipt; apply(input: StylesApplyInput, options?: StylesApplyOptions): StylesApplyReceipt; } // --------------------------------------------------------------------------- -// Validation +// Type-specific validators +// --------------------------------------------------------------------------- + +function validateBooleanValue(key: string, value: unknown): void { + if (typeof value !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', `patch.${key} must be a boolean, got ${typeof value}.`, { + field: 'patch', + key, + value, + }); + } +} + +function validateIntegerValue(key: string, value: unknown): void { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch.${key} must be a finite integer, got ${JSON.stringify(value)}.`, + { field: 'patch', key, value }, + ); + } +} + +function validateEnumValue(key: string, value: unknown, allowed: string[]): void { + if (typeof value !== 'string' || !allowed.includes(value)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch.${key} must be one of: ${allowed.join(', ')}. Got ${JSON.stringify(value)}.`, + { field: 'patch', key, value }, + ); + } +} + +function validateSubKeyValue(objectKey: string, subKey: string, value: unknown, subKeyType: SubKeyType): void { + if (subKeyType === 'string') { + if (typeof value !== 'string') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch.${objectKey}.${subKey} must be a string, got ${typeof value}.`, + { field: `patch.${objectKey}`, key: subKey, value }, + ); + } + return; + } + if (subKeyType === 'integer') { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch.${objectKey}.${subKey} must be a finite integer, got ${JSON.stringify(value)}.`, + { field: `patch.${objectKey}`, key: subKey, value }, + ); + } + return; + } + if (subKeyType === 'boolean') { + if (typeof value !== 'boolean') { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch.${objectKey}.${subKey} must be a boolean, got ${typeof value}.`, + { field: `patch.${objectKey}`, key: subKey, value }, + ); + } + return; + } + // enum:val1,val2,val3 + if (subKeyType.startsWith('enum:')) { + const allowed = subKeyType.slice(5).split(','); + if (typeof value !== 'string' || !allowed.includes(value)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch.${objectKey}.${subKey} must be one of: ${allowed.join(', ')}. Got ${JSON.stringify(value)}.`, + { field: `patch.${objectKey}`, key: subKey, value }, + ); + } + } +} + +function validateObjectValue(key: string, value: unknown, schema: ObjectSchema): void { + if (!isRecord(value)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch.${key} must be a non-null object, got ${typeof value}.`, + { field: 'patch', key, value }, + ); + } + + const allowedSubKeys = new Set(Object.keys(schema)); + const subKeys = Object.keys(value); + + if (subKeys.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', `patch.${key} must include at least one property.`, { + field: `patch.${key}`, + }); + } + + for (const subKey of subKeys) { + if (!allowedSubKeys.has(subKey)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown key "${subKey}" on patch.${key}. Allowed keys: ${[...allowedSubKeys].join(', ')}.`, + { field: `patch.${key}`, key: subKey }, + ); + } + validateSubKeyValue(key, subKey, value[subKey], schema[subKey]); + } +} + +/** + * Dispatches validation for a single patch key based on the registry definition. + */ +function validatePropertyValue(def: PropertyDefinition, value: unknown): void { + switch (def.type) { + case 'boolean': + return validateBooleanValue(def.key, value); + case 'integer': + return validateIntegerValue(def.key, value); + case 'enum': + return validateEnumValue(def.key, value, def.values); + case 'object': + return validateObjectValue(def.key, value, def.schema); + } +} + +// --------------------------------------------------------------------------- +// Input / Options Validation // --------------------------------------------------------------------------- const STYLES_APPLY_INPUT_ALLOWED_KEYS = new Set(['target', 'patch']); const STYLES_APPLY_TARGET_ALLOWED_KEYS = new Set(['scope', 'channel']); -const STYLES_APPLY_PATCH_ALLOWED_KEYS = new Set(['bold']); const STYLES_APPLY_OPTIONS_ALLOWED_KEYS = new Set(['dryRun', 'expectedRevision']); +const VALID_CHANNELS = new Set(['run', 'paragraph']); function validateStylesApplyInput(input: unknown): asserts input is StylesApplyInput { if (!isRecord(input)) { @@ -157,15 +452,17 @@ function validateStylesApplyInput(input: unknown): asserts input is StylesApplyI ); } - if (target.channel !== 'run') { + if (!VALID_CHANNELS.has(target.channel as string)) { throw new DocumentApiValidationError( 'INVALID_TARGET', - `target.channel must be "run", got ${JSON.stringify(target.channel)}.`, + `target.channel must be "run" or "paragraph", got ${JSON.stringify(target.channel)}.`, { field: 'target.channel', value: target.channel }, ); } - // --- Patch validation --- + const channel = target.channel as StylesChannel; + + // --- Patch validation (registry-driven) --- if (patch === undefined || patch === null) { throw new DocumentApiValidationError('INVALID_INPUT', 'styles.apply requires a patch object.'); } @@ -178,26 +475,27 @@ function validateStylesApplyInput(input: unknown): asserts input is StylesApplyI } const patchKeys = Object.keys(patch); + const allowedKeys = ALLOWED_KEYS_BY_CHANNEL[channel]; if (patchKeys.length === 0) { throw new DocumentApiValidationError('INVALID_INPUT', 'patch must include at least one property.'); } for (const key of patchKeys) { - if (!STYLES_APPLY_PATCH_ALLOWED_KEYS.has(key)) { + if (!allowedKeys.has(key)) { + // Provide a helpful message if the key belongs to a different channel + const otherChannel: StylesChannel = channel === 'run' ? 'paragraph' : 'run'; + const belongsToOther = ALLOWED_KEYS_BY_CHANNEL[otherChannel].has(key); + const detail = belongsToOther ? ` "${key}" is a ${otherChannel}-channel property.` : ''; throw new DocumentApiValidationError( 'INVALID_INPUT', - `Unknown patch key "${key}". Allowed keys: ${[...STYLES_APPLY_PATCH_ALLOWED_KEYS].join(', ')}.`, + `Unknown patch key "${key}" for channel "${channel}".${detail} Allowed keys: ${[...allowedKeys].join(', ')}.`, { field: 'patch', key }, ); } - if (typeof patch[key] !== 'boolean') { - throw new DocumentApiValidationError( - 'INVALID_INPUT', - `patch.${key} must be a boolean, got ${typeof patch[key]}.`, - { field: 'patch', key, value: patch[key] }, - ); - } + + const def = getPropertyDefinition(key, channel); + if (def) validatePropertyValue(def, patch[key]); } } diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index c50b88bc64..590fadbca7 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2489,6 +2489,19 @@ export class PresentationEditor extends EventEmitter { handler: handlePageStyleUpdate as (...args: unknown[]) => void, }); + // Listen for stylesheet default changes (e.g., styles.apply mutations to docDefaults). + // These changes mutate translatedLinkedStyles directly and need a full re-render + // so the style-engine picks up the updated default properties. + const handleStylesDefaultsChanged = () => { + this.#pendingDocChange = true; + this.#scheduleRerender(); + }; + this.#editor.on('stylesDefaultsChanged', handleStylesDefaultsChanged); + this.#editorListeners.push({ + event: 'stylesDefaultsChanged', + handler: handleStylesDefaultsChanged as (...args: unknown[]) => void, + }); + const handleCollaborationReady = (payload: unknown) => { this.emit('collaborationReady', payload); // Setup remote cursor rendering after collaboration is ready 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 0c4bae2a6e..a2f7c4070d 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 @@ -517,6 +517,7 @@ function makeStylesEditor( documentModified: false, documentGuid: 'test-guid', promoteToGuid: vi.fn(() => 'promoted-guid'), + translatedLinkedStyles: {}, } : undefined; @@ -524,6 +525,7 @@ function makeStylesEditor( converter, options: {}, on: vi.fn(), + emit: vi.fn(), } as unknown as Editor; } diff --git a/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts index 0ce738f067..973572bfd9 100644 --- a/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { StylesApplyInput, NormalizedStylesApplyOptions } from '@superdoc/document-api'; import { stylesApplyAdapter } from './styles-adapter.js'; import { DocumentApiAdapterError } from './errors.js'; @@ -9,6 +9,7 @@ import { DocumentApiAdapterError } from './errors.js'; interface XmlElement { name: string; + type?: string; elements?: XmlElement[]; attributes?: Record; } @@ -17,6 +18,7 @@ interface MockEditorOptions { stylesXml?: XmlElement; noConverter?: boolean; collaborationProvider?: { synced?: boolean; isSynced?: boolean } | null; + translatedLinkedStyles?: Record; } function createMockEditor(opts: MockEditorOptions = {}) { @@ -32,6 +34,7 @@ function createMockEditor(opts: MockEditorOptions = {}) { documentModified: false, documentGuid: 'existing-guid', promoteToGuid: vi.fn(() => 'new-guid'), + translatedLinkedStyles: opts.translatedLinkedStyles ?? {}, }; return { @@ -40,66 +43,43 @@ function createMockEditor(opts: MockEditorOptions = {}) { collaborationProvider: opts.collaborationProvider ?? null, }, on: vi.fn(), + emit: vi.fn(), } as unknown as Parameters[0]; } -function makeStylesXml(...rPrChildren: XmlElement[]): XmlElement { +/** Creates a minimal styles XML with w:styles root (enough to pass capability gates). */ +function makeStylesXml(): XmlElement { return { name: 'root', - elements: [ - { - name: 'w:styles', - elements: [ - { - name: 'w:docDefaults', - elements: [ - { - name: 'w:rPrDefault', - elements: [ - { - name: 'w:rPr', - elements: rPrChildren, - }, - ], - }, - ], - }, - ], - }, - ], + elements: [{ name: 'w:styles', elements: [] }], }; } -function makeMinimalStylesXml(): XmlElement { - return { - name: 'root', - elements: [{ name: 'w:styles', elements: [] }], - }; +function runInput(patch: Record): StylesApplyInput { + return { target: { scope: 'docDefaults', channel: 'run' }, patch } as StylesApplyInput; } -const VALID_INPUT: StylesApplyInput = { - target: { scope: 'docDefaults', channel: 'run' }, - patch: { bold: true }, -}; +function paragraphInput(patch: Record): StylesApplyInput { + return { target: { scope: 'docDefaults', channel: 'paragraph' }, patch } as StylesApplyInput; +} const DEFAULT_OPTIONS: NormalizedStylesApplyOptions = { dryRun: false, expectedRevision: undefined, }; +const DRY_RUN_OPTIONS: NormalizedStylesApplyOptions = { + dryRun: true, + expectedRevision: undefined, +}; + // --------------------------------------------------------------------------- -// Helper to get rPr from the mock styles XML +// Helpers // --------------------------------------------------------------------------- -function getRPrElements(editor: ReturnType): XmlElement[] | undefined { - const converter = (editor as unknown as { converter?: { convertedXml: Record } }).converter; - const stylesRoot = converter?.convertedXml['word/styles.xml']?.elements?.find( - (el: XmlElement) => el.name === 'w:styles', - ); - const docDefaults = stylesRoot?.elements?.find((el: XmlElement) => el.name === 'w:docDefaults'); - const rPrDefault = docDefaults?.elements?.find((el: XmlElement) => el.name === 'w:rPrDefault'); - const rPr = rPrDefault?.elements?.find((el: XmlElement) => el.name === 'w:rPr'); - return rPr?.elements; +function getTranslatedLinkedStyles(editor: ReturnType) { + return (editor as unknown as { converter: { translatedLinkedStyles: Record } }).converter + .translatedLinkedStyles; } // --------------------------------------------------------------------------- @@ -109,9 +89,11 @@ function getRPrElements(editor: ReturnType): XmlElement describe('styles adapter: capability gates', () => { it('throws CAPABILITY_UNAVAILABLE when converter is missing', () => { const editor = createMockEditor({ noConverter: true }); - expect(() => stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS)).toThrow(DocumentApiAdapterError); + expect(() => stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS)).toThrow( + DocumentApiAdapterError, + ); try { - stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); } catch (e) { expect((e as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); } @@ -119,13 +101,9 @@ describe('styles adapter: capability gates', () => { it('throws CAPABILITY_UNAVAILABLE when word/styles.xml is missing', () => { const editor = createMockEditor(); - expect(() => stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS)).toThrow(DocumentApiAdapterError); - try { - stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); - } catch (e) { - expect((e as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); - expect((e as DocumentApiAdapterError).message).toMatch(/word\/styles\.xml/); - } + expect(() => stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS)).toThrow( + DocumentApiAdapterError, + ); }); it('throws CAPABILITY_UNAVAILABLE when collaboration is active', () => { @@ -133,33 +111,38 @@ describe('styles adapter: capability gates', () => { stylesXml: makeStylesXml(), collaborationProvider: { synced: true }, }); - expect(() => stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS)).toThrow(DocumentApiAdapterError); - try { - stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); - } catch (e) { - expect((e as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); - expect((e as DocumentApiAdapterError).message).toMatch(/collaboration/); - } + expect(() => stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS)).toThrow( + DocumentApiAdapterError, + ); }); - it('allows mutation when collaboration provider is not synced (pre-initial-sync)', () => { + it('allows mutation when collaboration provider is not synced', () => { const editor = createMockEditor({ stylesXml: makeStylesXml(), collaborationProvider: { synced: false }, }); - const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); expect(result.success).toBe(true); }); + + it('throws CAPABILITY_UNAVAILABLE when w:styles root is missing', () => { + const editor = createMockEditor({ + stylesXml: { name: 'root', elements: [{ name: 'not-styles' }] }, + }); + expect(() => stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS)).toThrow( + DocumentApiAdapterError, + ); + }); }); // --------------------------------------------------------------------------- -// Bold write tests +// Run channel: boolean properties (bold, italic) // --------------------------------------------------------------------------- -describe('styles adapter: bold mutation', () => { - it('writes for patch.bold: true', () => { +describe('styles adapter: run boolean properties', () => { + it('sets bold: true on empty docDefaults', () => { const editor = createMockEditor({ stylesXml: makeStylesXml() }); - const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); expect(result.success).toBe(true); if (result.success) { @@ -168,19 +151,14 @@ describe('styles adapter: bold mutation', () => { expect(result.after.bold).toBe('on'); } - const elements = getRPrElements(editor); - const boldEl = elements?.find((el) => el.name === 'w:b'); - expect(boldEl).toBeDefined(); - expect(boldEl?.attributes).toBeUndefined(); + // Verify translatedLinkedStyles was mutated + const tls = getTranslatedLinkedStyles(editor) as { docDefaults: { runProperties: Record } }; + expect(tls.docDefaults.runProperties.bold).toBe(true); }); - it('writes for patch.bold: false', () => { + it('sets bold: false on empty docDefaults', () => { const editor = createMockEditor({ stylesXml: makeStylesXml() }); - const input: StylesApplyInput = { - target: { scope: 'docDefaults', channel: 'run' }, - patch: { bold: false }, - }; - const result = stylesApplyAdapter(editor, input, DEFAULT_OPTIONS); + const result = stylesApplyAdapter(editor, runInput({ bold: false }), DEFAULT_OPTIONS); expect(result.success).toBe(true); if (result.success) { @@ -188,63 +166,38 @@ describe('styles adapter: bold mutation', () => { expect(result.before.bold).toBe('inherit'); expect(result.after.bold).toBe('off'); } - - const elements = getRPrElements(editor); - const boldEl = elements?.find((el) => el.name === 'w:b'); - expect(boldEl).toBeDefined(); - expect(boldEl?.attributes?.['w:val']).toBe('0'); }); - it('detects inherit state when is absent', () => { - const editor = createMockEditor({ - stylesXml: makeStylesXml({ name: 'w:i' }), // italic only, no bold - }); - const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + it('sets italic: true on empty docDefaults', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ italic: true }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); if (result.success) { - expect(result.before.bold).toBe('inherit'); + expect(result.changed).toBe(true); + expect(result.before.italic).toBe('inherit'); + expect(result.after.italic).toBe('on'); } }); -}); -// --------------------------------------------------------------------------- -// OOXML boolean read normalization -// --------------------------------------------------------------------------- - -describe('styles adapter: OOXML boolean normalization', () => { - const normalizeTestCases: Array<{ desc: string; element: XmlElement; expected: 'on' | 'off' }> = [ - { desc: 'bare ', element: { name: 'w:b' }, expected: 'on' }, - { desc: '', element: { name: 'w:b', attributes: { 'w:val': '1' } }, expected: 'on' }, - { desc: '', element: { name: 'w:b', attributes: { 'w:val': 'true' } }, expected: 'on' }, - { desc: '', element: { name: 'w:b', attributes: { 'w:val': 'on' } }, expected: 'on' }, - { desc: '', element: { name: 'w:b', attributes: { 'w:val': '0' } }, expected: 'off' }, - { desc: '', element: { name: 'w:b', attributes: { 'w:val': 'false' } }, expected: 'off' }, - { desc: '', element: { name: 'w:b', attributes: { 'w:val': 'off' } }, expected: 'off' }, - ]; - - for (const { desc, element, expected } of normalizeTestCases) { - it(`reads ${desc} as "${expected}"`, () => { - const editor = createMockEditor({ stylesXml: makeStylesXml(element) }); - // Read with dryRun to avoid mutation - const result = stylesApplyAdapter( - editor, - { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true } }, - { dryRun: true, expectedRevision: undefined }, - ); - if (result.success) { - expect(result.before.bold).toBe(expected); - } - }); - } -}); + it('sets both bold and italic in single call', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ bold: true, italic: false }), DEFAULT_OPTIONS); -// --------------------------------------------------------------------------- -// No-op semantics -// --------------------------------------------------------------------------- + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(true); + expect(result.after.bold).toBe('on'); + expect(result.after.italic).toBe('off'); + } + }); -describe('styles adapter: no-op semantics', () => { - it('returns changed: false when patch.bold: true and already exists', () => { - const editor = createMockEditor({ stylesXml: makeStylesXml({ name: 'w:b' }) }); - const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + it('reads existing bold value from translatedLinkedStyles', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { docDefaults: { runProperties: { bold: true } } }, + }); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); expect(result.success).toBe(true); if (result.success) { @@ -253,42 +206,45 @@ describe('styles adapter: no-op semantics', () => { expect(result.after.bold).toBe('on'); } }); +}); + +// --------------------------------------------------------------------------- +// No-op semantics +// --------------------------------------------------------------------------- - it('returns changed: false when patch.bold: false and already exists', () => { +describe('styles adapter: no-op semantics', () => { + it('returns changed: false when value already matches', () => { const editor = createMockEditor({ - stylesXml: makeStylesXml({ name: 'w:b', attributes: { 'w:val': '0' } }), + stylesXml: makeStylesXml(), + translatedLinkedStyles: { docDefaults: { runProperties: { bold: true } } }, }); - const input: StylesApplyInput = { - target: { scope: 'docDefaults', channel: 'run' }, - patch: { bold: false }, - }; - const result = stylesApplyAdapter(editor, input, DEFAULT_OPTIONS); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); expect(result.success).toBe(true); if (result.success) { expect(result.changed).toBe(false); - expect(result.before.bold).toBe('off'); - expect(result.after.bold).toBe('off'); } }); it('does not mark converter as modified on no-op', () => { - const editor = createMockEditor({ stylesXml: makeStylesXml({ name: 'w:b' }) }); + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { docDefaults: { runProperties: { bold: true } } }, + }); const converter = (editor as unknown as { converter: { documentModified: boolean } }).converter; - stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); expect(converter.documentModified).toBe(false); }); - it('repeated identical calls produce identical receipts', () => { - const editor = createMockEditor({ stylesXml: makeStylesXml() }); - const r1 = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); - const r2 = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); - // Second call is a no-op - expect(r2.success).toBe(true); - if (r1.success && r2.success) { - expect(r2.changed).toBe(false); - expect(r2.before).toEqual(r2.after); - } + it('does not emit stylesDefaultsChanged on no-op', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { docDefaults: { runProperties: { bold: true } } }, + }); + stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); + expect((editor as unknown as { emit: ReturnType }).emit).not.toHaveBeenCalledWith( + 'stylesDefaultsChanged', + ); }); }); @@ -297,10 +253,9 @@ describe('styles adapter: no-op semantics', () => { // --------------------------------------------------------------------------- describe('styles adapter: dryRun', () => { - it('returns predicted after state without mutating XML', () => { + it('returns predicted after-state without mutating translatedLinkedStyles', () => { const editor = createMockEditor({ stylesXml: makeStylesXml() }); - const options: NormalizedStylesApplyOptions = { dryRun: true, expectedRevision: undefined }; - const result = stylesApplyAdapter(editor, VALID_INPUT, options); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), DRY_RUN_OPTIONS); expect(result.success).toBe(true); if (result.success) { @@ -310,144 +265,285 @@ describe('styles adapter: dryRun', () => { expect(result.changed).toBe(true); } - // Verify XML was not changed - const elements = getRPrElements(editor); - const boldEl = elements?.find((el) => el.name === 'w:b'); - expect(boldEl).toBeUndefined(); + // Verify translatedLinkedStyles was NOT mutated + const tls = getTranslatedLinkedStyles(editor) as { docDefaults?: { runProperties?: Record } }; + expect(tls.docDefaults?.runProperties?.bold).toBeUndefined(); + }); + + it('does not emit stylesDefaultsChanged on dryRun', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + stylesApplyAdapter(editor, runInput({ bold: true }), DRY_RUN_OPTIONS); + expect((editor as unknown as { emit: ReturnType }).emit).not.toHaveBeenCalledWith( + 'stylesDefaultsChanged', + ); }); it('does not mark converter as modified on dryRun', () => { const editor = createMockEditor({ stylesXml: makeStylesXml() }); const converter = (editor as unknown as { converter: { documentModified: boolean } }).converter; - const options: NormalizedStylesApplyOptions = { dryRun: true, expectedRevision: undefined }; - stylesApplyAdapter(editor, VALID_INPUT, options); + stylesApplyAdapter(editor, runInput({ bold: true }), DRY_RUN_OPTIONS); expect(converter.documentModified).toBe(false); }); +}); + +// --------------------------------------------------------------------------- +// Re-render trigger +// --------------------------------------------------------------------------- - it('does not create scaffolding nodes when docDefaults path is absent', () => { - const editor = createMockEditor({ stylesXml: makeMinimalStylesXml() }); - const options: NormalizedStylesApplyOptions = { dryRun: true, expectedRevision: undefined }; - const result = stylesApplyAdapter(editor, VALID_INPUT, options); +describe('styles adapter: re-render trigger', () => { + it('emits stylesDefaultsChanged after successful non-dry mutation', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); + expect((editor as unknown as { emit: ReturnType }).emit).toHaveBeenCalledWith( + 'stylesDefaultsChanged', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Run channel: number properties +// --------------------------------------------------------------------------- + +describe('styles adapter: run number properties', () => { + it('sets fontSize on empty docDefaults', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ fontSize: 24 }), DEFAULT_OPTIONS); expect(result.success).toBe(true); if (result.success) { - expect(result.dryRun).toBe(true); - expect(result.before.bold).toBe('inherit'); - expect(result.after.bold).toBe('on'); expect(result.changed).toBe(true); + expect(result.before.fontSize).toBe('inherit'); + expect(result.after.fontSize).toBe(24); } + }); - // The XML tree must remain untouched — no w:docDefaults should have been created. - const converter = (editor as unknown as { converter: { convertedXml: Record } }).converter; - const stylesRoot = converter.convertedXml['word/styles.xml'].elements?.find( - (el: XmlElement) => el.name === 'w:styles', - ); - const docDefaults = stylesRoot?.elements?.find((el: XmlElement) => el.name === 'w:docDefaults'); - expect(docDefaults).toBeUndefined(); + it('reads existing fontSize from translatedLinkedStyles', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { docDefaults: { runProperties: { fontSize: 24 } } }, + }); + const result = stylesApplyAdapter(editor, runInput({ fontSize: 24 }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(false); + expect(result.before.fontSize).toBe(24); + } + }); + + it('sets fontSizeCs', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ fontSizeCs: 32 }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.fontSizeCs).toBe(32); + } + }); + + it('sets letterSpacing (including negative)', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ letterSpacing: -20 }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.letterSpacing).toBe(-20); + } }); }); // --------------------------------------------------------------------------- -// Create-if-missing paths +// Run channel: object properties (fontFamily, color) // --------------------------------------------------------------------------- -describe('styles adapter: create-if-missing nodes', () => { - it('creates w:docDefaults, w:rPrDefault, w:rPr when missing', () => { - const editor = createMockEditor({ stylesXml: makeMinimalStylesXml() }); - const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); +describe('styles adapter: run object properties', () => { + it('sets fontFamily with merge semantics', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { runProperties: { fontFamily: { ascii: 'Times', hAnsi: 'Times' } } }, + }, + }); + const result = stylesApplyAdapter(editor, runInput({ fontFamily: { ascii: 'Arial' } }), DEFAULT_OPTIONS); expect(result.success).toBe(true); if (result.success) { expect(result.changed).toBe(true); - expect(result.after.bold).toBe('on'); + // Before shows the original + expect(result.before.fontFamily).toEqual({ ascii: 'Times', hAnsi: 'Times' }); + // After shows merge: ascii updated, hAnsi preserved + expect(result.after.fontFamily).toEqual({ ascii: 'Arial', hAnsi: 'Times' }); } - // Verify the full path was created - const elements = getRPrElements(editor); - expect(elements).toBeDefined(); - const boldEl = elements?.find((el) => el.name === 'w:b'); - expect(boldEl).toBeDefined(); + // Verify the actual stored value + const tls = getTranslatedLinkedStyles(editor) as { + docDefaults: { runProperties: { fontFamily: Record } }; + }; + expect(tls.docDefaults.runProperties.fontFamily).toEqual({ ascii: 'Arial', hAnsi: 'Times' }); + }); + + it('sets fontFamily on empty docDefaults', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ fontFamily: { ascii: 'Arial' } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.before.fontFamily).toBe('inherit'); + expect(result.after.fontFamily).toEqual({ ascii: 'Arial' }); + } + }); + + it('sets color with merge semantics', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { runProperties: { color: { val: '000000', themeColor: 'text1' } } }, + }, + }); + const result = stylesApplyAdapter(editor, runInput({ color: { val: 'FF0000' } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.color).toEqual({ val: 'FF0000', themeColor: 'text1' }); + } }); }); // --------------------------------------------------------------------------- -// Malformed XML canonicalization +// Paragraph channel: enum properties (justification) // --------------------------------------------------------------------------- -describe('styles adapter: malformed XML canonicalization', () => { - it('reads last when duplicates exist', () => { +describe('styles adapter: paragraph channel', () => { + it('sets justification on empty docDefaults', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, paragraphInput({ justification: 'center' }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(true); + expect(result.before.justification).toBe('inherit'); + expect(result.after.justification).toBe('center'); + } + + const tls = getTranslatedLinkedStyles(editor) as { + docDefaults: { paragraphProperties: Record }; + }; + expect(tls.docDefaults.paragraphProperties.justification).toBe('center'); + }); + + it('reads existing justification', () => { const editor = createMockEditor({ - stylesXml: makeStylesXml( - { name: 'w:b', attributes: { 'w:val': '0' } }, // first: off - { name: 'w:b' }, // last: on (wins) - ), + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: { justification: 'center' } }, + }, }); - const result = stylesApplyAdapter( - editor, - { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: true } }, - { dryRun: true, expectedRevision: undefined }, - ); + const result = stylesApplyAdapter(editor, paragraphInput({ justification: 'center' }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); if (result.success) { - expect(result.before.bold).toBe('on'); + expect(result.changed).toBe(false); + } + }); + + it('returns correct resolution metadata for paragraph channel', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, paragraphInput({ justification: 'left' }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.resolution).toEqual({ + scope: 'docDefaults', + channel: 'paragraph', + xmlPart: 'word/styles.xml', + xmlPath: 'w:styles/w:docDefaults/w:pPrDefault/w:pPr', + }); } }); +}); - it('normalizes to exactly one on write (removes duplicates)', () => { +// --------------------------------------------------------------------------- +// Paragraph channel: object properties (spacing, indent) +// --------------------------------------------------------------------------- + +describe('styles adapter: paragraph object properties', () => { + it('sets spacing with merge semantics', () => { const editor = createMockEditor({ - stylesXml: makeStylesXml({ name: 'w:b', attributes: { 'w:val': '0' } }, { name: 'w:b' }), + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: { spacing: { before: 240, after: 120 } } }, + }, }); - stylesApplyAdapter( + const result = stylesApplyAdapter( editor, - { target: { scope: 'docDefaults', channel: 'run' }, patch: { bold: false } }, + paragraphInput({ spacing: { before: 480, lineRule: 'exact' } }), DEFAULT_OPTIONS, ); - const elements = getRPrElements(editor); - const boldElements = elements?.filter((el) => el.name === 'w:b'); - expect(boldElements?.length).toBe(1); - expect(boldElements?.[0].attributes?.['w:val']).toBe('0'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.spacing).toEqual({ before: 480, after: 120, lineRule: 'exact' }); + } }); - it('normalizes mixed val form (w:val="true") to canonical form on mutation', () => { + it('sets indent with merge semantics', () => { const editor = createMockEditor({ - stylesXml: makeStylesXml({ name: 'w:b', attributes: { 'w:val': 'true' } }), + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: { indent: { left: 720 } } }, + }, }); - // Applying bold: true should not change state but should canonicalize - const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + const result = stylesApplyAdapter(editor, paragraphInput({ indent: { firstLine: 720 } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); if (result.success) { - expect(result.before.bold).toBe('on'); - // Same state — no change needed - expect(result.changed).toBe(false); + expect(result.after.indent).toEqual({ left: 720, firstLine: 720 }); } }); - it('preserves unknown sibling elements in w:rPr', () => { - const italicEl: XmlElement = { name: 'w:i' }; - const szEl: XmlElement = { name: 'w:sz', attributes: { 'w:val': '24' } }; - const editor = createMockEditor({ - stylesXml: makeStylesXml(italicEl, szEl), - }); - stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + it('sets indent on empty docDefaults', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, paragraphInput({ indent: { firstLine: 720 } }), DEFAULT_OPTIONS); - const elements = getRPrElements(editor); - const names = elements?.map((el) => el.name); - expect(names).toContain('w:i'); - expect(names).toContain('w:sz'); - expect(names).toContain('w:b'); + expect(result.success).toBe(true); + if (result.success) { + expect(result.before.indent).toBe('inherit'); + expect(result.after.indent).toEqual({ firstLine: 720 }); + } }); +}); - it('produces deterministic ordering on repeated calls', () => { - const editor = createMockEditor({ - stylesXml: makeStylesXml({ name: 'w:i' }), - }); - stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); - const elements1 = getRPrElements(editor)?.map((el) => el.name); +// --------------------------------------------------------------------------- +// Multi-property single call +// --------------------------------------------------------------------------- - // Call again (no-op) - stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); - const elements2 = getRPrElements(editor)?.map((el) => el.name); +describe('styles adapter: multi-property calls', () => { + it('handles multiple run properties in a single call', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ bold: true, italic: false, fontSize: 24 }), DEFAULT_OPTIONS); - expect(elements1).toEqual(elements2); + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.bold).toBe('on'); + expect(result.after.italic).toBe('off'); + expect(result.after.fontSize).toBe(24); + } + }); + + it('handles multiple paragraph properties in a single call', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter( + editor, + paragraphInput({ justification: 'center', spacing: { before: 240 }, indent: { left: 720 } }), + DEFAULT_OPTIONS, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.justification).toBe('center'); + expect(result.after.spacing).toEqual({ before: 240 }); + expect(result.after.indent).toEqual({ left: 720 }); + } }); }); @@ -456,9 +552,9 @@ describe('styles adapter: malformed XML canonicalization', () => { // --------------------------------------------------------------------------- describe('styles adapter: resolution metadata', () => { - it('returns correct resolution on success', () => { + it('returns correct resolution for run channel', () => { const editor = createMockEditor({ stylesXml: makeStylesXml() }); - const result = stylesApplyAdapter(editor, VALID_INPUT, DEFAULT_OPTIONS); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); expect(result.success).toBe(true); if (result.success) { expect(result.resolution).toEqual({ @@ -470,3 +566,55 @@ describe('styles adapter: resolution metadata', () => { } }); }); + +// --------------------------------------------------------------------------- +// XML sync (decode roundtrip) +// --------------------------------------------------------------------------- + +describe('styles adapter: XML sync via decode', () => { + it('syncs translatedLinkedStyles back to convertedXml on mutation', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); + + // The syncDocDefaultsToConvertedXml call should have updated the XML + const converter = (editor as unknown as { converter: { convertedXml: Record } }).converter; + const stylesRoot = converter.convertedXml['word/styles.xml']?.elements?.find( + (el: XmlElement) => el.name === 'w:styles', + ); + // After sync, w:docDefaults should exist in the XML + const docDefaults = stylesRoot?.elements?.find((el: XmlElement) => el.name === 'w:docDefaults'); + expect(docDefaults).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Data loss guard — decode roundtrip behavior +// --------------------------------------------------------------------------- + +describe('styles adapter: data loss guard', () => { + it('documents that decode roundtrip may not preserve unknown extensions', () => { + // This test documents known behavior: the translator decode() path + // can only reconstruct nodes it knows about. Unknown vendor extensions + // inside w:rPr may be dropped. + // + // This is NOT a new risk — the same decode() path is used during + // normal document export. If data loss exists, it existed before styles.apply. + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { runProperties: { bold: true } }, + }, + }); + + // Apply a change to trigger sync + const result = stylesApplyAdapter(editor, runInput({ italic: true }), DEFAULT_OPTIONS); + expect(result.success).toBe(true); + + // The translatedLinkedStyles should have both bold and italic + const tls = getTranslatedLinkedStyles(editor) as { + docDefaults: { runProperties: Record }; + }; + expect(tls.docDefaults.runProperties.bold).toBe(true); + expect(tls.docDefaults.runProperties.italic).toBe(true); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/styles-adapter.ts b/packages/super-editor/src/document-api-adapters/styles-adapter.ts index 935f77564a..e903381dcf 100644 --- a/packages/super-editor/src/document-api-adapters/styles-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.ts @@ -1,25 +1,33 @@ /** * Engine-specific adapter for `styles.apply`. * - * Mutates `word/styles.xml` (docDefaults run properties) directly via the - * converter's in-memory XML-JS representation. Does NOT use PM commands or - * transactions — lifecycle is handled by `executeOutOfBandMutation`. + * Reads and writes `translatedLinkedStyles.docDefaults` (the style-engine-facing + * JS object), then syncs the mutation back to `convertedXml` via the docDefaults + * translator's decode path. After a successful non-dry mutation, emits a + * `'stylesDefaultsChanged'` event so the layout pipeline re-renders. + * + * Lifecycle is handled by `executeOutOfBandMutation`. */ import type { StylesApplyInput, StylesApplyReceipt, - StylesBooleanState, StylesTargetResolution, + StylesStateMap, + StylesChannel, NormalizedStylesApplyOptions, + PropertyDefinition, } from '@superdoc/document-api'; +import { PROPERTY_REGISTRY } from '@superdoc/document-api'; import type { Editor } from '../core/Editor.js'; import { DocumentApiAdapterError } from './errors.js'; import { isCollaborationActive } from './collaboration-detection.js'; import { executeOutOfBandMutation } from './out-of-band-mutation.js'; +import { syncDocDefaultsToConvertedXml, type DocDefaultsTranslator } from './styles-xml-sync.js'; +import { translator as docDefaultsTranslator } from '../core/super-converter/v3/handlers/w/docDefaults/docDefaults-translator.js'; // --------------------------------------------------------------------------- -// XML-JS element shape (subset used by this adapter) +// Local type shapes (avoids importing engine-specific modules directly) // --------------------------------------------------------------------------- interface XmlElement { @@ -28,9 +36,14 @@ interface XmlElement { attributes?: Record; } -/** Converter shape accessed from the editor. */ interface ConverterForStyles { convertedXml: Record; + translatedLinkedStyles: { + docDefaults?: { + runProperties?: Record; + paragraphProperties?: Record; + }; + }; } // --------------------------------------------------------------------------- @@ -39,119 +52,114 @@ interface ConverterForStyles { const STYLES_PART = 'word/styles.xml'; -const DOC_DEFAULTS_RESOLUTION: StylesTargetResolution = { - scope: 'docDefaults', - channel: 'run', - xmlPart: STYLES_PART, - xmlPath: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', +const PROPERTIES_KEY_BY_CHANNEL: Record = { + run: 'runProperties', + paragraph: 'paragraphProperties', }; -/** - * OOXML boolean "truthy" values (element present with no val, or val = 1/true/on). - * All other val values are treated as "off". - */ -const OOXML_BOOLEAN_ON_VALUES = new Set(['1', 'true', 'on']); +const XML_PATH_BY_CHANNEL: Record = { + run: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', + paragraph: 'w:styles/w:docDefaults/w:pPrDefault/w:pPr', +}; // --------------------------------------------------------------------------- -// OOXML boolean read/write helpers +// State formatting helpers // --------------------------------------------------------------------------- -/** - * Reads the tri-state of a boolean OOXML element within `w:rPr`. - * - * Per § Malformed-XML Canonicalization, when duplicate elements exist the - * **last** one wins (matching Word's behavior). - */ -function readBooleanState(rPr: XmlElement, elementName: string): StylesBooleanState { - if (!rPr.elements) return 'inherit'; - - // Find all matching elements; last one wins. - let lastMatch: XmlElement | undefined; - for (const el of rPr.elements) { - if (el.name === elementName) lastMatch = el; - } - - if (!lastMatch) return 'inherit'; - return normalizeBooleanElement(lastMatch); -} +/** A single state value in a before/after receipt. */ +type StateValue = string | number | Record | 'inherit'; /** - * Normalizes a single OOXML boolean element to a `StylesBooleanState`. + * Converts a raw property value to its receipt state representation. + * + * - `undefined` → `'inherit'` + * - `true` → `'on'`, `false` → `'off'` + * - numbers, strings → pass through as-is + * - objects → shallow copy (for object properties) */ -function normalizeBooleanElement(el: XmlElement): StylesBooleanState { - const val = el.attributes?.['w:val']; - // Bare element (no w:val attribute) means "on" - if (val === undefined) return 'on'; - return OOXML_BOOLEAN_ON_VALUES.has(val) ? 'on' : 'off'; +function formatState(value: unknown, type: PropertyDefinition['type']): StateValue { + if (value === undefined) return 'inherit'; + if (type === 'boolean') return (value ? 'on' : 'off') as StateValue; + if (type === 'object' && typeof value === 'object' && value !== null) + return { ...(value as Record) }; + return value as StateValue; } /** - * Writes a boolean property to `w:rPr`, replacing any existing instances. - * - * - `true` → single `` (removes duplicates, normalizes val) - * - `false` → single `` (removes duplicates, normalizes val) - * - * Unknown sibling elements are preserved and not reordered. + * Shallow equality check for before/after state maps. */ -function writeBooleanProperty(rPr: XmlElement, elementName: string, value: boolean): void { - if (!rPr.elements) rPr.elements = []; - - // Remove all existing instances of this element - rPr.elements = rPr.elements.filter((el) => el.name !== elementName); - - // Build the canonical element - const newElement: XmlElement = { name: elementName }; - if (!value) { - newElement.attributes = { 'w:val': '0' }; +function stateMapEquals(a: StylesStateMap, b: StylesStateMap): boolean { + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + for (const key of keys) { + const av = a[key]; + const bv = b[key]; + if (av === bv) continue; + // Deep compare for object states + if (typeof av === 'object' && av !== null && typeof bv === 'object' && bv !== null) { + const aKeys = Object.keys(av); + const bKeys = Object.keys(bv as Record); + if (aKeys.length !== bKeys.length) return false; + for (const k of aKeys) { + if ((av as Record)[k] !== (bv as Record)[k]) return false; + } + continue; + } + return false; } - - // Insert at the beginning (deterministic position for repeated calls) - rPr.elements.unshift(newElement); + return true; } // --------------------------------------------------------------------------- -// XML traversal helpers +// Registry lookup // --------------------------------------------------------------------------- -/** - * Finds a direct child element by name, optionally creating it if missing. - */ -function findOrCreateChild(parent: XmlElement, childName: string): XmlElement { - if (!parent.elements) parent.elements = []; - - const existing = parent.elements.find((el) => el.name === childName); - if (existing) return existing; - - const child: XmlElement = { name: childName }; - parent.elements.push(child); - return child; +function getPropertyDefinition(key: string, channel: StylesChannel): PropertyDefinition { + const def = PROPERTY_REGISTRY.find((d) => d.key === key && d.channel === channel); + if (!def) throw new Error(`No property definition for key "${key}" on channel "${channel}".`); + return def; } -/** Finds a direct child element by name, returning `undefined` if missing. */ -function findChild(parent: XmlElement, childName: string): XmlElement | undefined { - return parent.elements?.find((el) => el.name === childName); -} +// --------------------------------------------------------------------------- +// Patch application +// --------------------------------------------------------------------------- /** - * Resolves the `w:rPr` element inside `w:styles/w:docDefaults/w:rPrDefault`, - * creating intermediate nodes as needed within the existing styles part. + * Applies a patch to the target properties object. + * + * - Boolean/number/enum: direct replacement + * - Object: merge semantics (provided sub-keys updated, unspecified preserved) + * + * Returns before/after state maps and a changed flag. */ -function resolveDocDefaultsRunProperties(stylesRoot: XmlElement): XmlElement { - const docDefaults = findOrCreateChild(stylesRoot, 'w:docDefaults'); - const rPrDefault = findOrCreateChild(docDefaults, 'w:rPrDefault'); - return findOrCreateChild(rPrDefault, 'w:rPr'); -} +function applyPatch( + targetProps: Record, + patch: Record, + channel: StylesChannel, +): { before: StylesStateMap; after: StylesStateMap; changed: boolean } { + const before: StylesStateMap = {}; + const after: StylesStateMap = {}; + + for (const [key, value] of Object.entries(patch)) { + const def = getPropertyDefinition(key, channel); + const currentValue = targetProps[key]; + + before[key] = formatState(currentValue, def.type); + + if (def.type === 'object') { + const current = + typeof currentValue === 'object' && currentValue !== null ? (currentValue as Record) : {}; + const merged = { ...current, ...(value as Record) }; + targetProps[key] = merged; + after[key] = formatState(merged, def.type); + } else { + targetProps[key] = value; + after[key] = formatState(value, def.type); + } + } -/** - * Read-only traversal of `w:styles/w:docDefaults/w:rPrDefault/w:rPr`. - * Returns `undefined` when any node in the path is absent — no mutation. - */ -function findDocDefaultsRunProperties(stylesRoot: XmlElement): XmlElement | undefined { - const docDefaults = findChild(stylesRoot, 'w:docDefaults'); - if (!docDefaults) return undefined; - const rPrDefault = findChild(docDefaults, 'w:rPrDefault'); - if (!rPrDefault) return undefined; - return findChild(rPrDefault, 'w:rPr'); + const changed = !stateMapEquals(before, after); + return { before, after, changed }; } // --------------------------------------------------------------------------- @@ -168,6 +176,8 @@ export function stylesApplyAdapter( input: StylesApplyInput, options: NormalizedStylesApplyOptions, ): StylesApplyReceipt { + const channel = input.target.channel; + // --- Capability gates (throw before mutation) --- const converter = (editor as unknown as { converter?: ConverterForStyles }).converter; if (!converter) { @@ -193,7 +203,6 @@ export function stylesApplyAdapter( ); } - // --- Resolve the XML target --- const stylesRoot = stylesPart.elements?.find((el: XmlElement) => el.name === 'w:styles'); if (!stylesRoot) { throw new DocumentApiAdapterError( @@ -203,35 +212,55 @@ export function stylesApplyAdapter( ); } + // --- Build resolution metadata --- + const resolution: StylesTargetResolution = { + scope: 'docDefaults', + channel, + xmlPart: STYLES_PART, + xmlPath: XML_PATH_BY_CHANNEL[channel], + }; + // --- Execute via out-of-band lifecycle --- return executeOutOfBandMutation( editor, (dryRun) => { - // Read before-state. Use the non-mutating traversal so dry-run - // never creates scaffolding nodes in the XML tree. - const existingRPr = findDocDefaultsRunProperties(stylesRoot); - const beforeBold = existingRPr ? readBooleanState(existingRPr, 'w:b') : 'inherit'; - const before = { bold: beforeBold }; - - // Compute after-state from patch - const afterBold = computeAfterState(beforeBold, input.patch.bold); - const after = { bold: afterBold }; + const propsKey = PROPERTIES_KEY_BY_CHANNEL[channel]; + + // Read the current target properties (non-mutating read) + const existingProps = converter.translatedLinkedStyles?.docDefaults?.[propsKey] as + | Record + | undefined; + + // For dry-run: work on a copy. For real mutation: ensure hierarchy exists. + let targetProps: Record; + if (dryRun) { + targetProps = existingProps ? { ...existingProps } : {}; + } else { + if (!converter.translatedLinkedStyles) { + (converter as unknown as Record).translatedLinkedStyles = {}; + } + if (!converter.translatedLinkedStyles.docDefaults) { + converter.translatedLinkedStyles.docDefaults = {}; + } + if (!converter.translatedLinkedStyles.docDefaults[propsKey]) { + converter.translatedLinkedStyles.docDefaults[propsKey] = {}; + } + targetProps = converter.translatedLinkedStyles.docDefaults[propsKey] as Record; + } - const changed = beforeBold !== afterBold; + // Apply patch and compute before/after + const { before, after, changed } = applyPatch(targetProps, input.patch as Record, channel); - // Apply mutation (skip on dryRun or no-op) + // Post-mutation side effects (only on real, changed mutations) if (changed && !dryRun) { - // Only now create scaffolding — we know a real write will follow. - const rPr = resolveDocDefaultsRunProperties(stylesRoot); - if (input.patch.bold !== undefined) { - writeBooleanProperty(rPr, 'w:b', input.patch.bold); - } + syncDocDefaultsToConvertedXml(converter, docDefaultsTranslator as unknown as DocDefaultsTranslator); + editor.emit('stylesDefaultsChanged'); } const receipt: StylesApplyReceipt = { success: true, changed, - resolution: DOC_DEFAULTS_RESOLUTION, + resolution, dryRun, before, after, @@ -242,11 +271,3 @@ export function stylesApplyAdapter( options, ); } - -/** - * Computes the predicted after-state for a single boolean property. - */ -function computeAfterState(currentState: StylesBooleanState, patchValue: boolean | undefined): StylesBooleanState { - if (patchValue === undefined) return currentState; - return patchValue ? 'on' : 'off'; -} diff --git a/packages/super-editor/src/document-api-adapters/styles-xml-sync.ts b/packages/super-editor/src/document-api-adapters/styles-xml-sync.ts new file mode 100644 index 0000000000..2586a4f051 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/styles-xml-sync.ts @@ -0,0 +1,80 @@ +/** + * Shared helper to sync `translatedLinkedStyles.docDefaults` back to `convertedXml`. + * + * After any mutation to `translatedLinkedStyles.docDefaults`, the export-facing + * XML-JS tree must be updated. This helper reconstructs the `w:docDefaults` node + * from the translated data using the docDefaults translator's `decode()` path. + * + * Reused by: + * - `styles-adapter.ts` (after local mutation) + * - SD-2019 collaboration sync (after remote mutation received) + */ + +// --------------------------------------------------------------------------- +// Local type shapes (avoids importing engine-specific modules) +// --------------------------------------------------------------------------- + +interface XmlElement { + name: string; + type?: string; + elements?: XmlElement[]; + attributes?: Record; +} + +export interface DocDefaultsTranslator { + decode(params: { node: { attrs: Record } }): XmlElement | undefined; +} + +interface ConverterForSync { + convertedXml: Record; + translatedLinkedStyles: { + docDefaults?: Record; + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Reconstructs the `w:docDefaults` node in `convertedXml['word/styles.xml']` + * from `translatedLinkedStyles.docDefaults`. + * + * Call after any mutation to `translatedLinkedStyles.docDefaults` to keep + * the export-facing XML in sync with the style-engine-facing JS object. + */ +export function syncDocDefaultsToConvertedXml( + converter: ConverterForSync, + docDefaultsTranslator: DocDefaultsTranslator, +): void { + const docDefaults = converter.translatedLinkedStyles.docDefaults; + + // Decode the current JS representation back to an XML-JS node + const newDocDefaultsNode = docDefaultsTranslator.decode({ + node: { attrs: { docDefaults } }, + }); + + // Find the w:styles root in the export-facing XML + const stylesPart = converter.convertedXml['word/styles.xml']; + if (!stylesPart) return; + + const stylesRoot = stylesPart.elements?.find((el) => el.name === 'w:styles'); + if (!stylesRoot) return; + if (!stylesRoot.elements) stylesRoot.elements = []; + + // Find existing w:docDefaults index + const existingIndex = stylesRoot.elements.findIndex((el) => el.name === 'w:docDefaults'); + + if (newDocDefaultsNode) { + if (existingIndex >= 0) { + // Replace in-place + stylesRoot.elements[existingIndex] = newDocDefaultsNode; + } else { + // Insert at position 0 (w:docDefaults is always first child of w:styles) + stylesRoot.elements.unshift(newDocDefaultsNode); + } + } else if (existingIndex >= 0) { + // Translator returned undefined (empty docDefaults) — remove the node + stylesRoot.elements.splice(existingIndex, 1); + } +} From 851a43d09c8e2337afb5b92b3aee910cc23461d4 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 22:42:47 -0800 Subject: [PATCH 4/5] test(doc-api-tests): improve doc api tests for tables --- apps/cli/src/cli/operation-params.ts | 4 + .../v3/handlers/w/numPr/numPr-translator.js | 3 +- .../v3/handlers/w/tab/tab-translator.js | 116 +++++- .../tests/formatting/inline-formatting.ts | 1 + tests/doc-api-stories/tests/harness.ts | 67 +++- .../tests/styles/doc-defaults.ts | 360 ++++++++++++++++++ 6 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 tests/doc-api-stories/tests/styles/doc-defaults.ts diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index dfb665924a..38495d73f1 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -351,6 +351,10 @@ const EXTRA_CLI_PARAMS: Partial> = { 'doc.format.fontFamily': [...TEXT_TARGET_FLAT_PARAMS], 'doc.format.color': [...TEXT_TARGET_FLAT_PARAMS], 'doc.format.align': [...TEXT_TARGET_FLAT_PARAMS], + 'doc.styles.apply': [ + { name: 'target', kind: 'jsonFlag', flag: 'target-json', type: 'json' }, + { name: 'patch', kind: 'jsonFlag', flag: 'patch-json', type: 'json' }, + ], 'doc.comments.create': [...TEXT_TARGET_FLAT_PARAMS], 'doc.comments.patch': [...TEXT_TARGET_FLAT_PARAMS], // List operations: flat flag (--node-id) as shortcut for --target-json, plus --input-json diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/numPr/numPr-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/numPr/numPr-translator.js index b155f09050..d86e867eee 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/numPr/numPr-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/numPr/numPr-translator.js @@ -3,13 +3,12 @@ import { NodeTranslator } from '@translator'; import { createNestedPropertiesTranslator } from '@converter/v3/handlers/utils.js'; import { translator as mcAlternateContentTranslator } from '../../mc/altermateContent'; import { translator as wIlvlTranslator } from '../ilvl'; -import { translator as wInsTranslator } from '../ins'; import { translator as wNumIdTranslator } from '../numId'; // Property translators for w:numPr child elements // Each translator handles a specific property of the numbering properties /** @type {import('@translator').NodeTranslator[]} */ -const propertyTranslators = [mcAlternateContentTranslator, wIlvlTranslator, wInsTranslator, wNumIdTranslator]; +const propertyTranslators = [mcAlternateContentTranslator, wIlvlTranslator, wNumIdTranslator]; /** * The NodeTranslator instance for the w:numPr element. diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.js index bdd0698a8c..c092fdee73 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.js @@ -1,7 +1,7 @@ // @ts-check import { NodeTranslator } from '@translator'; import validXmlAttributes from './attributes/index.js'; -import { generateRunProps, processOutputMarks } from '../../../../exporter.js'; +import { translator as wRPrNodeTranslator } from '../rpr/rpr-translator.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:tab'; @@ -49,17 +49,121 @@ function decode(params, decodedAttrs = {}) { elements: [wTab], }; - // This is needed until we support w:r nodes - // Later we will refactor this to not wrap the tab in a run and run those nodes separately + // Preserve inherited run properties and mark-derived formatting on exported tabs. const { marks: nodeMarks = [] } = node; - const outputMarks = processOutputMarks(nodeMarks); - if (outputMarks.length) { - translated.elements.unshift(generateRunProps(outputMarks)); + const markRunProperties = decodeRunPropertiesFromMarks(nodeMarks); + const inheritedRunProperties = params.extraParams?.runProperties || {}; + const mergedRunProperties = mergeRunProperties(inheritedRunProperties, markRunProperties); + const rPrNode = wRPrNodeTranslator.decode({ + node: { + type: 'runProperties', + attrs: { runProperties: mergedRunProperties }, + }, + }); + if (rPrNode) { + translated.elements.unshift(rPrNode); } return translated; } +/** + * @param {Record} base + * @param {Record} override + */ +function mergeRunProperties(base = {}, override = {}) { + const merged = { ...base }; + for (const [key, value] of Object.entries(override)) { + if (value && typeof value === 'object' && !Array.isArray(value) && base[key] && typeof base[key] === 'object') { + merged[key] = { ...base[key], ...value }; + continue; + } + merged[key] = value; + } + return merged; +} + +/** + * Lightweight mark -> runProperties mapper for tab-node export. + * Mirrors the common subset used by text export without importing exporter.js + * (which creates a module cycle during converter bootstrap). + * @param {Array} marks + */ +function decodeRunPropertiesFromMarks(marks = []) { + const runProperties = {}; + + for (const mark of marks) { + const type = mark?.type?.name ?? mark?.type; + const attrs = mark?.attrs ?? {}; + + switch (type) { + case 'bold': + case 'italic': + case 'strike': + runProperties[type] = attrs.value !== '0' && attrs.value !== false; + break; + case 'underline': { + const underlineAttrs = {}; + if (attrs.underlineType) underlineAttrs['w:val'] = attrs.underlineType; + if (attrs.underlineColor) underlineAttrs['w:color'] = String(attrs.underlineColor).replace('#', ''); + if (Object.keys(underlineAttrs).length > 0) { + runProperties.underline = underlineAttrs; + } + break; + } + case 'highlight': + if (attrs.color) { + runProperties.highlight = + String(attrs.color).toLowerCase() === 'transparent' ? { 'w:val': 'none' } : { 'w:val': attrs.color }; + } + break; + case 'link': + runProperties.styleId = 'Hyperlink'; + break; + case 'textStyle': + if (attrs.textTransform != null) { + runProperties.textTransform = attrs.textTransform; + } + if (attrs.color != null) { + runProperties.color = { val: String(attrs.color).replace('#', '') }; + } + if (attrs.fontSize != null) { + const points = Number.parseFloat(String(attrs.fontSize)); + if (!Number.isNaN(points)) { + runProperties.fontSize = points * 2; + } + } + if (attrs.letterSpacing != null) { + const ptValue = Number.parseFloat(String(attrs.letterSpacing)); + if (!Number.isNaN(ptValue)) { + runProperties.letterSpacing = ptValue * 20; + } + } + if (attrs.fontFamily != null) { + const cleanValue = String(attrs.fontFamily).split(',')[0].trim(); + runProperties.fontFamily = { + ascii: cleanValue, + eastAsia: cleanValue, + hAnsi: cleanValue, + cs: cleanValue, + }; + } + if (attrs.vertAlign != null) { + runProperties.vertAlign = attrs.vertAlign; + } + if (attrs.position != null) { + const numeric = Number.parseFloat(String(attrs.position)); + if (!Number.isNaN(numeric)) { + runProperties.position = numeric * 2; + } + } + break; + } + } + + return runProperties; +} + /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, diff --git a/tests/doc-api-stories/tests/formatting/inline-formatting.ts b/tests/doc-api-stories/tests/formatting/inline-formatting.ts index 62f1a932e9..2a86d33968 100644 --- a/tests/doc-api-stories/tests/formatting/inline-formatting.ts +++ b/tests/doc-api-stories/tests/formatting/inline-formatting.ts @@ -232,5 +232,6 @@ describe('document-api story: inline formatting', () => { }), ); expect(result.receipt?.success).toBe(true); + await saveResult(sid, 'dryRun.docx'); }); }); diff --git a/tests/doc-api-stories/tests/harness.ts b/tests/doc-api-stories/tests/harness.ts index 8e9cca364a..577ea19e05 100644 --- a/tests/doc-api-stories/tests/harness.ts +++ b/tests/doc-api-stories/tests/harness.ts @@ -1,5 +1,7 @@ import { access, copyFile, mkdir, rm } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; import path from 'node:path'; +import { promisify } from 'node:util'; import { afterEach, beforeEach } from 'vitest'; import { createSuperDocClient, type SuperDocClient } from '@superdoc-dev/sdk'; @@ -7,6 +9,46 @@ const REPO_ROOT = path.resolve(import.meta.dirname, '../../..'); const STORIES_ROOT = path.resolve(import.meta.dirname, '..'); const CLI_DIST_BIN = path.join(REPO_ROOT, 'apps/cli/dist/index.js'); const CLI_SRC_BIN = path.join(REPO_ROOT, 'apps/cli/src/index.ts'); +const execFileAsync = promisify(execFile); + +interface CliInvocation { + command: string; + prefixArgs: string[]; +} + +function resolveInvocation(cliBin: string): CliInvocation { + if (cliBin.toLowerCase().endsWith('.js')) { + return { command: 'node', prefixArgs: [cliBin] }; + } + if (cliBin.toLowerCase().endsWith('.ts')) { + return { command: 'bun', prefixArgs: [cliBin] }; + } + return { command: cliBin, prefixArgs: [] }; +} + +function parseJsonEnvelope(stdout: string, stderr: string): any { + const source = stdout.trim() || stderr.trim(); + if (!source) { + throw new Error('No CLI JSON envelope output found.'); + } + + try { + return JSON.parse(source); + } catch { + const lines = source.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const candidate = lines.slice(index).join('\n').trim(); + if (!candidate.startsWith('{')) continue; + try { + return JSON.parse(candidate); + } catch { + // continue scanning + } + } + } + + throw new Error(`Failed to parse CLI JSON envelope:\n${source}`); +} /** Resolve a test-corpus relative path to its absolute location. */ export function corpusDoc(relativePath: string): string { @@ -24,6 +66,8 @@ export interface StoryContext { copyDoc(source: string, name?: string): Promise; /** Return a path inside the results dir. */ outPath(name: string): string; + /** Run a raw CLI command with the story's state dir and parse the JSON envelope. */ + runCli(args: string[]): Promise; } export interface StoryHarnessOptions { @@ -56,11 +100,12 @@ export function useStoryHarness(storyName: string, options: StoryHarnessOptions () => CLI_DIST_BIN, () => CLI_SRC_BIN, ); + const stateDir = path.join(resultsDir, '.superdoc-cli-state'); const client = createSuperDocClient({ env: { SUPERDOC_CLI_BIN: cliBin, - SUPERDOC_CLI_STATE_DIR: path.join(resultsDir, '.superdoc-cli-state'), + SUPERDOC_CLI_STATE_DIR: stateDir, }, requestTimeoutMs: 30_000, startupTimeoutMs: 30_000, @@ -86,6 +131,25 @@ export function useStoryHarness(storyName: string, options: StoryHarnessOptions return dest; }, outPath: (name) => path.join(resultsDir, name), + runCli: async (args) => { + const invocation = resolveInvocation(cliBin); + const argv = [...invocation.prefixArgs, ...args, '--output', 'json']; + const { stdout, stderr } = await execFileAsync(invocation.command, argv, { + cwd: REPO_ROOT, + env: { + ...process.env, + SUPERDOC_CLI_STATE_DIR: stateDir, + }, + }); + + const envelope = parseJsonEnvelope(stdout, stderr); + if (envelope?.ok === false) { + const code = envelope.error?.code ?? 'UNKNOWN'; + const message = envelope.error?.message ?? 'Unknown CLI error'; + throw new Error(`${code}: ${message}`); + } + return envelope; + }, }; }); @@ -113,6 +177,7 @@ export function useStoryHarness(storyName: string, options: StoryHarnessOptions client: clientProxy, copyDoc: (source: string, name?: string) => requireCtx().copyDoc(source, name), outPath: (name: string) => requireCtx().outPath(name), + runCli: (args: string[]) => requireCtx().runCli(args), } as StoryContext; Object.defineProperty(api, 'resultsDir', { diff --git a/tests/doc-api-stories/tests/styles/doc-defaults.ts b/tests/doc-api-stories/tests/styles/doc-defaults.ts new file mode 100644 index 0000000000..8bb41e1545 --- /dev/null +++ b/tests/doc-api-stories/tests/styles/doc-defaults.ts @@ -0,0 +1,360 @@ +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +/** + * End-to-end story tests for `styles.apply` (docDefaults mutation). + * + * Each test starts from a blank document, inserts visible sample text, applies a + * stylesheet patch, and saves the output DOCX under `tests/doc-api-stories/results`. + * This keeps every case visually inspectable while still asserting receipt + * semantics (`before`/`after`, `changed`, and resolution metadata). + */ +describe('document-api story: styles.apply docDefaults', () => { + const { client, outPath, runCli } = useStoryHarness('styles/doc-defaults', { + preserveResults: true, + }); + + function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; + } + + async function seedBlankDoc(sessionId: string, text: string, docName: string): Promise { + await client.doc.open({ sessionId }); + const insertResult = unwrap(await client.doc.insert({ sessionId, text })); + expect(insertResult.receipt?.success).toBe(true); + const sourceDoc = outPath(docName); + await client.doc.save({ sessionId, out: sourceDoc }); + return sourceDoc; + } + + async function seedBlankDocWithParagraphs(sessionId: string, paragraphs: string[], docName: string): Promise { + if (paragraphs.length === 0) { + throw new Error('seedBlankDocWithParagraphs requires at least one paragraph.'); + } + + await client.doc.open({ sessionId }); + const firstInsert = unwrap(await client.doc.insert({ sessionId, text: paragraphs[0] })); + expect(firstInsert.receipt?.success).toBe(true); + + for (const paragraphText of paragraphs.slice(1)) { + const createResult = unwrap( + await client.doc.create.paragraph({ + sessionId, + at: { kind: 'documentEnd' }, + text: paragraphText, + }), + ); + expect(createResult.success).toBe(true); + } + + const sourceDoc = outPath(docName); + await client.doc.save({ sessionId, out: sourceDoc }); + return sourceDoc; + } + + async function applyStylesPatch( + doc: string, + channel: 'run' | 'paragraph', + patch: Record, + options?: { dryRun?: boolean; out?: string }, + ): Promise { + const args = [ + 'styles', + 'apply', + doc, + '--target-json', + JSON.stringify({ scope: 'docDefaults', channel }), + '--patch-json', + JSON.stringify(patch), + ]; + + if (options?.dryRun) { + args.push('--dry-run', 'true'); + } + if (options?.out) { + args.push('--out', options.out); + } + + const envelope = await runCli(args); + const payload = envelope?.data ?? envelope; + const receipt = payload?.receipt ?? payload; + expect(receipt).toBeDefined(); + return receipt; + } + + it('run channel: bold + italic on', async () => { + const sessionId = sid('styles-run-bold-italic'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should render this text bold and italic.', + 'run-bold-italic-source.docx', + ); + + const receipt = await applyStylesPatch( + sourceDoc, + 'run', + { bold: true, italic: true }, + { + out: outPath('run-bold-italic.docx'), + }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.after.bold).toBe('on'); + expect(receipt.after.italic).toBe('on'); + expect(receipt.resolution.xmlPath).toBe('w:styles/w:docDefaults/w:rPrDefault/w:rPr'); + }); + + it('run channel: bold off state', async () => { + const sessionId = sid('styles-run-bold-off'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should explicitly disable bold for this text.', + 'run-bold-off-source.docx', + ); + + const receipt = await applyStylesPatch(sourceDoc, 'run', { bold: false }, { out: outPath('run-bold-off.docx') }); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.before.bold).toBe('inherit'); + expect(receipt.after.bold).toBe('off'); + }); + + it('run channel: fontSize + fontSizeCs', async () => { + const sessionId = sid('styles-run-font-size'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should set this text to 14pt for latin and cs scripts.', + 'run-font-size-source.docx', + ); + + const receipt = await applyStylesPatch( + sourceDoc, + 'run', + { fontSize: 28, fontSizeCs: 28 }, + { + out: outPath('run-font-size.docx'), + }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.before.fontSize === 'inherit' || typeof receipt.before.fontSize === 'number').toBe(true); + expect(receipt.after.fontSize).toBe(28); + expect(receipt.after.fontSizeCs).toBe(28); + }); + + it('run channel: fontFamily object patch', async () => { + const sessionId = sid('styles-run-font-family'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should use Courier New as the primary family.', + 'run-font-family-source.docx', + ); + + const receipt = await applyStylesPatch( + sourceDoc, + 'run', + { + fontFamily: { ascii: 'Courier New', hAnsi: 'Courier New' }, + }, + { out: outPath('run-font-family.docx') }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.before.fontFamily === 'inherit' || typeof receipt.before.fontFamily === 'object').toBe(true); + expect(receipt.after.fontFamily).toMatchObject({ ascii: 'Courier New', hAnsi: 'Courier New' }); + }); + + it('run channel: color object patch', async () => { + const sessionId = sid('styles-run-color'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should render this text in red.', + 'run-color-source.docx', + ); + + const receipt = await applyStylesPatch( + sourceDoc, + 'run', + { color: { val: 'FF0000' } }, + { out: outPath('run-color.docx') }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.before.color).toBe('inherit'); + expect(receipt.after.color).toEqual({ val: 'FF0000' }); + }); + + it('run channel: letterSpacing', async () => { + const sessionId = sid('styles-run-letter-spacing'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should apply extra tracking to this text.', + 'run-letter-spacing-source.docx', + ); + + const receipt = await applyStylesPatch( + sourceDoc, + 'run', + { letterSpacing: 20 }, + { + out: outPath('run-letter-spacing.docx'), + }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.before.letterSpacing).toBe('inherit'); + expect(receipt.after.letterSpacing).toBe(20); + }); + + it('paragraph channel: justification center', async () => { + const sessionId = sid('styles-paragraph-justification'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should center this paragraph by default.', + 'paragraph-justification-center-source.docx', + ); + + const receipt = await applyStylesPatch( + sourceDoc, + 'paragraph', + { justification: 'center' }, + { + out: outPath('paragraph-justification-center.docx'), + }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.before.justification).toBe('inherit'); + expect(receipt.after.justification).toBe('center'); + expect(receipt.resolution.xmlPath).toBe('w:styles/w:docDefaults/w:pPrDefault/w:pPr'); + }); + + it('paragraph channel: spacing object patch', async () => { + const sessionId = sid('styles-paragraph-spacing'); + const sourceDoc = await seedBlankDocWithParagraphs( + sessionId, + [ + 'Paragraph 1: spacing should be visible above and below this paragraph.', + 'Paragraph 2: this paragraph exists to make the inter-paragraph spacing obvious.', + 'Paragraph 3: another paragraph to confirm spacing repeats consistently.', + ], + 'paragraph-spacing-source.docx', + ); + + const spacingPatch = { before: 240, after: 240, line: 360, lineRule: 'auto' }; + const receipt = await applyStylesPatch( + sourceDoc, + 'paragraph', + { spacing: spacingPatch }, + { + out: outPath('paragraph-spacing.docx'), + }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.before.spacing).toBe('inherit'); + expect(receipt.after.spacing).toEqual(spacingPatch); + }); + + it('paragraph channel: indent object patch', async () => { + const sessionId = sid('styles-paragraph-indent'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should indent this paragraph.', + 'paragraph-indent-source.docx', + ); + + const indentPatch = { left: 720, firstLine: 360 }; + const receipt = await applyStylesPatch( + sourceDoc, + 'paragraph', + { indent: indentPatch }, + { + out: outPath('paragraph-indent.docx'), + }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.before.indent).toBe('inherit'); + expect(receipt.after.indent).toEqual(indentPatch); + }); + + it('run channel: multi-property patch in one call', async () => { + const sessionId = sid('styles-run-multi'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should combine bold, font size, color, and font family for this text.', + 'run-multi-property-source.docx', + ); + + const receipt = await applyStylesPatch( + sourceDoc, + 'run', + { + bold: true, + fontSize: 30, + color: { val: '0000FF' }, + fontFamily: { ascii: 'Georgia' }, + }, + { out: outPath('run-multi-property.docx') }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.after.bold).toBe('on'); + expect(receipt.after.fontSize).toBe(30); + expect(receipt.after.color).toEqual({ val: '0000FF' }); + expect(receipt.after.fontFamily).toMatchObject({ ascii: 'Georgia' }); + }); + + it('paragraph channel: multi-property patch in one call', async () => { + const sessionId = sid('styles-paragraph-multi'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should combine paragraph justification, spacing, and indent.', + 'paragraph-multi-property-source.docx', + ); + + const spacingPatch = { before: 120 }; + const indentPatch = { left: 720 }; + const receipt = await applyStylesPatch( + sourceDoc, + 'paragraph', + { + justification: 'justify', + spacing: spacingPatch, + indent: indentPatch, + }, + { out: outPath('paragraph-multi-property.docx') }, + ); + expect(receipt.success).toBe(true); + expect(receipt.changed).toBe(true); + expect(receipt.after.justification).toBe('justify'); + expect(receipt.after.spacing).toEqual(spacingPatch); + expect(receipt.after.indent).toEqual(indentPatch); + }); + + it('roundtrip persistence: saved docDefaults report changed=false on dryRun re-apply', async () => { + const sessionId = sid('styles-persist'); + const sourceDoc = await seedBlankDoc( + sessionId, + 'Doc defaults should persist across save and reopen.', + 'run-persistence-source.docx', + ); + + const runPatch = { bold: true, fontSize: 28 }; + const persistedDoc = outPath('run-persistence-applied.docx'); + const applyReceipt = await applyStylesPatch(sourceDoc, 'run', runPatch, { out: persistedDoc }); + expect(applyReceipt.success).toBe(true); + expect(applyReceipt.changed).toBe(true); + + const dryRunReceipt = await applyStylesPatch(persistedDoc, 'run', runPatch, { dryRun: true }); + expect(dryRunReceipt.success).toBe(true); + expect(dryRunReceipt.dryRun).toBe(true); + expect(dryRunReceipt.changed).toBe(false); + expect(dryRunReceipt.before.bold).toBe('on'); + expect(dryRunReceipt.after.bold).toBe('on'); + expect(dryRunReceipt.before.fontSize).toBe(28); + expect(dryRunReceipt.after.fontSize).toBe(28); + }); +}); From 42608cd89cad14c940ddceaad19940ac70ac2977 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 22:58:04 -0800 Subject: [PATCH 5/5] chore: fix tab translator --- .../v3/handlers/w/tab/tab-translator.js | 8 ++ .../v3/handlers/w/tab/tab-translator.test.js | 78 ++++++------------- 2 files changed, 32 insertions(+), 54 deletions(-) diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.js index c092fdee73..59390cf407 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.js @@ -120,7 +120,15 @@ function decodeRunPropertiesFromMarks(marks = []) { case 'link': runProperties.styleId = 'Hyperlink'; break; + case 'styleId': + if (attrs.styleId != null) { + runProperties.styleId = attrs.styleId; + } + break; case 'textStyle': + if (attrs.styleId != null) { + runProperties.styleId = attrs.styleId; + } if (attrs.textTransform != null) { runProperties.textTransform = attrs.textTransform; } diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.test.js index d266b5c457..eb9191a4a9 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/tab/tab-translator.test.js @@ -1,15 +1,5 @@ -import { vi, beforeEach, describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { config } from './index.js'; -import { processOutputMarks, generateRunProps } from '../../../../exporter.js'; - -vi.mock('../../../../exporter.js', () => { - const processOutputMarks = vi.fn((marks) => marks || []); - const generateRunProps = vi.fn((processedMarks) => ({ - name: 'w:rPr', - elements: [], - })); - return { processOutputMarks, generateRunProps }; -}); describe('w:tab translator config', () => { describe('encode', () => { @@ -62,58 +52,31 @@ describe('w:tab translator config', () => { }); describe('decode — marks and run props', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('calls processOutputMarks with node.marks and adds run props before ', () => { - const fakeMarks = [{ type: 'bold' }, { type: 'italic' }]; - const processed = [{ type: 'bold' }]; - const rPrNode = { name: 'w:rPr', elements: [{ name: 'w:b' }] }; - - processOutputMarks.mockReturnValue(processed); - generateRunProps.mockReturnValue(rPrNode); - - const params = { node: { type: 'tab', marks: fakeMarks } }; - const res = config.decode(params, undefined); - - expect(processOutputMarks).toHaveBeenCalledTimes(1); - expect(processOutputMarks).toHaveBeenCalledWith(fakeMarks); - - expect(generateRunProps).toHaveBeenCalledTimes(1); - expect(generateRunProps).toHaveBeenCalledWith(processed); - + it('adds run props from node.marks before ', () => { + const res = config.decode({ node: { type: 'tab', marks: [{ type: 'bold' }, { type: 'italic' }] } }, undefined); expect(res).toBeTruthy(); expect(res.name).toBe('w:r'); expect(Array.isArray(res.elements)).toBe(true); - expect(res.elements[0]).toEqual(rPrNode); // run props first + expect(res.elements[0].name).toBe('w:rPr'); + const childNames = res.elements[0].elements.map((el) => el.name); + expect(childNames).toContain('w:b'); + expect(childNames).toContain('w:i'); expect(res.elements[1]).toEqual({ name: 'w:tab', attributes: {}, elements: [] }); }); - it('does not add run props when processOutputMarks returns an empty array', () => { - processOutputMarks.mockReturnValue([]); - - const params = { node: { type: 'tab', marks: [{ type: 'bold' }] } }; - const res = config.decode(params, undefined); - - expect(processOutputMarks).toHaveBeenCalledTimes(1); - expect(generateRunProps).not.toHaveBeenCalled(); - + it('does not add run props when node.marks is empty', () => { + const res = config.decode({ node: { type: 'tab', marks: [] } }, undefined); expect(res.name).toBe('w:r'); expect(res.elements).toEqual([{ name: 'w:tab', attributes: {}, elements: [] }]); }); it('still places run props before when decodedAttrs are present', () => { - processOutputMarks.mockReturnValue([{ type: 'bold' }]); - generateRunProps.mockReturnValue({ name: 'w:rPr', elements: [{ name: 'w:b' }] }); - - const params = { node: { type: 'tab', marks: [{ type: 'bold' }] } }; const decoded = { 'w:val': 'left', 'w:custom': 'foo' }; - const res = config.decode(params, decoded); + const res = config.decode({ node: { type: 'tab', marks: [{ type: 'bold' }] } }, decoded); expect(res.name).toBe('w:r'); - expect(res.elements[0]).toEqual({ name: 'w:rPr', elements: [{ name: 'w:b' }] }); + expect(res.elements[0].name).toBe('w:rPr'); expect(res.elements[1]).toEqual({ name: 'w:tab', attributes: { 'w:val': 'left', 'w:custom': 'foo' }, @@ -121,14 +84,21 @@ describe('w:tab translator config', () => { }); }); - it('passes an empty array to processOutputMarks when node.marks is missing', () => { - processOutputMarks.mockReturnValue([]); - + it('does not add run props when node.marks is missing', () => { const res = config.decode({ node: { type: 'tab' } }, undefined); - - expect(processOutputMarks).toHaveBeenCalledTimes(1); - expect(processOutputMarks).toHaveBeenCalledWith([]); expect(res.elements).toEqual([{ name: 'w:tab', attributes: {}, elements: [] }]); }); + + it('preserves textStyle.styleId as w:rStyle in tab run props', () => { + const res = config.decode( + { node: { type: 'tab', marks: [{ type: 'textStyle', attrs: { styleId: 'Emphasis' } }] } }, + undefined, + ); + + expect(res.name).toBe('w:r'); + expect(res.elements[0].name).toBe('w:rPr'); + const rStyle = res.elements[0].elements.find((el) => el.name === 'w:rStyle'); + expect(rStyle?.attributes?.['w:val']).toBe('Emphasis'); + }); }); });