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/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/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..ac363cd00f 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": "d5b584c9762004cd89a0af9a3068c83a36049453f26a0e80232cfd51654e32a3" } 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..8efef68a3c --- /dev/null +++ b/apps/docs/document-api/reference/styles/apply.mdx @@ -0,0 +1,959 @@ +--- +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 + +_No fields._ + +### Example request + +```json +{ + "patch": { + "bold": true, + "italic": true + }, + "target": { + "channel": "run", + "scope": "docDefaults" + } +} +``` + +## Output fields + +_No fields._ + +### Example response + +```json +{ + "after": { + "bold": "on", + "italic": "on" + }, + "before": { + "bold": "on", + "italic": "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 +{ + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "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" + }, + { + "additionalProperties": false, + "properties": { + "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" + }, + "target": { + "additionalProperties": false, + "properties": { + "channel": { + "const": "paragraph" + }, + "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" + ] + }, + "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" + } + ] + } + }, + "type": "object" + }, + "before": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "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" + } + ] + } + }, + "type": "object" + }, + "changed": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "channel": { + "enum": [ + "run", + "paragraph" + ] + }, + "scope": { + "const": "docDefaults" + }, + "xmlPart": { + "const": "word/styles.xml" + }, + "xmlPath": { + "enum": [ + "w:styles/w:docDefaults/w:rPrDefault/w:rPr", + "w:styles/w:docDefaults/w:pPrDefault/w:pPr" + ] + } + }, + "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": { + "enum": [ + "run", + "paragraph" + ] + }, + "scope": { + "const": "docDefaults" + }, + "xmlPart": { + "const": "word/styles.xml" + }, + "xmlPath": { + "enum": [ + "w:styles/w:docDefaults/w:rPrDefault/w:rPr", + "w:styles/w:docDefaults/w:pPrDefault/w:pPr" + ] + } + }, + "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" + ] + }, + "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" + } + ] + } + }, + "type": "object" + }, + "before": { + "additionalProperties": false, + "properties": { + "bold": { + "enum": [ + "on", + "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" + } + ] + } + }, + "type": "object" + }, + "changed": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "resolution": { + "additionalProperties": false, + "properties": { + "channel": { + "enum": [ + "run", + "paragraph" + ] + }, + "scope": { + "const": "docDefaults" + }, + "xmlPart": { + "const": "word/styles.xml" + }, + "xmlPath": { + "enum": [ + "w:styles/w:docDefaults/w:rPrDefault/w:rPr", + "w:styles/w:docDefaults/w:pPrDefault/w:pPr" + ] + } + }, + "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": { + "enum": [ + "run", + "paragraph" + ] + }, + "scope": { + "const": "docDefaults" + }, + "xmlPart": { + "const": "word/styles.xml" + }, + "xmlPath": { + "enum": [ + "w:styles/w:docDefaults/w:rPrDefault/w:rPr", + "w:styles/w:docDefaults/w:pPrDefault/w:pPr" + ] + } + }, + "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': (() => { + // --- 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: { enum: ['run', 'paragraph'] }, + xmlPart: { const: 'word/styles.xml' }, + xmlPath: { enum: ['w:styles/w:docDefaults/w:rPrDefault/w:rPr', 'w:styles/w:docDefaults/w:pPrDefault/w:pPr'] }, + }, + ['scope', 'channel', 'xmlPart', 'xmlPath'], + ); + + // --- 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 }, + 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 { + // Discriminated input: oneOf with channel as the discriminator + input: { oneOf: [runInputSchema, paragraphInputSchema] }, + 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..cf4c573793 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, 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'; @@ -136,6 +144,30 @@ 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, + NormalizedStylesApplyOptions, +} from './styles/styles.js'; export type { CreateAdapter } from './create/create.js'; export type { TrackChangesAdapter, @@ -273,6 +305,10 @@ export interface DocumentApi { * Formatting operations. */ format: FormatApi; + /** + * Stylesheet operations (docDefaults, style definitions). + */ + styles: StylesApi; /** * Tracked-change operations (list, get, decide). */ @@ -327,6 +363,7 @@ export interface DocumentApiAdapters { comments: CommentsAdapter; write: WriteAdapter; format: FormatAdapter; + styles: StylesAdapter; trackChanges: TrackChangesAdapter; create: CreateAdapter; blocks: BlocksAdapter; @@ -426,6 +463,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..6d3da60f81 --- /dev/null +++ b/packages/document-api/src/styles/styles.test.ts @@ -0,0 +1,593 @@ +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_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(); + 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); + } + } +} + +// --------------------------------------------------------------------------- +// Input shape validation +// --------------------------------------------------------------------------- + +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'); + }); + + 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'); + }); + + 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 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/, + ); + }); + + 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 — run channel +// --------------------------------------------------------------------------- + +describe('styles.apply validation: run patch', () => { + 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 paragraph keys on run channel', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { justification: 'center' } as never, + }), + 'INVALID_INPUT', + /paragraph-channel/, + ); + }); + + it('throws INVALID_INPUT for completely unknown patch keys', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fakeProperty: true } as never, + }), + 'INVALID_INPUT', + ); + }); + + // 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: { bold: 'yes' as never }, + }), + 'INVALID_INPUT', + /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: { italic: 'yes' as never }, + }), + 'INVALID_INPUT', + /boolean/, + ); + }); + + // 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('rejects NaN for fontSize', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontSize: NaN } as never, + }), + 'INVALID_INPUT', + /finite integer/, + ); + }); + + it('rejects Infinity for fontSize', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { fontSize: Infinity } as never, + }), + 'INVALID_INPUT', + /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/, + ); + }); + + // 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_RUN_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_RUN_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_RUN_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_RUN_INPUT, options); + expect(result.success).toBe(true); + }); + + it('accepts undefined/null options', () => { + const adapter = makeAdapter(); + expect(() => executeStylesApply(adapter, VALID_RUN_INPUT, undefined)).not.toThrow(); + expect(() => executeStylesApply(adapter, VALID_RUN_INPUT)).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Execution delegation +// --------------------------------------------------------------------------- + +describe('styles.apply execution', () => { + it('delegates to adapter with normalized options', () => { + const adapter = makeAdapter(); + 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_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_RUN_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 }); + }); + + 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 new file mode 100644 index 0000000000..c5591247f9 --- /dev/null +++ b/packages/document-api/src/styles/styles.ts @@ -0,0 +1,579 @@ +/** + * `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'; + +// --------------------------------------------------------------------------- +// Property State 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'; + +/** 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: StylesChannel; + xmlPart: 'word/styles.xml'; + 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`. + * + * 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 type StylesApplyInput = StylesApplyRunInput | StylesApplyParagraphInput; + +/** + * 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; +} + +/** 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: StylesStateMap; + after: StylesStateMap; +} + +/** 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: StylesApplyRunInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyParagraphInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; + 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: StylesApplyRunInput, options?: StylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyParagraphInput, options?: StylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyInput, options?: StylesApplyOptions): StylesApplyReceipt; +} + +// --------------------------------------------------------------------------- +// 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_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)) { + 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 (!VALID_CHANNELS.has(target.channel as string)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `target.channel must be "run" or "paragraph", got ${JSON.stringify(target.channel)}.`, + { field: 'target.channel', value: target.channel }, + ); + } + + 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.'); + } + + if (!isRecord(patch)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'patch must be a non-null object.', { + field: 'patch', + value: patch, + }); + } + + 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 (!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}" for channel "${channel}".${detail} Allowed keys: ${[...allowedKeys].join(', ')}.`, + { field: 'patch', key }, + ); + } + + const def = getPropertyDefinition(key, channel); + if (def) validatePropertyValue(def, 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/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/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..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 @@ -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,129 @@ 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 '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; + } + 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/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'); + }); }); }); 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..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 @@ -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,67 @@ 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'), + translatedLinkedStyles: {}, + } + : undefined; + + return { + converter, + options: {}, + on: vi.fn(), + emit: vi.fn(), + } as unknown as Editor; +} + function setTrackChanges(changes: Array>): void { mockedDeps.getTrackChanges.mockReturnValue(changes as never); } @@ -1009,6 +1071,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 +1280,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..973572bfd9 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts @@ -0,0 +1,620 @@ +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'; + +// --------------------------------------------------------------------------- +// Mock editor factory +// --------------------------------------------------------------------------- + +interface XmlElement { + name: string; + type?: string; + elements?: XmlElement[]; + attributes?: Record; +} + +interface MockEditorOptions { + stylesXml?: XmlElement; + noConverter?: boolean; + collaborationProvider?: { synced?: boolean; isSynced?: boolean } | null; + translatedLinkedStyles?: Record; +} + +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'), + translatedLinkedStyles: opts.translatedLinkedStyles ?? {}, + }; + + return { + converter, + options: { + collaborationProvider: opts.collaborationProvider ?? null, + }, + on: vi.fn(), + emit: vi.fn(), + } as unknown as Parameters[0]; +} + +/** 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: [] }], + }; +} + +function runInput(patch: Record): StylesApplyInput { + return { target: { scope: 'docDefaults', channel: 'run' }, patch } as StylesApplyInput; +} + +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, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getTranslatedLinkedStyles(editor: ReturnType) { + return (editor as unknown as { converter: { translatedLinkedStyles: Record } }).converter + .translatedLinkedStyles; +} + +// --------------------------------------------------------------------------- +// Capability gate tests +// --------------------------------------------------------------------------- + +describe('styles adapter: capability gates', () => { + it('throws CAPABILITY_UNAVAILABLE when converter is missing', () => { + const editor = createMockEditor({ noConverter: true }); + expect(() => stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS)).toThrow( + DocumentApiAdapterError, + ); + try { + stylesApplyAdapter(editor, runInput({ bold: true }), 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, runInput({ bold: true }), DEFAULT_OPTIONS)).toThrow( + DocumentApiAdapterError, + ); + }); + + it('throws CAPABILITY_UNAVAILABLE when collaboration is active', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + collaborationProvider: { synced: true }, + }); + expect(() => stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS)).toThrow( + DocumentApiAdapterError, + ); + }); + + it('allows mutation when collaboration provider is not synced', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + collaborationProvider: { synced: false }, + }); + 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, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Run channel: boolean properties (bold, italic) +// --------------------------------------------------------------------------- + +describe('styles adapter: run boolean properties', () => { + it('sets bold: true on empty docDefaults', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), 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'); + } + + // Verify translatedLinkedStyles was mutated + const tls = getTranslatedLinkedStyles(editor) as { docDefaults: { runProperties: Record } }; + expect(tls.docDefaults.runProperties.bold).toBe(true); + }); + + it('sets bold: false on empty docDefaults', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ bold: false }), 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'); + } + }); + + 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.changed).toBe(true); + expect(result.before.italic).toBe('inherit'); + expect(result.after.italic).toBe('on'); + } + }); + + 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); + + 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'); + } + }); + + 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) { + expect(result.changed).toBe(false); + expect(result.before.bold).toBe('on'); + expect(result.after.bold).toBe('on'); + } + }); +}); + +// --------------------------------------------------------------------------- +// No-op semantics +// --------------------------------------------------------------------------- + +describe('styles adapter: no-op semantics', () => { + it('returns changed: false when value already matches', () => { + 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) { + expect(result.changed).toBe(false); + } + }); + + it('does not mark converter as modified on no-op', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { docDefaults: { runProperties: { bold: true } } }, + }); + const converter = (editor as unknown as { converter: { documentModified: boolean } }).converter; + stylesApplyAdapter(editor, runInput({ bold: true }), DEFAULT_OPTIONS); + expect(converter.documentModified).toBe(false); + }); + + 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', + ); + }); +}); + +// --------------------------------------------------------------------------- +// dryRun semantics +// --------------------------------------------------------------------------- + +describe('styles adapter: dryRun', () => { + it('returns predicted after-state without mutating translatedLinkedStyles', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), DRY_RUN_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 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; + stylesApplyAdapter(editor, runInput({ bold: true }), DRY_RUN_OPTIONS); + expect(converter.documentModified).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Re-render trigger +// --------------------------------------------------------------------------- + +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.changed).toBe(true); + expect(result.before.fontSize).toBe('inherit'); + expect(result.after.fontSize).toBe(24); + } + }); + + 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); + } + }); +}); + +// --------------------------------------------------------------------------- +// Run channel: object properties (fontFamily, color) +// --------------------------------------------------------------------------- + +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); + // 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 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' }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Paragraph channel: enum properties (justification) +// --------------------------------------------------------------------------- + +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(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: { justification: 'center' } }, + }, + }); + const result = stylesApplyAdapter(editor, paragraphInput({ justification: 'center' }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + 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', + }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Paragraph channel: object properties (spacing, indent) +// --------------------------------------------------------------------------- + +describe('styles adapter: paragraph object properties', () => { + it('sets spacing with merge semantics', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: { spacing: { before: 240, after: 120 } } }, + }, + }); + const result = stylesApplyAdapter( + editor, + paragraphInput({ spacing: { before: 480, lineRule: 'exact' } }), + DEFAULT_OPTIONS, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.spacing).toEqual({ before: 480, after: 120, lineRule: 'exact' }); + } + }); + + it('sets indent with merge semantics', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: { indent: { left: 720 } } }, + }, + }); + const result = stylesApplyAdapter(editor, paragraphInput({ indent: { firstLine: 720 } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.indent).toEqual({ left: 720, firstLine: 720 }); + } + }); + + it('sets indent on empty docDefaults', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, paragraphInput({ indent: { firstLine: 720 } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.before.indent).toBe('inherit'); + expect(result.after.indent).toEqual({ firstLine: 720 }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Multi-property single call +// --------------------------------------------------------------------------- + +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(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 }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Resolution metadata +// --------------------------------------------------------------------------- + +describe('styles adapter: resolution metadata', () => { + it('returns correct resolution for run channel', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ bold: true }), 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', + }); + } + }); +}); + +// --------------------------------------------------------------------------- +// 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 new file mode 100644 index 0000000000..e903381dcf --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.ts @@ -0,0 +1,273 @@ +/** + * Engine-specific adapter for `styles.apply`. + * + * 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, + 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'; + +// --------------------------------------------------------------------------- +// Local type shapes (avoids importing engine-specific modules directly) +// --------------------------------------------------------------------------- + +interface XmlElement { + name: string; + elements?: XmlElement[]; + attributes?: Record; +} + +interface ConverterForStyles { + convertedXml: Record; + translatedLinkedStyles: { + docDefaults?: { + runProperties?: Record; + paragraphProperties?: Record; + }; + }; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const STYLES_PART = 'word/styles.xml'; + +const PROPERTIES_KEY_BY_CHANNEL: Record = { + run: 'runProperties', + paragraph: 'paragraphProperties', +}; + +const XML_PATH_BY_CHANNEL: Record = { + run: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', + paragraph: 'w:styles/w:docDefaults/w:pPrDefault/w:pPr', +}; + +// --------------------------------------------------------------------------- +// State formatting helpers +// --------------------------------------------------------------------------- + +/** A single state value in a before/after receipt. */ +type StateValue = string | number | Record | 'inherit'; + +/** + * 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 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; +} + +/** + * Shallow equality check for before/after state maps. + */ +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; + } + return true; +} + +// --------------------------------------------------------------------------- +// Registry lookup +// --------------------------------------------------------------------------- + +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; +} + +// --------------------------------------------------------------------------- +// Patch application +// --------------------------------------------------------------------------- + +/** + * 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 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); + } + } + + const changed = !stateMapEquals(before, after); + return { before, after, changed }; +} + +// --------------------------------------------------------------------------- +// 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 { + const channel = input.target.channel; + + // --- 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' }, + ); + } + + 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' }, + ); + } + + // --- 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) => { + 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; + } + + // Apply patch and compute before/after + const { before, after, changed } = applyPatch(targetProps, input.patch as Record, channel); + + // Post-mutation side effects (only on real, changed mutations) + if (changed && !dryRun) { + syncDocDefaultsToConvertedXml(converter, docDefaultsTranslator as unknown as DocDefaultsTranslator); + editor.emit('stylesDefaultsChanged'); + } + + const receipt: StylesApplyReceipt = { + success: true, + changed, + resolution, + dryRun, + before, + after, + }; + + return { changed, payload: receipt }; + }, + options, + ); +} 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); + } +} 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); + }); +});