From dcab016691912a35475571e0d781c4258a74d943 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 3 Mar 2026 17:09:43 -0800 Subject: [PATCH] feat(document-api): make styles.apply registry-driven with schema/validation --- .../reference/_generated-manifest.json | 2 +- .../document-api/reference/styles/apply.mdx | 3063 ++++++++++++++++- .../src/contract/operation-registry.ts | 2 +- packages/document-api/src/contract/schemas.ts | 125 +- packages/document-api/src/index.ts | 21 +- .../document-api/src/invoke/invoke.test.ts | 2 +- packages/document-api/src/styles/apply.ts | 214 ++ packages/document-api/src/styles/index.ts | 52 + .../document-api/src/styles/registry.test.ts | 119 + packages/document-api/src/styles/registry.ts | 424 +++ packages/document-api/src/styles/schema.ts | 106 + .../document-api/src/styles/styles.test.ts | 2 +- packages/document-api/src/styles/styles.ts | 579 ---- .../src/styles/validation.test.ts | 385 +++ .../document-api/src/styles/validation.ts | 296 ++ .../styles-adapter.test.ts | 495 ++- .../document-api-adapters/styles-adapter.ts | 252 +- 17 files changed, 5173 insertions(+), 966 deletions(-) create mode 100644 packages/document-api/src/styles/apply.ts create mode 100644 packages/document-api/src/styles/index.ts create mode 100644 packages/document-api/src/styles/registry.test.ts create mode 100644 packages/document-api/src/styles/registry.ts create mode 100644 packages/document-api/src/styles/schema.ts delete mode 100644 packages/document-api/src/styles/styles.ts create mode 100644 packages/document-api/src/styles/validation.test.ts create mode 100644 packages/document-api/src/styles/validation.ts diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 6bdd798543..975bec65ea 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -496,5 +496,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "ee513c6250d785a2081789da920000cc75e2077ffd129acafd7ef7983c6518b1" + "sourceHash": "e06db0688bf06e7963ac3e777833076f02a71b4fa840cf301ca76a51efedc48f" } diff --git a/apps/docs/document-api/reference/styles/apply.mdx b/apps/docs/document-api/reference/styles/apply.mdx index adf37d5e13..74f8e1aaac 100644 --- a/apps/docs/document-api/reference/styles/apply.mdx +++ b/apps/docs/document-api/reference/styles/apply.mdx @@ -32,11 +32,35 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p | --- | --- | --- | --- | | `patch` | object | yes | | | `patch.bold` | boolean | no | | +| `patch.boldCs` | boolean | no | | +| `patch.borders` | object | no | | +| `patch.borders.color` | string | no | | +| `patch.borders.frame` | boolean | no | | +| `patch.borders.shadow` | boolean | no | | +| `patch.borders.size` | integer | no | | +| `patch.borders.space` | integer | no | | +| `patch.borders.themeColor` | string | no | | +| `patch.borders.themeShade` | string | no | | +| `patch.borders.themeTint` | string | no | | +| `patch.borders.val` | string | no | | | `patch.color` | object | no | | | `patch.color.themeColor` | string | no | | | `patch.color.themeShade` | string | no | | | `patch.color.themeTint` | string | no | | | `patch.color.val` | string | no | | +| `patch.dstrike` | boolean | no | | +| `patch.eastAsianLayout` | object | no | | +| `patch.eastAsianLayout.combine` | boolean | no | | +| `patch.eastAsianLayout.combineBrackets` | string | no | | +| `patch.eastAsianLayout.id` | integer | no | | +| `patch.eastAsianLayout.vert` | boolean | no | | +| `patch.eastAsianLayout.vertCompress` | boolean | no | | +| `patch.effect` | string | no | | +| `patch.em` | enum | no | `"none"`, `"dot"`, `"comma"`, `"circle"`, `"sesame"` | +| `patch.emboss` | boolean | no | | +| `patch.fitText` | object | no | | +| `patch.fitText.id` | integer | no | | +| `patch.fitText.val` | integer | no | | | `patch.fontFamily` | object | no | | | `patch.fontFamily.ascii` | string | no | | | `patch.fontFamily.asciiTheme` | string | no | | @@ -50,8 +74,44 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p | `patch.fontFamily.val` | string | no | | | `patch.fontSize` | integer | no | | | `patch.fontSizeCs` | integer | no | | +| `patch.iCs` | boolean | no | | +| `patch.imprint` | boolean | no | | | `patch.italic` | boolean | no | | +| `patch.kern` | integer | no | | +| `patch.lang` | object | no | | +| `patch.lang.bidi` | string | no | | +| `patch.lang.eastAsia` | string | no | | +| `patch.lang.val` | string | no | | | `patch.letterSpacing` | integer | no | | +| `patch.noProof` | boolean | no | | +| `patch.outline` | boolean | no | | +| `patch.position` | integer | no | | +| `patch.shading` | object | no | | +| `patch.shading.color` | string | no | | +| `patch.shading.fill` | string | no | | +| `patch.shading.themeColor` | string | no | | +| `patch.shading.themeFill` | string | no | | +| `patch.shading.themeFillShade` | string | no | | +| `patch.shading.themeFillTint` | string | no | | +| `patch.shading.themeShade` | string | no | | +| `patch.shading.themeTint` | string | no | | +| `patch.shading.val` | string | no | | +| `patch.shadow` | boolean | no | | +| `patch.smallCaps` | boolean | no | | +| `patch.snapToGrid` | boolean | no | | +| `patch.specVanish` | boolean | no | | +| `patch.strike` | boolean | no | | +| `patch.textTransform` | enum | no | `"uppercase"`, `"none"` | +| `patch.underline` | object | no | | +| `patch.underline.color` | string | no | | +| `patch.underline.themeColor` | string | no | | +| `patch.underline.themeShade` | string | no | | +| `patch.underline.themeTint` | string | no | | +| `patch.underline.val` | enum | no | `"single"`, `"double"`, `"thick"`, `"dotted"`, `"dottedHeavy"`, `"dash"`, `"dashedHeavy"`, `"dashLong"`, `"dashLongHeavy"`, `"dotDash"`, `"dashDotHeavy"`, `"dotDotDash"`, `"dashDotDotHeavy"`, `"wave"`, `"wavyHeavy"`, `"wavyDouble"`, `"words"`, `"none"` | +| `patch.vanish` | boolean | no | | +| `patch.vertAlign` | enum | no | `"superscript"`, `"subscript"`, `"baseline"` | +| `patch.w` | integer | no | | +| `patch.webHidden` | boolean | no | | | `target` | object(scope="docDefaults") | yes | | | `target.channel` | `"run"` | yes | Constant: `"run"` | | `target.scope` | `"docDefaults"` | yes | Constant: `"docDefaults"` | @@ -61,6 +121,87 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p | Field | Type | Required | Description | | --- | --- | --- | --- | | `patch` | object | yes | | +| `patch.adjustRightInd` | boolean | no | | +| `patch.autoSpaceDE` | boolean | no | | +| `patch.autoSpaceDN` | boolean | no | | +| `patch.borders` | object | no | | +| `patch.borders.bar` | object | no | | +| `patch.borders.bar.color` | string | no | | +| `patch.borders.bar.frame` | boolean | no | | +| `patch.borders.bar.shadow` | boolean | no | | +| `patch.borders.bar.size` | integer | no | | +| `patch.borders.bar.space` | integer | no | | +| `patch.borders.bar.themeColor` | string | no | | +| `patch.borders.bar.themeShade` | string | no | | +| `patch.borders.bar.themeTint` | string | no | | +| `patch.borders.bar.val` | string | no | | +| `patch.borders.between` | object | no | | +| `patch.borders.between.color` | string | no | | +| `patch.borders.between.frame` | boolean | no | | +| `patch.borders.between.shadow` | boolean | no | | +| `patch.borders.between.size` | integer | no | | +| `patch.borders.between.space` | integer | no | | +| `patch.borders.between.themeColor` | string | no | | +| `patch.borders.between.themeShade` | string | no | | +| `patch.borders.between.themeTint` | string | no | | +| `patch.borders.between.val` | string | no | | +| `patch.borders.bottom` | object | no | | +| `patch.borders.bottom.color` | string | no | | +| `patch.borders.bottom.frame` | boolean | no | | +| `patch.borders.bottom.shadow` | boolean | no | | +| `patch.borders.bottom.size` | integer | no | | +| `patch.borders.bottom.space` | integer | no | | +| `patch.borders.bottom.themeColor` | string | no | | +| `patch.borders.bottom.themeShade` | string | no | | +| `patch.borders.bottom.themeTint` | string | no | | +| `patch.borders.bottom.val` | string | no | | +| `patch.borders.left` | object | no | | +| `patch.borders.left.color` | string | no | | +| `patch.borders.left.frame` | boolean | no | | +| `patch.borders.left.shadow` | boolean | no | | +| `patch.borders.left.size` | integer | no | | +| `patch.borders.left.space` | integer | no | | +| `patch.borders.left.themeColor` | string | no | | +| `patch.borders.left.themeShade` | string | no | | +| `patch.borders.left.themeTint` | string | no | | +| `patch.borders.left.val` | string | no | | +| `patch.borders.right` | object | no | | +| `patch.borders.right.color` | string | no | | +| `patch.borders.right.frame` | boolean | no | | +| `patch.borders.right.shadow` | boolean | no | | +| `patch.borders.right.size` | integer | no | | +| `patch.borders.right.space` | integer | no | | +| `patch.borders.right.themeColor` | string | no | | +| `patch.borders.right.themeShade` | string | no | | +| `patch.borders.right.themeTint` | string | no | | +| `patch.borders.right.val` | string | no | | +| `patch.borders.top` | object | no | | +| `patch.borders.top.color` | string | no | | +| `patch.borders.top.frame` | boolean | no | | +| `patch.borders.top.shadow` | boolean | no | | +| `patch.borders.top.size` | integer | no | | +| `patch.borders.top.space` | integer | no | | +| `patch.borders.top.themeColor` | string | no | | +| `patch.borders.top.themeShade` | string | no | | +| `patch.borders.top.themeTint` | string | no | | +| `patch.borders.top.val` | string | no | | +| `patch.contextualSpacing` | boolean | no | | +| `patch.framePr` | object | no | | +| `patch.framePr.anchorLock` | boolean | no | | +| `patch.framePr.dropCap` | string | no | | +| `patch.framePr.h` | integer | no | | +| `patch.framePr.hAnchor` | string | no | | +| `patch.framePr.hRule` | string | no | | +| `patch.framePr.hSpace` | integer | no | | +| `patch.framePr.lines` | integer | no | | +| `patch.framePr.vAnchor` | string | no | | +| `patch.framePr.vSpace` | integer | no | | +| `patch.framePr.w` | integer | no | | +| `patch.framePr.wrap` | string | no | | +| `patch.framePr.x` | integer | no | | +| `patch.framePr.xAlign` | string | no | | +| `patch.framePr.y` | integer | no | | +| `patch.framePr.yAlign` | string | no | | | `patch.indent` | object | no | | | `patch.indent.end` | integer | no | | | `patch.indent.endChars` | integer | no | | @@ -75,6 +216,28 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p | `patch.indent.start` | integer | no | | | `patch.indent.startChars` | integer | no | | | `patch.justification` | enum | no | `"left"`, `"center"`, `"right"`, `"justify"`, `"distribute"` | +| `patch.keepLines` | boolean | no | | +| `patch.keepNext` | boolean | no | | +| `patch.kinsoku` | boolean | no | | +| `patch.mirrorIndents` | boolean | no | | +| `patch.numberingProperties` | object | no | | +| `patch.numberingProperties.ilvl` | integer | no | | +| `patch.numberingProperties.numId` | integer | no | | +| `patch.outlineLvl` | integer | no | | +| `patch.overflowPunct` | boolean | no | | +| `patch.pageBreakBefore` | boolean | no | | +| `patch.rightToLeft` | boolean | no | | +| `patch.shading` | object | no | | +| `patch.shading.color` | string | no | | +| `patch.shading.fill` | string | no | | +| `patch.shading.themeColor` | string | no | | +| `patch.shading.themeFill` | string | no | | +| `patch.shading.themeFillShade` | string | no | | +| `patch.shading.themeFillTint` | string | no | | +| `patch.shading.themeShade` | string | no | | +| `patch.shading.themeTint` | string | no | | +| `patch.shading.val` | string | no | | +| `patch.snapToGrid` | boolean | no | | | `patch.spacing` | object | no | | | `patch.spacing.after` | integer | no | | | `patch.spacing.afterAutospacing` | boolean | no | | @@ -84,6 +247,16 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p | `patch.spacing.beforeLines` | integer | no | | | `patch.spacing.line` | integer | no | | | `patch.spacing.lineRule` | enum | no | `"auto"`, `"exact"`, `"atLeast"` | +| `patch.suppressAutoHyphens` | boolean | no | | +| `patch.suppressLineNumbers` | boolean | no | | +| `patch.suppressOverlap` | boolean | no | | +| `patch.tabStops` | object[] | no | | +| `patch.textAlignment` | enum | no | `"top"`, `"center"`, `"baseline"`, `"bottom"`, `"auto"` | +| `patch.textDirection` | enum | no | `"lrTb"`, `"tbRl"`, `"btLr"`, `"lrTbV"`, `"tbRlV"`, `"tbLrV"` | +| `patch.textboxTightWrap` | enum | no | `"none"`, `"allLines"`, `"firstAndLastLine"`, `"firstLineOnly"`, `"lastLineOnly"` | +| `patch.topLinePunct` | boolean | no | | +| `patch.widowControl` | boolean | no | | +| `patch.wordWrap` | boolean | no | | | `target` | object(scope="docDefaults") | yes | | | `target.channel` | `"paragraph"` | yes | Constant: `"paragraph"` | | `target.scope` | `"docDefaults"` | yes | Constant: `"docDefaults"` | @@ -94,7 +267,7 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p { "patch": { "bold": true, - "italic": true + "boldCs": true }, "target": { "channel": "run", @@ -110,27 +283,129 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p | Field | Type | Required | Description | | --- | --- | --- | --- | | `after` | object | yes | | +| `after.adjustRightInd` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.autoSpaceDE` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.autoSpaceDN` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.bold` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.boldCs` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.borders` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `after.color` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.contextualSpacing` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.dstrike` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.eastAsianLayout` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.effect` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.em` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.emboss` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.fitText` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `after.fontFamily` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `after.fontSize` | number \\| `"inherit"` | no | One of: number, `"inherit"` | | `after.fontSizeCs` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.framePr` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.iCs` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.imprint` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.indent` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `after.italic` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.justification` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.keepLines` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.keepNext` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.kern` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.kinsoku` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.lang` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `after.letterSpacing` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.mirrorIndents` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.noProof` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.numberingProperties` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.outline` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.outlineLvl` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.overflowPunct` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.pageBreakBefore` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.position` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.rightToLeft` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.shading` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.shadow` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.smallCaps` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.snapToGrid` | enum | no | `"on"`, `"off"`, `"inherit"` | | `after.spacing` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.specVanish` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.strike` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.suppressAutoHyphens` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.suppressLineNumbers` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.suppressOverlap` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.tabStops` | array \\| `"inherit"` | no | One of: array, `"inherit"` | +| `after.textAlignment` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.textDirection` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.textTransform` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.textboxTightWrap` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.topLinePunct` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.underline` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `after.vanish` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.vertAlign` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `after.w` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `after.webHidden` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.widowControl` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `after.wordWrap` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before` | object | yes | | +| `before.adjustRightInd` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.autoSpaceDE` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.autoSpaceDN` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.bold` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.boldCs` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.borders` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `before.color` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.contextualSpacing` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.dstrike` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.eastAsianLayout` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.effect` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.em` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.emboss` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.fitText` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `before.fontFamily` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `before.fontSize` | number \\| `"inherit"` | no | One of: number, `"inherit"` | | `before.fontSizeCs` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.framePr` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.iCs` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.imprint` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.indent` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `before.italic` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.justification` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.keepLines` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.keepNext` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.kern` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.kinsoku` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.lang` | object \\| `"inherit"` | no | One of: object, `"inherit"` | | `before.letterSpacing` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.mirrorIndents` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.noProof` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.numberingProperties` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.outline` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.outlineLvl` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.overflowPunct` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.pageBreakBefore` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.position` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.rightToLeft` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.shading` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.shadow` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.smallCaps` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.snapToGrid` | enum | no | `"on"`, `"off"`, `"inherit"` | | `before.spacing` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.specVanish` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.strike` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.suppressAutoHyphens` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.suppressLineNumbers` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.suppressOverlap` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.tabStops` | array \\| `"inherit"` | no | One of: array, `"inherit"` | +| `before.textAlignment` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.textDirection` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.textTransform` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.textboxTightWrap` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.topLinePunct` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.underline` | object \\| `"inherit"` | no | One of: object, `"inherit"` | +| `before.vanish` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.vertAlign` | string \\| `"inherit"` | no | One of: string, `"inherit"` | +| `before.w` | number \\| `"inherit"` | no | One of: number, `"inherit"` | +| `before.webHidden` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.widowControl` | enum | no | `"on"`, `"off"`, `"inherit"` | +| `before.wordWrap` | enum | no | `"on"`, `"off"`, `"inherit"` | | `changed` | boolean | yes | | | `dryRun` | boolean | yes | | | `resolution` | object(scope="docDefaults") | yes | | @@ -161,11 +436,11 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p { "after": { "bold": "on", - "italic": "on" + "boldCs": "on" }, "before": { "bold": "on", - "italic": "on" + "boldCs": "on" }, "changed": true, "dryRun": true, @@ -206,21 +481,122 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "bold": { "type": "boolean" }, + "boldCs": { + "type": "boolean" + }, + "borders": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "boolean" + }, + "shadow": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "space": { + "type": "integer" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, "color": { "additionalProperties": false, "minProperties": 1, "properties": { "themeColor": { + "minLength": 1, "type": "string" }, "themeShade": { + "minLength": 1, "type": "string" }, "themeTint": { + "minLength": 1, "type": "string" }, "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "dstrike": { + "type": "boolean" + }, + "eastAsianLayout": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "combine": { + "type": "boolean" + }, + "combineBrackets": { + "minLength": 1, "type": "string" + }, + "id": { + "type": "integer" + }, + "vert": { + "type": "boolean" + }, + "vertCompress": { + "type": "boolean" + } + }, + "type": "object" + }, + "effect": { + "minLength": 1, + "type": "string" + }, + "em": { + "enum": [ + "none", + "dot", + "comma", + "circle", + "sesame" + ] + }, + "emboss": { + "type": "boolean" + }, + "fitText": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "id": { + "type": "integer" + }, + "val": { + "type": "integer" } }, "type": "object" @@ -230,33 +606,43 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "minProperties": 1, "properties": { "ascii": { + "minLength": 1, "type": "string" }, "asciiTheme": { + "minLength": 1, "type": "string" }, "cs": { + "minLength": 1, "type": "string" }, "cstheme": { + "minLength": 1, "type": "string" }, "eastAsia": { + "minLength": 1, "type": "string" }, "eastAsiaTheme": { + "minLength": 1, "type": "string" }, "hAnsi": { + "minLength": 1, "type": "string" }, "hAnsiTheme": { + "minLength": 1, "type": "string" }, "hint": { + "minLength": 1, "type": "string" }, "val": { + "minLength": 1, "type": "string" } }, @@ -268,11 +654,175 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "fontSizeCs": { "type": "integer" }, + "iCs": { + "type": "boolean" + }, + "imprint": { + "type": "boolean" + }, "italic": { "type": "boolean" }, + "kern": { + "type": "integer" + }, + "lang": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bidi": { + "minLength": 1, + "type": "string" + }, + "eastAsia": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, "letterSpacing": { "type": "integer" + }, + "noProof": { + "type": "boolean" + }, + "outline": { + "type": "boolean" + }, + "position": { + "type": "integer" + }, + "shading": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "fill": { + "minLength": 1, + "type": "string" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeFill": { + "minLength": 1, + "type": "string" + }, + "themeFillShade": { + "minLength": 1, + "type": "string" + }, + "themeFillTint": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "shadow": { + "type": "boolean" + }, + "smallCaps": { + "type": "boolean" + }, + "snapToGrid": { + "type": "boolean" + }, + "specVanish": { + "type": "boolean" + }, + "strike": { + "type": "boolean" + }, + "textTransform": { + "enum": [ + "uppercase", + "none" + ] + }, + "underline": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "enum": [ + "single", + "double", + "thick", + "dotted", + "dottedHeavy", + "dash", + "dashedHeavy", + "dashLong", + "dashLongHeavy", + "dotDash", + "dashDotHeavy", + "dotDotDash", + "dashDotDotHeavy", + "wave", + "wavyHeavy", + "wavyDouble", + "words", + "none" + ] + } + }, + "type": "object" + }, + "vanish": { + "type": "boolean" + }, + "vertAlign": { + "enum": [ + "superscript", + "subscript", + "baseline" + ] + }, + "w": { + "maximum": 600, + "minimum": 1, + "type": "integer" + }, + "webHidden": { + "type": "boolean" } }, "type": "object" @@ -307,6 +857,318 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "additionalProperties": false, "minProperties": 1, "properties": { + "adjustRightInd": { + "type": "boolean" + }, + "autoSpaceDE": { + "type": "boolean" + }, + "autoSpaceDN": { + "type": "boolean" + }, + "borders": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "bar": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "boolean" + }, + "shadow": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "space": { + "type": "integer" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "between": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "boolean" + }, + "shadow": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "space": { + "type": "integer" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "bottom": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "boolean" + }, + "shadow": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "space": { + "type": "integer" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "left": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "boolean" + }, + "shadow": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "space": { + "type": "integer" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "right": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "boolean" + }, + "shadow": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "space": { + "type": "integer" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "top": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "frame": { + "type": "boolean" + }, + "shadow": { + "type": "boolean" + }, + "size": { + "type": "integer" + }, + "space": { + "type": "integer" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "contextualSpacing": { + "type": "boolean" + }, + "framePr": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "anchorLock": { + "type": "boolean" + }, + "dropCap": { + "minLength": 1, + "type": "string" + }, + "h": { + "type": "integer" + }, + "hAnchor": { + "minLength": 1, + "type": "string" + }, + "hRule": { + "minLength": 1, + "type": "string" + }, + "hSpace": { + "type": "integer" + }, + "lines": { + "type": "integer" + }, + "vAnchor": { + "minLength": 1, + "type": "string" + }, + "vSpace": { + "type": "integer" + }, + "w": { + "type": "integer" + }, + "wrap": { + "minLength": 1, + "type": "string" + }, + "x": { + "type": "integer" + }, + "xAlign": { + "minLength": 1, + "type": "string" + }, + "y": { + "type": "integer" + }, + "yAlign": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, "indent": { "additionalProperties": false, "minProperties": 1, @@ -359,33 +1221,118 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "distribute" ] }, - "spacing": { + "keepLines": { + "type": "boolean" + }, + "keepNext": { + "type": "boolean" + }, + "kinsoku": { + "type": "boolean" + }, + "mirrorIndents": { + "type": "boolean" + }, + "numberingProperties": { "additionalProperties": false, "minProperties": 1, "properties": { - "after": { - "type": "integer" - }, - "afterAutospacing": { - "type": "boolean" - }, - "afterLines": { - "type": "integer" - }, - "before": { - "type": "integer" - }, - "beforeAutospacing": { - "type": "boolean" - }, - "beforeLines": { + "ilvl": { "type": "integer" }, - "line": { + "numId": { "type": "integer" - }, - "lineRule": { - "enum": [ + } + }, + "type": "object" + }, + "outlineLvl": { + "maximum": 9, + "minimum": 0, + "type": "integer" + }, + "overflowPunct": { + "type": "boolean" + }, + "pageBreakBefore": { + "type": "boolean" + }, + "rightToLeft": { + "type": "boolean" + }, + "shading": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "color": { + "minLength": 1, + "type": "string" + }, + "fill": { + "minLength": 1, + "type": "string" + }, + "themeColor": { + "minLength": 1, + "type": "string" + }, + "themeFill": { + "minLength": 1, + "type": "string" + }, + "themeFillShade": { + "minLength": 1, + "type": "string" + }, + "themeFillTint": { + "minLength": 1, + "type": "string" + }, + "themeShade": { + "minLength": 1, + "type": "string" + }, + "themeTint": { + "minLength": 1, + "type": "string" + }, + "val": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "snapToGrid": { + "type": "boolean" + }, + "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" @@ -393,6 +1340,80 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } }, "type": "object" + }, + "suppressAutoHyphens": { + "type": "boolean" + }, + "suppressLineNumbers": { + "type": "boolean" + }, + "suppressOverlap": { + "type": "boolean" + }, + "tabStops": { + "items": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "tab": { + "additionalProperties": false, + "minProperties": 1, + "properties": { + "leader": { + "minLength": 1, + "type": "string" + }, + "pos": { + "type": "integer" + }, + "tabType": { + "minLength": 1, + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "textAlignment": { + "enum": [ + "top", + "center", + "baseline", + "bottom", + "auto" + ] + }, + "textboxTightWrap": { + "enum": [ + "none", + "allLines", + "firstAndLastLine", + "firstLineOnly", + "lastLineOnly" + ] + }, + "textDirection": { + "enum": [ + "lrTb", + "tbRl", + "btLr", + "lrTbV", + "tbRlV", + "tbLrV" + ] + }, + "topLinePunct": { + "type": "boolean" + }, + "widowControl": { + "type": "boolean" + }, + "wordWrap": { + "type": "boolean" } }, "type": "object" @@ -435,6 +1456,27 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "after": { "additionalProperties": false, "properties": { + "adjustRightInd": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "autoSpaceDE": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "autoSpaceDN": { + "enum": [ + "on", + "off", + "inherit" + ] + }, "bold": { "enum": [ "on", @@ -442,7 +1484,14 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "inherit" ] }, - "color": { + "boldCs": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "borders": { "oneOf": [ { "type": "object" @@ -452,7 +1501,7 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "fontFamily": { + "color": { "oneOf": [ { "type": "object" @@ -462,54 +1511,78 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "fontSize": { + "contextualSpacing": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "dstrike": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "eastAsianLayout": { "oneOf": [ { - "type": "number" + "type": "object" }, { "const": "inherit" } ] }, - "fontSizeCs": { + "effect": { "oneOf": [ { - "type": "number" + "type": "string" }, { "const": "inherit" } ] }, - "indent": { + "em": { "oneOf": [ { - "type": "object" + "type": "string" }, { "const": "inherit" } ] }, - "italic": { + "emboss": { "enum": [ "on", "off", "inherit" ] }, - "justification": { + "fitText": { "oneOf": [ { - "type": "string" + "type": "object" }, { "const": "inherit" } ] }, - "letterSpacing": { + "fontFamily": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontSize": { "oneOf": [ { "type": "number" @@ -519,7 +1592,17 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "spacing": { + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "framePr": { "oneOf": [ { "type": "object" @@ -528,21 +1611,22 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "const": "inherit" } ] - } - }, - "type": "object" - }, - "before": { - "additionalProperties": false, - "properties": { - "bold": { + }, + "iCs": { "enum": [ "on", "off", "inherit" ] }, - "color": { + "imprint": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "indent": { "oneOf": [ { "type": "object" @@ -552,17 +1636,38 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "fontFamily": { + "italic": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "justification": { "oneOf": [ { - "type": "object" + "type": "string" }, { "const": "inherit" } ] }, - "fontSize": { + "keepLines": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "keepNext": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "kern": { "oneOf": [ { "type": "number" @@ -572,44 +1677,65 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "fontSizeCs": { + "kinsoku": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "lang": { "oneOf": [ { - "type": "number" + "type": "object" }, { "const": "inherit" } ] }, - "indent": { + "letterSpacing": { "oneOf": [ { - "type": "object" + "type": "number" }, { "const": "inherit" } ] }, - "italic": { + "mirrorIndents": { "enum": [ "on", "off", "inherit" ] }, - "justification": { + "noProof": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "numberingProperties": { "oneOf": [ { - "type": "string" + "type": "object" }, { "const": "inherit" } ] }, - "letterSpacing": { + "outline": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "outlineLvl": { "oneOf": [ { "type": "number" @@ -619,151 +1745,1496 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "spacing": { + "overflowPunct": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "pageBreakBefore": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "position": { "oneOf": [ { - "type": "object" + "type": "number" }, { "const": "inherit" } ] - } + }, + "rightToLeft": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "shading": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "shadow": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "smallCaps": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "snapToGrid": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "spacing": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "specVanish": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "strike": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressAutoHyphens": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressLineNumbers": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressOverlap": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "tabStops": { + "oneOf": [ + { + "type": "array" + }, + { + "const": "inherit" + } + ] + }, + "textAlignment": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "textboxTightWrap": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "textDirection": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "textTransform": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "topLinePunct": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "underline": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "vanish": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "vertAlign": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "w": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "webHidden": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "widowControl": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "wordWrap": { + "enum": [ + "on", + "off", + "inherit" + ] + } }, "type": "object" }, - "changed": { - "type": "boolean" + "before": { + "additionalProperties": false, + "properties": { + "adjustRightInd": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "autoSpaceDE": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "autoSpaceDN": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "bold": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "boldCs": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "borders": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "color": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "contextualSpacing": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "dstrike": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "eastAsianLayout": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "effect": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "em": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "emboss": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "fitText": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontFamily": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "framePr": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "iCs": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "imprint": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "indent": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "italic": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "justification": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "keepLines": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "keepNext": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "kern": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "kinsoku": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "lang": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "mirrorIndents": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "noProof": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "numberingProperties": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "outline": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "outlineLvl": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "overflowPunct": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "pageBreakBefore": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "position": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "rightToLeft": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "shading": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "shadow": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "smallCaps": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "snapToGrid": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "spacing": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "specVanish": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "strike": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressAutoHyphens": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressLineNumbers": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressOverlap": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "tabStops": { + "oneOf": [ + { + "type": "array" + }, + { + "const": "inherit" + } + ] + }, + "textAlignment": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "textboxTightWrap": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "textDirection": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "textTransform": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "topLinePunct": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "underline": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "vanish": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "vertAlign": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "w": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "webHidden": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "widowControl": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "wordWrap": { + "enum": [ + "on", + "off", + "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": { + "adjustRightInd": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "autoSpaceDE": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "autoSpaceDN": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "bold": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "boldCs": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "borders": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "color": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "contextualSpacing": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "dstrike": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "eastAsianLayout": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "effect": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "em": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "emboss": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "fitText": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontFamily": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "fontSize": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "fontSizeCs": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "framePr": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "iCs": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "imprint": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "indent": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "italic": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "justification": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "keepLines": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "keepNext": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "kern": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "kinsoku": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "lang": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "mirrorIndents": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "noProof": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "numberingProperties": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "outline": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "outlineLvl": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "overflowPunct": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "pageBreakBefore": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "position": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "rightToLeft": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "shading": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "shadow": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "smallCaps": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "snapToGrid": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "spacing": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "specVanish": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "strike": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressAutoHyphens": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressLineNumbers": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressOverlap": { + "enum": [ + "on", + "off", + "inherit" + ] }, - "dryRun": { - "type": "boolean" + "tabStops": { + "oneOf": [ + { + "type": "array" + }, + { + "const": "inherit" + } + ] }, - "resolution": { - "additionalProperties": false, - "properties": { - "channel": { - "enum": [ - "run", - "paragraph" - ] + "textAlignment": { + "oneOf": [ + { + "type": "string" }, - "scope": { - "const": "docDefaults" + { + "const": "inherit" + } + ] + }, + "textboxTightWrap": { + "oneOf": [ + { + "type": "string" }, - "xmlPart": { - "const": "word/styles.xml" + { + "const": "inherit" + } + ] + }, + "textDirection": { + "oneOf": [ + { + "type": "string" }, - "xmlPath": { - "enum": [ - "w:styles/w:docDefaults/w:rPrDefault/w:rPr", - "w:styles/w:docDefaults/w:pPrDefault/w:pPr" - ] + { + "const": "inherit" } - }, - "required": [ - "scope", - "channel", - "xmlPart", - "xmlPath" - ], - "type": "object" + ] }, - "success": { - "const": true + "textTransform": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "topLinePunct": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "underline": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "vanish": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "vertAlign": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "w": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "webHidden": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "widowControl": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "wordWrap": { + "enum": [ + "on", + "off", + "inherit" + ] } }, - "required": [ - "success", - "changed", - "resolution", - "dryRun", - "before", - "after" - ], "type": "object" }, - { + "before": { "additionalProperties": false, "properties": { - "failure": { - "additionalProperties": false, - "properties": { - "code": { - "type": "string" + "adjustRightInd": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "autoSpaceDE": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "autoSpaceDN": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "bold": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "boldCs": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "borders": { + "oneOf": [ + { + "type": "object" }, - "details": {}, - "message": { - "type": "string" + { + "const": "inherit" } - }, - "required": [ - "code", - "message" - ], - "type": "object" + ] }, - "resolution": { - "additionalProperties": false, - "properties": { - "channel": { - "enum": [ - "run", - "paragraph" - ] + "color": { + "oneOf": [ + { + "type": "object" }, - "scope": { - "const": "docDefaults" + { + "const": "inherit" + } + ] + }, + "contextualSpacing": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "dstrike": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "eastAsianLayout": { + "oneOf": [ + { + "type": "object" }, - "xmlPart": { - "const": "word/styles.xml" + { + "const": "inherit" + } + ] + }, + "effect": { + "oneOf": [ + { + "type": "string" }, - "xmlPath": { - "enum": [ - "w:styles/w:docDefaults/w:rPrDefault/w:rPr", - "w:styles/w:docDefaults/w:pPrDefault/w:pPr" - ] + { + "const": "inherit" } - }, - "required": [ - "scope", - "channel", - "xmlPart", - "xmlPath" - ], - "type": "object" + ] + }, + "em": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] }, - "success": { - "const": false - } - }, - "required": [ - "success", - "resolution", - "failure" - ], - "type": "object" - } - ] -} -``` - - - -```json -{ - "additionalProperties": false, - "properties": { - "after": { - "additionalProperties": false, - "properties": { - "bold": { + "emboss": { "enum": [ "on", "off", "inherit" ] }, - "color": { + "fitText": { "oneOf": [ { "type": "object" @@ -803,6 +3274,30 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, + "framePr": { + "oneOf": [ + { + "type": "object" + }, + { + "const": "inherit" + } + ] + }, + "iCs": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "imprint": { + "enum": [ + "on", + "off", + "inherit" + ] + }, "indent": { "oneOf": [ { @@ -830,7 +3325,21 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "letterSpacing": { + "keepLines": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "keepNext": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "kern": { "oneOf": [ { "type": "number" @@ -840,7 +3349,14 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "spacing": { + "kinsoku": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "lang": { "oneOf": [ { "type": "object" @@ -849,21 +3365,32 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "const": "inherit" } ] - } - }, - "type": "object" - }, - "before": { - "additionalProperties": false, - "properties": { - "bold": { + }, + "letterSpacing": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "mirrorIndents": { "enum": [ "on", "off", "inherit" ] }, - "color": { + "noProof": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "numberingProperties": { "oneOf": [ { "type": "object" @@ -873,17 +3400,38 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "fontFamily": { + "outline": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "outlineLvl": { "oneOf": [ { - "type": "object" + "type": "number" }, { "const": "inherit" } ] }, - "fontSize": { + "overflowPunct": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "pageBreakBefore": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "position": { "oneOf": [ { "type": "number" @@ -893,17 +3441,45 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "fontSizeCs": { + "rightToLeft": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "shading": { "oneOf": [ { - "type": "number" + "type": "object" }, { "const": "inherit" } ] }, - "indent": { + "shadow": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "smallCaps": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "snapToGrid": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "spacing": { "oneOf": [ { "type": "object" @@ -913,14 +3489,52 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "italic": { + "specVanish": { "enum": [ "on", "off", "inherit" ] }, - "justification": { + "strike": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressAutoHyphens": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressLineNumbers": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "suppressOverlap": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "tabStops": { + "oneOf": [ + { + "type": "array" + }, + { + "const": "inherit" + } + ] + }, + "textAlignment": { "oneOf": [ { "type": "string" @@ -930,17 +3544,44 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p } ] }, - "letterSpacing": { + "textboxTightWrap": { "oneOf": [ { - "type": "number" + "type": "string" }, { "const": "inherit" } ] }, - "spacing": { + "textDirection": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "textTransform": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "topLinePunct": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "underline": { "oneOf": [ { "type": "object" @@ -949,6 +3590,54 @@ Returns a StylesApplyReceipt with per-channel success/failure details for each p "const": "inherit" } ] + }, + "vanish": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "vertAlign": { + "oneOf": [ + { + "type": "string" + }, + { + "const": "inherit" + } + ] + }, + "w": { + "oneOf": [ + { + "type": "number" + }, + { + "const": "inherit" + } + ] + }, + "webHidden": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "widowControl": { + "enum": [ + "on", + "off", + "inherit" + ] + }, + "wordWrap": { + "enum": [ + "on", + "off", + "inherit" + ] } }, "type": "object" diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index f564d98d6c..b75497dfba 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -31,7 +31,7 @@ import type { DeleteInput } from '../delete/delete.js'; import type { MutationOptions, RevisionGuardOptions } from '../write/write.js'; import type { FormatInlineAliasInput, StyleApplyInput } from '../format/format.js'; import type { InlineRunPatchKey } from '../format/inline-run-patch.js'; -import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt } from '../styles/styles.js'; +import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt } from '../styles/index.js'; import type { CommentsCreateInput, CommentsPatchInput, diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 75fe2e1535..a1cc02b0a7 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -11,6 +11,7 @@ import { CLEAR_BORDER_SIDES, LINE_RULES, } from '../paragraphs/paragraphs.js'; +import { buildPatchSchema, buildStateSchema } from '../styles/index.js'; type JsonSchema = Record; @@ -1851,120 +1852,25 @@ const operationSchemas: Record = { failure: paragraphMutationFailureSchemaFor('format.paragraph.clearShading'), }, '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) --- + // Derived from PROPERTY_REGISTRY — no hardcoded property lists 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, - }, + patch: buildPatchSchema('run'), }, ['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, - }, + patch: buildPatchSchema('paragraph'), }, ['target', 'patch'], ); - // --- Resolution: discriminated by channel with concrete xmlPath values --- const stylesTargetResolutionSchema = objectSchema( { scope: { const: 'docDefaults' }, @@ -1975,27 +1881,7 @@ const operationSchemas: Record = { ['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 stylesStateSchema = buildStateSchema(); const stylesSuccessSchema = objectSchema( { @@ -2024,7 +1910,6 @@ const operationSchemas: Record = { ['success', 'resolution', 'failure'], ); return { - // Discriminated input: oneOf with channel as the discriminator input: { oneOf: [runInputSchema, paragraphInputSchema] }, output: { oneOf: [stylesSuccessSchema, stylesFailureSchema] }, success: stylesSuccessSchema, diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index c969851ec9..a1ab30e63d 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -63,8 +63,8 @@ import type { StylesApplyInput, StylesApplyOptions, StylesApplyReceipt, -} from './styles/styles.js'; -import { executeStylesApply } from './styles/styles.js'; +} from './styles/index.js'; +import { executeStylesApply } from './styles/index.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'; @@ -380,10 +380,19 @@ export { validateInlineRunPatch, buildInlineRunPatchSchema, } from './format/inline-run-patch.js'; -export { PROPERTY_REGISTRY } from './styles/styles.js'; +export { + PROPERTY_REGISTRY, + EXCLUDED_KEYS, + ALLOWED_KEYS_BY_CHANNEL, + getPropertyDefinition, + toJsonSchema, + buildPatchSchema, + buildStateSchema, +} from './styles/index.js'; export type { + ValueSchema, + MergeStrategy, PropertyDefinition, - ObjectSchema, StylesAdapter, StylesApplyInput, StylesApplyRunInput, @@ -394,16 +403,16 @@ export type { StylesNumberState, StylesEnumState, StylesObjectState, + StylesArrayState, StylesStateMap, StylesChannel, - StylesJustification, StylesRunPatch, StylesParagraphPatch, StylesTargetResolution, StylesApplyReceiptSuccess, StylesApplyReceiptFailure, NormalizedStylesApplyOptions, -} from './styles/styles.js'; +} from './styles/index.js'; export type { CreateAdapter } from './create/create.js'; export type { TrackChangesAdapter, diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index f970464755..5f3f0336e5 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -6,7 +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 { StylesAdapter } from '../styles/index.js'; import type { TrackChangesAdapter } from '../track-changes/track-changes.js'; import type { CreateAdapter } from '../create/create.js'; import type { ListsAdapter } from '../lists/lists.js'; diff --git a/packages/document-api/src/styles/apply.ts b/packages/document-api/src/styles/apply.ts new file mode 100644 index 0000000000..e854f85517 --- /dev/null +++ b/packages/document-api/src/styles/apply.ts @@ -0,0 +1,214 @@ +/** + * `styles.apply` — stylesheet mutation for document-level defaults. + * + * Defines the contract types, validation orchestration, and execution entry point. + * Engine-agnostic: no ProseMirror, Yjs, or converter imports. + * + * Imports: registry.ts (types/data), validation.ts (validation functions). + */ + +import type { ReceiptFailure } from '../types/receipt.js'; +import type { StylesChannel } from './registry.js'; +import { XML_PATH_BY_CHANNEL } from './registry.js'; +import { validateStylesApplyInput, validateStylesApplyOptions } from './validation.js'; + +// --------------------------------------------------------------------------- +// State Types (before/after receipts) +// --------------------------------------------------------------------------- + +/** Tri-state for OOXML boolean style properties. */ +export type StylesBooleanState = 'on' | 'off' | 'inherit'; +export type StylesNumberState = number | 'inherit'; +export type StylesEnumState = string | 'inherit'; +export type StylesObjectState = Record | 'inherit'; +export type StylesArrayState = unknown[] | 'inherit'; + +// --------------------------------------------------------------------------- +// Patch Types +// --------------------------------------------------------------------------- + +/** Patch for run-channel properties (docDefaults/w:rPrDefault/w:rPr). */ +export interface StylesRunPatch { + // Booleans + bold?: boolean; + boldCs?: boolean; + italic?: boolean; + iCs?: boolean; + smallCaps?: boolean; + strike?: boolean; + dstrike?: boolean; + emboss?: boolean; + imprint?: boolean; + outline?: boolean; + shadow?: boolean; + vanish?: boolean; + webHidden?: boolean; + specVanish?: boolean; + snapToGrid?: boolean; + noProof?: boolean; + // Integers + fontSize?: number; + fontSizeCs?: number; + letterSpacing?: number; + kern?: number; + position?: number; + w?: number; + // Enums + textTransform?: string; + vertAlign?: string; + em?: string; + // Strings + effect?: string; + // Objects + fontFamily?: Record; + color?: Record; + underline?: Record; + borders?: Record; + shading?: Record; + lang?: Record; + eastAsianLayout?: Record; + fitText?: Record; +} + +/** Patch for paragraph-channel properties (docDefaults/w:pPrDefault/w:pPr). */ +export interface StylesParagraphPatch { + // Booleans + keepLines?: boolean; + keepNext?: boolean; + widowControl?: boolean; + contextualSpacing?: boolean; + pageBreakBefore?: boolean; + suppressAutoHyphens?: boolean; + suppressLineNumbers?: boolean; + suppressOverlap?: boolean; + mirrorIndents?: boolean; + wordWrap?: boolean; + kinsoku?: boolean; + overflowPunct?: boolean; + topLinePunct?: boolean; + autoSpaceDE?: boolean; + autoSpaceDN?: boolean; + adjustRightInd?: boolean; + rightToLeft?: boolean; + snapToGrid?: boolean; + // Integers + outlineLvl?: number; + // Enums + justification?: string; + textAlignment?: string; + textDirection?: string; + textboxTightWrap?: string; + // Objects + spacing?: Record; + indent?: Record; + shading?: Record; + numberingProperties?: Record; + framePr?: Record; + borders?: Record; + // Arrays + tabStops?: unknown[]; +} + +// --------------------------------------------------------------------------- +// Resolution Metadata +// --------------------------------------------------------------------------- + +export interface StylesTargetResolution { + scope: 'docDefaults'; + channel: StylesChannel; + xmlPart: 'word/styles.xml'; + xmlPath: string; +} + +// --------------------------------------------------------------------------- +// Input / Output Types +// --------------------------------------------------------------------------- + +export interface StylesApplyRunInput { + target: { scope: 'docDefaults'; channel: 'run' }; + patch: StylesRunPatch; +} + +export interface StylesApplyParagraphInput { + target: { scope: 'docDefaults'; channel: 'paragraph' }; + patch: StylesParagraphPatch; +} + +export type StylesApplyInput = StylesApplyRunInput | StylesApplyParagraphInput; + +export interface StylesApplyOptions { + dryRun?: boolean; + expectedRevision?: string; +} + +export type StylesStateMap = Record< + string, + StylesBooleanState | StylesNumberState | StylesEnumState | StylesObjectState | StylesArrayState +>; + +export interface StylesApplyReceiptSuccess { + success: true; + changed: boolean; + resolution: StylesTargetResolution; + dryRun: boolean; + before: StylesStateMap; + after: StylesStateMap; +} + +export interface StylesApplyReceiptFailure { + success: false; + resolution: StylesTargetResolution; + failure: ReceiptFailure; +} + +export type StylesApplyReceipt = StylesApplyReceiptSuccess | StylesApplyReceiptFailure; + +// --------------------------------------------------------------------------- +// Adapter Interface +// --------------------------------------------------------------------------- + +export interface StylesAdapter { + apply(input: StylesApplyRunInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyParagraphInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyInput, options: NormalizedStylesApplyOptions): StylesApplyReceipt; +} + +export interface NormalizedStylesApplyOptions { + dryRun: boolean; + expectedRevision: string | undefined; +} + +// --------------------------------------------------------------------------- +// Public API surface +// --------------------------------------------------------------------------- + +export interface StylesApi { + apply(input: StylesApplyRunInput, options?: StylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyParagraphInput, options?: StylesApplyOptions): StylesApplyReceipt; + apply(input: StylesApplyInput, options?: StylesApplyOptions): StylesApplyReceipt; +} + +// --------------------------------------------------------------------------- +// Execution +// --------------------------------------------------------------------------- + +function normalizeOptions(options?: StylesApplyOptions): NormalizedStylesApplyOptions { + return { + dryRun: options?.dryRun ?? false, + expectedRevision: options?.expectedRevision, + }; +} + +/** + * 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, normalizeOptions(options)); +} diff --git a/packages/document-api/src/styles/index.ts b/packages/document-api/src/styles/index.ts new file mode 100644 index 0000000000..a662cccbdf --- /dev/null +++ b/packages/document-api/src/styles/index.ts @@ -0,0 +1,52 @@ +/** + * Barrel re-export for styles/ submodules. + * + * Public surface only — internal validation/schema helpers are not exposed. + */ + +// Registry: types, constants, and property definitions +export type { ValueSchema, StylesChannel, MergeStrategy, PropertyDefinition } from './registry.js'; +export { + PROPERTY_REGISTRY, + ALLOWED_KEYS_BY_CHANNEL, + EXCLUDED_KEYS, + XML_PATH_BY_CHANNEL, + getPropertyDefinition, + ST_VERTICAL_ALIGN_RUN, + ST_EM, + ST_TEXT_ALIGNMENT, + ST_TEXT_DIRECTION, + ST_TEXTBOX_TIGHT_WRAP, + ST_TEXT_TRANSFORM, + ST_JUSTIFICATION, +} from './registry.js'; + +// Schema: JSON Schema builders (consumed by contract/schemas.ts) +export { toJsonSchema, buildPatchSchema, buildStateSchema } from './schema.js'; + +// Apply: types, interfaces, and execution +export type { + StylesBooleanState, + StylesNumberState, + StylesEnumState, + StylesObjectState, + StylesArrayState, + StylesRunPatch, + StylesParagraphPatch, + StylesTargetResolution, + StylesApplyRunInput, + StylesApplyParagraphInput, + StylesApplyInput, + StylesApplyOptions, + StylesStateMap, + StylesApplyReceiptSuccess, + StylesApplyReceiptFailure, + StylesApplyReceipt, + StylesAdapter, + NormalizedStylesApplyOptions, + StylesApi, +} from './apply.js'; +export { executeStylesApply } from './apply.js'; + +// Validation: exported for adapter use (excluded-key checking) +export { validateValue } from './validation.js'; diff --git a/packages/document-api/src/styles/registry.test.ts b/packages/document-api/src/styles/registry.test.ts new file mode 100644 index 0000000000..351f247d60 --- /dev/null +++ b/packages/document-api/src/styles/registry.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { PROPERTY_REGISTRY, EXCLUDED_KEYS } from './registry.js'; + +// --------------------------------------------------------------------------- +// SD-2018 coverage gate — machine-checked completeness assertion +// --------------------------------------------------------------------------- + +describe('SD-2018 coverage gate', () => { + const registryKeys = (channel: string) => + PROPERTY_REGISTRY.filter((d) => d.channel === channel) + .map((d) => d.key) + .sort(); + + it('run channel contains exactly the SD-2018 property set', () => { + expect(registryKeys('run')).toEqual( + [ + 'bold', + 'boldCs', + 'borders', + 'color', + 'dstrike', + 'eastAsianLayout', + 'effect', + 'em', + 'emboss', + 'fitText', + 'fontFamily', + 'fontSize', + 'fontSizeCs', + 'iCs', + 'imprint', + 'kern', + 'lang', + 'letterSpacing', + 'italic', + 'noProof', + 'outline', + 'position', + 'shading', + 'shadow', + 'smallCaps', + 'snapToGrid', + 'specVanish', + 'strike', + 'textTransform', + 'underline', + 'vanish', + 'vertAlign', + 'w', + 'webHidden', + ].sort(), + ); + }); + + it('paragraph channel contains exactly the SD-2018 property set', () => { + expect(registryKeys('paragraph')).toEqual( + [ + 'adjustRightInd', + 'autoSpaceDE', + 'autoSpaceDN', + 'borders', + 'contextualSpacing', + 'framePr', + 'indent', + 'justification', + 'keepLines', + 'keepNext', + 'kinsoku', + 'mirrorIndents', + 'numberingProperties', + 'outlineLvl', + 'overflowPunct', + 'pageBreakBefore', + 'rightToLeft', + 'shading', + 'snapToGrid', + 'spacing', + 'suppressAutoHyphens', + 'suppressLineNumbers', + 'suppressOverlap', + 'tabStops', + 'textAlignment', + 'textDirection', + 'textboxTightWrap', + 'topLinePunct', + 'widowControl', + 'wordWrap', + ].sort(), + ); + }); + + it('excluded keys are exactly the SD-2018 exclusion set', () => { + expect([...EXCLUDED_KEYS.run.keys()].sort()).toEqual( + ['cs', 'highlight', 'oMath', 'rPrChange', 'rStyle', 'rtl'].sort(), + ); + expect([...EXCLUDED_KEYS.paragraph.keys()].sort()).toEqual( + ['cnfStyle', 'divId', 'pPrChange', 'pStyle', 'runProperties', 'sectPr'].sort(), + ); + }); + + it('no duplicate keys in the registry', () => { + const seen = new Set(); + for (const def of PROPERTY_REGISTRY) { + const id = `${def.channel}:${def.key}`; + expect(seen.has(id), `Duplicate registry entry: ${id}`).toBe(false); + seen.add(id); + } + }); + + it('every registry entry has a valid mergeStrategy', () => { + const validStrategies = new Set(['replace', 'shallowMerge', 'edgeMerge']); + for (const def of PROPERTY_REGISTRY) { + expect( + validStrategies.has(def.mergeStrategy), + `${def.channel}.${def.key} has invalid mergeStrategy: ${def.mergeStrategy}`, + ).toBe(true); + } + }); +}); diff --git a/packages/document-api/src/styles/registry.ts b/packages/document-api/src/styles/registry.ts new file mode 100644 index 0000000000..7f0bf4141b --- /dev/null +++ b/packages/document-api/src/styles/registry.ts @@ -0,0 +1,424 @@ +/** + * Single source of truth for `styles.apply` property definitions. + * + * This module is the leaf of the styles/ dependency graph — it imports nothing + * from validation.ts, schema.ts, or apply.ts. All other styles modules import + * from here. + */ + +import { ST_UNDERLINE_VALUES } from '../inline-semantics/token-sets.js'; + +// --------------------------------------------------------------------------- +// OOXML Token Constants +// --------------------------------------------------------------------------- + +export const ST_VERTICAL_ALIGN_RUN = ['superscript', 'subscript', 'baseline'] as const; +export const ST_EM = ['none', 'dot', 'comma', 'circle', 'sesame'] as const; +export const ST_TEXT_ALIGNMENT = ['top', 'center', 'baseline', 'bottom', 'auto'] as const; +export const ST_TEXT_DIRECTION = ['lrTb', 'tbRl', 'btLr', 'lrTbV', 'tbRlV', 'tbLrV'] as const; +export const ST_TEXTBOX_TIGHT_WRAP = ['none', 'allLines', 'firstAndLastLine', 'firstLineOnly', 'lastLineOnly'] as const; +export const ST_TEXT_TRANSFORM = ['uppercase', 'none'] as const; +export const ST_JUSTIFICATION = ['left', 'center', 'right', 'justify', 'distribute'] as const; +export { ST_UNDERLINE_VALUES }; + +// --------------------------------------------------------------------------- +// Value Schema AST +// --------------------------------------------------------------------------- + +/** Recursive schema AST describing the shape of a property value. */ +export type ValueSchema = + | { kind: 'boolean' } + | { kind: 'integer'; min?: number; max?: number } + | { kind: 'enum'; values: readonly string[] } + | { kind: 'string' } + | { kind: 'object'; children: Record } + | { kind: 'array'; item: ValueSchema }; + +// --------------------------------------------------------------------------- +// Property Definition +// --------------------------------------------------------------------------- + +export type StylesChannel = 'run' | 'paragraph'; + +export type MergeStrategy = 'replace' | 'shallowMerge' | 'edgeMerge'; + +export interface PropertyDefinition { + key: string; + channel: StylesChannel; + schema: ValueSchema; + mergeStrategy: MergeStrategy; +} + +// --------------------------------------------------------------------------- +// Reusable Schema Fragments +// --------------------------------------------------------------------------- + +const FONT_FAMILY_SCHEMA: ValueSchema = { + kind: 'object', + children: { + hint: { kind: 'string' }, + ascii: { kind: 'string' }, + hAnsi: { kind: 'string' }, + eastAsia: { kind: 'string' }, + cs: { kind: 'string' }, + val: { kind: 'string' }, + asciiTheme: { kind: 'string' }, + hAnsiTheme: { kind: 'string' }, + eastAsiaTheme: { kind: 'string' }, + cstheme: { kind: 'string' }, + }, +}; + +const COLOR_SCHEMA: ValueSchema = { + kind: 'object', + children: { + val: { kind: 'string' }, + themeColor: { kind: 'string' }, + themeTint: { kind: 'string' }, + themeShade: { kind: 'string' }, + }, +}; + +const SPACING_SCHEMA: ValueSchema = { + kind: 'object', + children: { + after: { kind: 'integer' }, + afterAutospacing: { kind: 'boolean' }, + afterLines: { kind: 'integer' }, + before: { kind: 'integer' }, + beforeAutospacing: { kind: 'boolean' }, + beforeLines: { kind: 'integer' }, + line: { kind: 'integer' }, + lineRule: { kind: 'enum', values: ['auto', 'exact', 'atLeast'] }, + }, +}; + +const INDENT_SCHEMA: ValueSchema = { + kind: 'object', + children: { + end: { kind: 'integer' }, + endChars: { kind: 'integer' }, + firstLine: { kind: 'integer' }, + firstLineChars: { kind: 'integer' }, + hanging: { kind: 'integer' }, + hangingChars: { kind: 'integer' }, + left: { kind: 'integer' }, + leftChars: { kind: 'integer' }, + right: { kind: 'integer' }, + rightChars: { kind: 'integer' }, + start: { kind: 'integer' }, + startChars: { kind: 'integer' }, + }, +}; + +const UNDERLINE_SCHEMA: ValueSchema = { + kind: 'object', + children: { + val: { kind: 'enum', values: [...ST_UNDERLINE_VALUES] }, + color: { kind: 'string' }, + themeColor: { kind: 'string' }, + themeTint: { kind: 'string' }, + themeShade: { kind: 'string' }, + }, +}; + +const BORDER_PROPERTIES_SCHEMA: ValueSchema = { + kind: 'object', + children: { + val: { kind: 'string' }, + color: { kind: 'string' }, + themeColor: { kind: 'string' }, + themeTint: { kind: 'string' }, + themeShade: { kind: 'string' }, + size: { kind: 'integer' }, + space: { kind: 'integer' }, + shadow: { kind: 'boolean' }, + frame: { kind: 'boolean' }, + }, +}; + +const SHADING_SCHEMA: ValueSchema = { + kind: 'object', + children: { + color: { kind: 'string' }, + fill: { kind: 'string' }, + themeColor: { kind: 'string' }, + themeFill: { kind: 'string' }, + themeFillShade: { kind: 'string' }, + themeFillTint: { kind: 'string' }, + themeShade: { kind: 'string' }, + themeTint: { kind: 'string' }, + val: { kind: 'string' }, + }, +}; + +const LANG_SCHEMA: ValueSchema = { + kind: 'object', + children: { + val: { kind: 'string' }, + eastAsia: { kind: 'string' }, + bidi: { kind: 'string' }, + }, +}; + +const EAST_ASIAN_LAYOUT_SCHEMA: ValueSchema = { + kind: 'object', + children: { + id: { kind: 'integer' }, + combine: { kind: 'boolean' }, + combineBrackets: { kind: 'string' }, + vert: { kind: 'boolean' }, + vertCompress: { kind: 'boolean' }, + }, +}; + +const FIT_TEXT_SCHEMA: ValueSchema = { + kind: 'object', + children: { + val: { kind: 'integer' }, + id: { kind: 'integer' }, + }, +}; + +const NUMBERING_PROPERTIES_SCHEMA: ValueSchema = { + kind: 'object', + children: { + ilvl: { kind: 'integer' }, + numId: { kind: 'integer' }, + }, +}; + +const FRAME_PR_SCHEMA: ValueSchema = { + kind: 'object', + children: { + anchorLock: { kind: 'boolean' }, + dropCap: { kind: 'string' }, + h: { kind: 'integer' }, + hAnchor: { kind: 'string' }, + hRule: { kind: 'string' }, + hSpace: { kind: 'integer' }, + lines: { kind: 'integer' }, + vAnchor: { kind: 'string' }, + vSpace: { kind: 'integer' }, + w: { kind: 'integer' }, + wrap: { kind: 'string' }, + x: { kind: 'integer' }, + xAlign: { kind: 'string' }, + y: { kind: 'integer' }, + yAlign: { kind: 'string' }, + }, +}; + +const PARAGRAPH_BORDERS_SCHEMA: ValueSchema = { + kind: 'object', + children: { + top: BORDER_PROPERTIES_SCHEMA, + bottom: BORDER_PROPERTIES_SCHEMA, + left: BORDER_PROPERTIES_SCHEMA, + right: BORDER_PROPERTIES_SCHEMA, + between: BORDER_PROPERTIES_SCHEMA, + bar: BORDER_PROPERTIES_SCHEMA, + }, +}; + +const TAB_STOP_SCHEMA: ValueSchema = { + kind: 'array', + item: { + kind: 'object', + children: { + tab: { + kind: 'object', + children: { + tabType: { kind: 'string' }, + pos: { kind: 'integer' }, + leader: { kind: 'string' }, + }, + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Property Registry — single source of truth for styles.apply properties +// --------------------------------------------------------------------------- + +export const PROPERTY_REGISTRY: PropertyDefinition[] = [ + // ------------------------------------------------------------------------- + // Run channel + // ------------------------------------------------------------------------- + + // Booleans + { key: 'bold', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'boldCs', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'italic', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'iCs', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'smallCaps', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'strike', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'dstrike', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'emboss', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'imprint', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'outline', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'shadow', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'vanish', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'webHidden', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'specVanish', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'snapToGrid', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'noProof', channel: 'run', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + + // Integers + { key: 'fontSize', channel: 'run', schema: { kind: 'integer' }, mergeStrategy: 'replace' }, + { key: 'fontSizeCs', channel: 'run', schema: { kind: 'integer' }, mergeStrategy: 'replace' }, + { key: 'letterSpacing', channel: 'run', schema: { kind: 'integer' }, mergeStrategy: 'replace' }, + { key: 'kern', channel: 'run', schema: { kind: 'integer' }, mergeStrategy: 'replace' }, + { key: 'position', channel: 'run', schema: { kind: 'integer' }, mergeStrategy: 'replace' }, + { key: 'w', channel: 'run', schema: { kind: 'integer', min: 1, max: 600 }, mergeStrategy: 'replace' }, + + // Enums + { + key: 'textTransform', + channel: 'run', + schema: { kind: 'enum', values: [...ST_TEXT_TRANSFORM] }, + mergeStrategy: 'replace', + }, + { + key: 'vertAlign', + channel: 'run', + schema: { kind: 'enum', values: [...ST_VERTICAL_ALIGN_RUN] }, + mergeStrategy: 'replace', + }, + { key: 'em', channel: 'run', schema: { kind: 'enum', values: [...ST_EM] }, mergeStrategy: 'replace' }, + + // Strings (deprecated/unconstrained) + { key: 'effect', channel: 'run', schema: { kind: 'string' }, mergeStrategy: 'replace' }, + + // Objects (shallow merge) + { key: 'fontFamily', channel: 'run', schema: FONT_FAMILY_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'color', channel: 'run', schema: COLOR_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'underline', channel: 'run', schema: UNDERLINE_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'borders', channel: 'run', schema: BORDER_PROPERTIES_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'shading', channel: 'run', schema: SHADING_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'lang', channel: 'run', schema: LANG_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'eastAsianLayout', channel: 'run', schema: EAST_ASIAN_LAYOUT_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'fitText', channel: 'run', schema: FIT_TEXT_SCHEMA, mergeStrategy: 'shallowMerge' }, + + // ------------------------------------------------------------------------- + // Paragraph channel + // ------------------------------------------------------------------------- + + // Booleans + { key: 'keepLines', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'keepNext', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'widowControl', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'contextualSpacing', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'pageBreakBefore', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'suppressAutoHyphens', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'suppressLineNumbers', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'suppressOverlap', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'mirrorIndents', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'wordWrap', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'kinsoku', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'overflowPunct', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'topLinePunct', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'autoSpaceDE', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'autoSpaceDN', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'adjustRightInd', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'rightToLeft', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + { key: 'snapToGrid', channel: 'paragraph', schema: { kind: 'boolean' }, mergeStrategy: 'replace' }, + + // Integers + { key: 'outlineLvl', channel: 'paragraph', schema: { kind: 'integer', min: 0, max: 9 }, mergeStrategy: 'replace' }, + + // Enums + { + key: 'justification', + channel: 'paragraph', + schema: { kind: 'enum', values: [...ST_JUSTIFICATION] }, + mergeStrategy: 'replace', + }, + { + key: 'textAlignment', + channel: 'paragraph', + schema: { kind: 'enum', values: [...ST_TEXT_ALIGNMENT] }, + mergeStrategy: 'replace', + }, + { + key: 'textDirection', + channel: 'paragraph', + schema: { kind: 'enum', values: [...ST_TEXT_DIRECTION] }, + mergeStrategy: 'replace', + }, + { + key: 'textboxTightWrap', + channel: 'paragraph', + schema: { kind: 'enum', values: [...ST_TEXTBOX_TIGHT_WRAP] }, + mergeStrategy: 'replace', + }, + + // Objects (shallow merge) + { key: 'spacing', channel: 'paragraph', schema: SPACING_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'indent', channel: 'paragraph', schema: INDENT_SCHEMA, mergeStrategy: 'shallowMerge' }, + { key: 'shading', channel: 'paragraph', schema: SHADING_SCHEMA, mergeStrategy: 'shallowMerge' }, + { + key: 'numberingProperties', + channel: 'paragraph', + schema: NUMBERING_PROPERTIES_SCHEMA, + mergeStrategy: 'shallowMerge', + }, + { key: 'framePr', channel: 'paragraph', schema: FRAME_PR_SCHEMA, mergeStrategy: 'shallowMerge' }, + + // Nested objects (edge merge) + { key: 'borders', channel: 'paragraph', schema: PARAGRAPH_BORDERS_SCHEMA, mergeStrategy: 'edgeMerge' }, + + // Arrays (full replace) + { key: 'tabStops', channel: 'paragraph', schema: TAB_STOP_SCHEMA, mergeStrategy: 'replace' }, +]; + +// --------------------------------------------------------------------------- +// Derived Lookup Maps +// --------------------------------------------------------------------------- + +/** Allowed patch keys per channel, derived from the registry. */ +export 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)), +}; + +/** Index for O(1) property definition lookup. */ +const PROPERTY_INDEX = new Map(PROPERTY_REGISTRY.map((d) => [`${d.channel}:${d.key}`, d])); + +/** Lookup a property definition by key and channel. */ +export function getPropertyDefinition(key: string, channel: StylesChannel): PropertyDefinition | undefined { + return PROPERTY_INDEX.get(`${channel}:${key}`); +} + +// --------------------------------------------------------------------------- +// Excluded Keys — intentionally disallowed in Word docDefaults +// --------------------------------------------------------------------------- + +export const EXCLUDED_KEYS: Record> = { + run: new Map([ + ['cs', 'w:cs'], + ['highlight', 'w:highlight'], + ['oMath', 'w:oMath'], + ['rPrChange', 'w:rPrChange'], + ['rStyle', 'w:rStyle'], + ['rtl', 'w:rtl'], + ]), + paragraph: new Map([ + ['cnfStyle', 'w:cnfStyle'], + ['divId', 'w:divId'], + ['pPrChange', 'w:pPrChange'], + ['pStyle', 'w:pStyle'], + ['runProperties', 'w:pPr/w:rPr'], + ['sectPr', 'w:sectPr'], + ]), +}; + +// --------------------------------------------------------------------------- +// Target Resolution +// --------------------------------------------------------------------------- + +export const XML_PATH_BY_CHANNEL: Record = { + run: 'w:styles/w:docDefaults/w:rPrDefault/w:rPr', + paragraph: 'w:styles/w:docDefaults/w:pPrDefault/w:pPr', +}; diff --git a/packages/document-api/src/styles/schema.ts b/packages/document-api/src/styles/schema.ts new file mode 100644 index 0000000000..6953727e70 --- /dev/null +++ b/packages/document-api/src/styles/schema.ts @@ -0,0 +1,106 @@ +/** + * JSON Schema generation from the ValueSchema AST. + * + * Imports only from registry.ts. Consumed by contract/schemas.ts. + */ + +import type { ValueSchema, StylesChannel } from './registry.js'; +import { PROPERTY_REGISTRY } from './registry.js'; + +type JsonSchema = Record; + +// --------------------------------------------------------------------------- +// ValueSchema → JSON Schema conversion (recursive) +// --------------------------------------------------------------------------- + +/** Converts a ValueSchema AST node to a JSON Schema object. */ +export function toJsonSchema(schema: ValueSchema): JsonSchema { + switch (schema.kind) { + case 'boolean': + return { type: 'boolean' }; + + case 'integer': { + const s: JsonSchema = { type: 'integer' }; + if (schema.min !== undefined) s.minimum = schema.min; + if (schema.max !== undefined) s.maximum = schema.max; + return s; + } + + case 'enum': + return { enum: [...schema.values] }; + + case 'string': + return { type: 'string', minLength: 1 }; + + case 'object': { + const properties: Record = {}; + for (const [key, childSchema] of Object.entries(schema.children)) { + properties[key] = toJsonSchema(childSchema); + } + return { + type: 'object', + properties, + additionalProperties: false, + minProperties: 1, + }; + } + + case 'array': + return { + type: 'array', + items: toJsonSchema(schema.item), + }; + } +} + +// --------------------------------------------------------------------------- +// Registry → patch schemas (for contract/schemas.ts) +// --------------------------------------------------------------------------- + +/** Builds a JSON Schema for the patch object of a given channel. */ +export function buildPatchSchema(channel: StylesChannel): JsonSchema { + const properties: Record = {}; + for (const def of PROPERTY_REGISTRY) { + if (def.channel !== channel) continue; + properties[def.key] = toJsonSchema(def.schema); + } + return { + type: 'object', + properties, + additionalProperties: false, + minProperties: 1, + }; +} + +/** Builds a JSON Schema for the before/after state map covering all registry keys. */ +export function buildStateSchema(): JsonSchema { + const properties: Record = {}; + + for (const def of PROPERTY_REGISTRY) { + const schema = def.schema; + switch (schema.kind) { + case 'boolean': + properties[def.key] = { enum: ['on', 'off', 'inherit'] }; + break; + case 'integer': + properties[def.key] = { oneOf: [{ type: 'number' }, { const: 'inherit' }] }; + break; + case 'enum': + case 'string': + properties[def.key] = { oneOf: [{ type: 'string' }, { const: 'inherit' }] }; + break; + case 'object': + properties[def.key] = { oneOf: [{ type: 'object' }, { const: 'inherit' }] }; + break; + case 'array': + properties[def.key] = { oneOf: [{ type: 'array' }, { const: 'inherit' }] }; + break; + } + } + + return { + type: 'object', + properties, + additionalProperties: false, + }; +} diff --git a/packages/document-api/src/styles/styles.test.ts b/packages/document-api/src/styles/styles.test.ts index 6d3da60f81..d60cf64312 100644 --- a/packages/document-api/src/styles/styles.test.ts +++ b/packages/document-api/src/styles/styles.test.ts @@ -5,7 +5,7 @@ import { type StylesApplyInput, type StylesApplyOptions, type StylesApplyReceipt, -} from './styles.js'; +} from './index.js'; import { DocumentApiValidationError } from '../errors.js'; // --------------------------------------------------------------------------- diff --git a/packages/document-api/src/styles/styles.ts b/packages/document-api/src/styles/styles.ts deleted file mode 100644 index c5591247f9..0000000000 --- a/packages/document-api/src/styles/styles.ts +++ /dev/null @@ -1,579 +0,0 @@ -/** - * `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/document-api/src/styles/validation.test.ts b/packages/document-api/src/styles/validation.test.ts new file mode 100644 index 0000000000..3410445e2d --- /dev/null +++ b/packages/document-api/src/styles/validation.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + executeStylesApply, + PROPERTY_REGISTRY, + EXCLUDED_KEYS, + type StylesAdapter, + type StylesApplyReceipt, + type ValueSchema, +} from './index.js'; +import { DocumentApiValidationError } from '../errors.js'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +function makeAdapter(): 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: {}, + after: {}, + }), + ), + }; +} + +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); + } + } +} + +/** Generates a valid test value for a given schema. */ +function validValueForSchema(schema: ValueSchema): unknown { + switch (schema.kind) { + case 'boolean': + return true; + case 'integer': + return schema.min ?? 1; + case 'enum': + return schema.values[0]; + case 'string': + return 'test'; + case 'object': { + const firstKey = Object.keys(schema.children)[0]; + return { [firstKey]: validValueForSchema(schema.children[firstKey]) }; + } + case 'array': + return []; // Empty array is always valid + } +} + +/** Generates an invalid test value for a given schema. */ +function invalidValueForSchema(schema: ValueSchema): unknown { + switch (schema.kind) { + case 'boolean': + return 'not-a-boolean'; + case 'integer': + return 'not-a-number'; + case 'enum': + return 'INVALID_ENUM_VALUE'; + case 'string': + return 42; + case 'object': + return 'not-an-object'; + case 'array': + return 'not-an-array'; + } +} + +// --------------------------------------------------------------------------- +// Registry-driven acceptance tests +// --------------------------------------------------------------------------- + +describe('styles.apply validation: registry-driven property acceptance', () => { + for (const def of PROPERTY_REGISTRY) { + it(`accepts valid ${def.channel}.${def.key} (${def.schema.kind})`, () => { + const adapter = makeAdapter(); + const value = validValueForSchema(def.schema); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: def.channel }, + patch: { [def.key]: value }, + }), + ).not.toThrow(); + }); + } +}); + +// --------------------------------------------------------------------------- +// Registry-driven rejection tests +// --------------------------------------------------------------------------- + +describe('styles.apply validation: registry-driven type rejection', () => { + for (const def of PROPERTY_REGISTRY) { + it(`rejects invalid ${def.channel}.${def.key} type`, () => { + const adapter = makeAdapter(); + const value = invalidValueForSchema(def.schema); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: def.channel }, + patch: { [def.key]: value }, + }), + 'INVALID_INPUT', + ); + }); + } +}); + +// --------------------------------------------------------------------------- +// Excluded-key tests +// --------------------------------------------------------------------------- + +describe('styles.apply validation: excluded keys', () => { + for (const [channel, keys] of Object.entries(EXCLUDED_KEYS) as [string, Map][]) { + for (const [key, xmlPath] of keys) { + it(`rejects excluded key "${key}" on ${channel} with reason 'excluded_docdefaults_key'`, () => { + const adapter = makeAdapter(); + try { + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: channel as 'run' | 'paragraph' }, + patch: { [key]: true }, + }); + throw new Error('Expected error'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentApiValidationError); + const e = err as DocumentApiValidationError; + expect(e.code).toBe('INVALID_INPUT'); + expect(e.message).toContain(key); + expect(e.message).toContain('docDefaults'); + expect((e as unknown as { details: Record }).details?.reason).toBe( + 'excluded_docdefaults_key', + ); + } + }); + } + } +}); + +// --------------------------------------------------------------------------- +// Manual edge-case tests +// --------------------------------------------------------------------------- + +describe('styles.apply validation: manual edge cases', () => { + // Integer range boundaries + it('accepts outlineLvl: 0 (minimum)', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { outlineLvl: 0 }, + }), + ).not.toThrow(); + }); + + it('accepts outlineLvl: 9 (maximum)', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { outlineLvl: 9 }, + }), + ).not.toThrow(); + }); + + it('rejects outlineLvl: 10 (above maximum)', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { outlineLvl: 10 }, + }), + 'INVALID_INPUT', + /<= 9/, + ); + }); + + it('rejects outlineLvl: -1 (below minimum)', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { outlineLvl: -1 }, + }), + 'INVALID_INPUT', + />= 0/, + ); + }); + + it('accepts w: 1 (minimum character scaling)', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { w: 1 }, + }), + ).not.toThrow(); + }); + + it('accepts w: 600 (maximum character scaling)', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { w: 600 }, + }), + ).not.toThrow(); + }); + + it('rejects w: 601 (above maximum)', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { w: 601 }, + }), + 'INVALID_INPUT', + /<= 600/, + ); + }); + + it('rejects w: 0 (below minimum)', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { w: 0 }, + }), + 'INVALID_INPUT', + />= 1/, + ); + }); + + // underline.val token validation + it('accepts valid underline.val token', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { underline: { val: 'single' } }, + }), + ).not.toThrow(); + }); + + it('rejects invalid underline.val token', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { underline: { val: 'invalid-style' } }, + }), + 'INVALID_INPUT', + /must be one of/, + ); + }); + + // tabStops: [] (empty array is legal) + it('accepts tabStops: [] (empty array clears tab stops)', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { tabStops: [] }, + }), + ).not.toThrow(); + }); + + // tabStops: valid non-empty array + it('accepts valid tabStops array', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { tabStops: [{ tab: { tabType: 'left', pos: 720 } }] }, + }), + ).not.toThrow(); + }); + + // tabStops: invalid item + it('rejects tabStops with invalid item structure', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { tabStops: ['invalid'] }, + }), + 'INVALID_INPUT', + ); + }); + + // Nested paragraph borders validation + it('accepts valid paragraph borders with edge sub-keys', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { borders: { top: { val: 'single', size: 4 } } }, + }), + ).not.toThrow(); + }); + + it('rejects paragraph borders with unknown edge key', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'paragraph' }, + patch: { borders: { invalid: { val: 'single' } } }, + }), + 'INVALID_INPUT', + /Unknown key/, + ); + }); + + // String property validation + it('accepts valid effect string', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { effect: 'blinkBackground' }, + }), + ).not.toThrow(); + }); + + it('rejects empty string for effect', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { effect: '' }, + }), + 'INVALID_INPUT', + /non-empty string/, + ); + }); + + // Mixed-type object sub-keys (eastAsianLayout) + it('accepts eastAsianLayout with mixed sub-key types', () => { + const adapter = makeAdapter(); + expect(() => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { eastAsianLayout: { id: 1, combine: true, vert: false } }, + }), + ).not.toThrow(); + }); + + it('rejects eastAsianLayout with wrong sub-key type', () => { + const adapter = makeAdapter(); + expectValidationError( + () => + executeStylesApply(adapter, { + target: { scope: 'docDefaults', channel: 'run' }, + patch: { eastAsianLayout: { id: 'not-a-number' } }, + }), + 'INVALID_INPUT', + /finite integer/, + ); + }); +}); diff --git a/packages/document-api/src/styles/validation.ts b/packages/document-api/src/styles/validation.ts new file mode 100644 index 0000000000..f9ab3c1862 --- /dev/null +++ b/packages/document-api/src/styles/validation.ts @@ -0,0 +1,296 @@ +/** + * Validation functions for `styles.apply` input. + * + * Walks the ValueSchema AST recursively — one function handles all depths. + * Imports only from registry.ts. + */ + +import { DocumentApiValidationError } from '../errors.js'; +import { isRecord } from '../validation-primitives.js'; +import type { ValueSchema, StylesChannel } from './registry.js'; +import { PROPERTY_REGISTRY, ALLOWED_KEYS_BY_CHANNEL, EXCLUDED_KEYS, getPropertyDefinition } from './registry.js'; + +// --------------------------------------------------------------------------- +// Recursive ValueSchema validation +// --------------------------------------------------------------------------- + +/** + * Validates a value against a ValueSchema AST node. + * + * @param path - Dot-delimited path for error messages (e.g. "patch.borders.top.size") + * @param value - The value to validate + * @param schema - The schema to validate against + */ +export function validateValue(path: string, value: unknown, schema: ValueSchema): void { + switch (schema.kind) { + case 'boolean': + if (typeof value !== 'boolean') { + throw new DocumentApiValidationError('INVALID_INPUT', `${path} must be a boolean, got ${typeof value}.`, { + field: path, + value, + }); + } + return; + + case 'integer': { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${path} must be a finite integer, got ${JSON.stringify(value)}.`, + { field: path, value }, + ); + } + if (schema.min !== undefined && value < schema.min) { + throw new DocumentApiValidationError('INVALID_INPUT', `${path} must be >= ${schema.min}, got ${value}.`, { + field: path, + value, + }); + } + if (schema.max !== undefined && value > schema.max) { + throw new DocumentApiValidationError('INVALID_INPUT', `${path} must be <= ${schema.max}, got ${value}.`, { + field: path, + value, + }); + } + return; + } + + case 'enum': + if (typeof value !== 'string' || !schema.values.includes(value)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${path} must be one of: ${schema.values.join(', ')}. Got ${JSON.stringify(value)}.`, + { field: path, value }, + ); + } + return; + + case 'string': + if (typeof value !== 'string' || value.length === 0) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `${path} must be a non-empty string, got ${JSON.stringify(value)}.`, + { field: path, value }, + ); + } + return; + + case 'object': + validateObjectValue(path, value, schema.children); + return; + + case 'array': + validateArrayValue(path, value, schema.item); + return; + } +} + +function validateObjectValue(path: string, value: unknown, children: Record): void { + if (!isRecord(value)) { + throw new DocumentApiValidationError('INVALID_INPUT', `${path} must be a non-null object, got ${typeof value}.`, { + field: path, + value, + }); + } + + const allowedKeys = new Set(Object.keys(children)); + const keys = Object.keys(value); + + if (keys.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', `${path} must include at least one property.`, { + field: path, + }); + } + + for (const key of keys) { + if (!allowedKeys.has(key)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown key "${key}" on ${path}. Allowed keys: ${[...allowedKeys].join(', ')}.`, + { field: path, key }, + ); + } + validateValue(`${path}.${key}`, value[key], children[key]); + } +} + +function validateArrayValue(path: string, value: unknown, itemSchema: ValueSchema): void { + if (!Array.isArray(value)) { + throw new DocumentApiValidationError('INVALID_INPUT', `${path} must be an array, got ${typeof value}.`, { + field: path, + value, + }); + } + + // Empty arrays are legal (e.g. tabStops: [] means "clear all") + for (let i = 0; i < value.length; i++) { + validateValue(`${path}[${i}]`, value[i], itemSchema); + } +} + +// --------------------------------------------------------------------------- +// Top-level input / options validation +// --------------------------------------------------------------------------- + +export type StylesApplyInputShape = { + target: { scope: 'docDefaults'; channel: StylesChannel }; + patch: Record; +}; + +const INPUT_ALLOWED_KEYS = new Set(['target', 'patch']); +const TARGET_ALLOWED_KEYS = new Set(['scope', 'channel']); +const OPTIONS_ALLOWED_KEYS = new Set(['dryRun', 'expectedRevision']); +const VALID_CHANNELS = new Set(['run', 'paragraph']); + +export function validateStylesApplyInput(input: unknown): asserts input is StylesApplyInputShape { + if (!isRecord(input)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'styles.apply input must be a non-null object.'); + } + + assertNoUnknownFields(input, INPUT_ALLOWED_KEYS); + + // --- Target --- + 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, + }); + } + + assertNoUnknownFields(target, 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 --- + if (patch === undefined || patch === null) { + throw new DocumentApiValidationError('INVALID_INPUT', 'styles.apply requires a patch object.'); + } + if (!isRecord(patch)) { + throw new DocumentApiValidationError('INVALID_INPUT', 'patch must be a non-null object.', { + field: 'patch', + value: patch, + }); + } + + const patchKeys = Object.keys(patch); + if (patchKeys.length === 0) { + throw new DocumentApiValidationError('INVALID_INPUT', 'patch must include at least one property.'); + } + + const allowedKeys = ALLOWED_KEYS_BY_CHANNEL[channel]; + const otherChannel: StylesChannel = channel === 'run' ? 'paragraph' : 'run'; + + for (const key of patchKeys) { + // 1. Check excluded keys first + const excludedEntry = EXCLUDED_KEYS[channel].get(key); + if (excludedEntry !== undefined) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch key '${key}' is not valid in Word docDefaults (${excludedEntry}). This is an intentional restriction per MS-OI29500.`, + { field: 'patch', key, reason: 'excluded_docdefaults_key' }, + ); + } + + // 2. Check cross-channel + if (!allowedKeys.has(key)) { + const belongsToOther = ALLOWED_KEYS_BY_CHANNEL[otherChannel].has(key); + if (belongsToOther) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown patch key "${key}" for channel "${channel}". "${key}" is a ${otherChannel}-channel property. Allowed keys: ${[...allowedKeys].join(', ')}.`, + { field: 'patch', key }, + ); + } + + // 3. Check excluded on other channel + const otherExcluded = EXCLUDED_KEYS[otherChannel].get(key); + if (otherExcluded !== undefined) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `patch key '${key}' is not valid in Word docDefaults (${otherExcluded}). This is an intentional restriction per MS-OI29500.`, + { field: 'patch', key, reason: 'excluded_docdefaults_key' }, + ); + } + + // 4. Completely unknown + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown patch key "${key}" for channel "${channel}". Allowed keys: ${[...allowedKeys].join(', ')}.`, + { field: 'patch', key }, + ); + } + + // Validate the value against the registry schema + const def = getPropertyDefinition(key, channel); + if (def) validateValue(`patch.${key}`, patch[key], def.schema); + } +} + +export 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 (!OPTIONS_ALLOWED_KEYS.has(key)) { + throw new DocumentApiValidationError( + 'INVALID_INPUT', + `Unknown options key "${key}". Allowed keys: ${[...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 assertNoUnknownFields(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 }, + ); + } + } +} diff --git a/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts index 973572bfd9..0fe9a4be32 100644 --- a/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import type { StylesApplyInput, NormalizedStylesApplyOptions } from '@superdoc/document-api'; +import type { StylesApplyInput, NormalizedStylesApplyOptions, ValueSchema } from '@superdoc/document-api'; +import { PROPERTY_REGISTRY } from '@superdoc/document-api'; import { stylesApplyAdapter } from './styles-adapter.js'; import { DocumentApiAdapterError } from './errors.js'; @@ -407,6 +408,21 @@ describe('styles adapter: run object properties', () => { expect(result.after.color).toEqual({ val: 'FF0000', themeColor: 'text1' }); } }); + + it('preserves themeColor token case when patching color.themeColor directly', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter(editor, runInput({ color: { themeColor: 'accent1' } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.color).toEqual({ themeColor: 'accent1' }); + } + + const tls = getTranslatedLinkedStyles(editor) as { + docDefaults: { runProperties: { color: Record } }; + }; + expect(tls.docDefaults.runProperties.color.themeColor).toBe('accent1'); + }); }); // --------------------------------------------------------------------------- @@ -618,3 +634,480 @@ describe('styles adapter: data loss guard', () => { expect(tls.docDefaults.runProperties.italic).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Registry-driven baseline tests (SD-2018 property coverage) +// --------------------------------------------------------------------------- + +/** Generates a valid test value for a given schema kind. */ +function validValueForSchema(schema: ValueSchema): unknown { + switch (schema.kind) { + case 'boolean': + return true; + case 'integer': + return schema.min ?? 1; + case 'enum': + return schema.values[0]; + case 'string': + return 'test-value'; + case 'object': { + const firstKey = Object.keys(schema.children)[0]; + return { [firstKey]: validValueForSchema(schema.children[firstKey]) }; + } + case 'array': + return []; + } +} + +describe('styles adapter: registry-driven set from inherit', () => { + for (const def of PROPERTY_REGISTRY) { + it(`${def.channel}.${def.key}: set from inherit → value produces changed: true`, () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const value = validValueForSchema(def.schema); + const inputFn = def.channel === 'run' ? runInput : paragraphInput; + const result = stylesApplyAdapter(editor, inputFn({ [def.key]: value }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(true); + expect(result.before[def.key]).toBe('inherit'); + expect(result.after[def.key]).not.toBe('inherit'); + } + }); + } +}); + +describe('styles adapter: registry-driven idempotent no-op', () => { + for (const def of PROPERTY_REGISTRY) { + it(`${def.channel}.${def.key}: set same value → changed: false`, () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const value = validValueForSchema(def.schema); + const inputFn = def.channel === 'run' ? runInput : paragraphInput; + + // First apply: sets and normalizes the value + stylesApplyAdapter(editor, inputFn({ [def.key]: value }), DEFAULT_OPTIONS); + + // Second apply: same value — should be a no-op + const result = stylesApplyAdapter(editor, inputFn({ [def.key]: value }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(false); + } + }); + } +}); + +describe('styles adapter: registry-driven dryRun', () => { + for (const def of PROPERTY_REGISTRY) { + it(`${def.channel}.${def.key}: dryRun mirrors state but does not mutate`, () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const value = validValueForSchema(def.schema); + const inputFn = def.channel === 'run' ? runInput : paragraphInput; + const result = stylesApplyAdapter(editor, inputFn({ [def.key]: value }), DRY_RUN_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.dryRun).toBe(true); + expect(result.changed).toBe(true); + expect(result.before[def.key]).toBe('inherit'); + } + + // Storage should NOT be mutated + const tls = getTranslatedLinkedStyles(editor) as Record; + expect(tls.docDefaults).toBeUndefined(); + }); + } +}); + +// --------------------------------------------------------------------------- +// Merge strategy: shallowMerge +// --------------------------------------------------------------------------- + +describe('styles adapter: shallowMerge preserves unspecified sub-keys', () => { + it('shading: partial patch preserves existing sub-keys', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { runProperties: { shading: { fill: 'FFFFFF', val: 'clear' } } }, + }, + }); + const result = stylesApplyAdapter(editor, runInput({ shading: { fill: '000000' } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.shading).toEqual({ fill: '000000', val: 'clear' }); + } + }); + + it('lang: partial patch preserves existing sub-keys', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { runProperties: { lang: { val: 'en-US', eastAsia: 'ja-JP' } } }, + }, + }); + const result = stylesApplyAdapter(editor, runInput({ lang: { val: 'fr-FR' } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.lang).toEqual({ val: 'fr-FR', eastAsia: 'ja-JP' }); + } + }); + + it('run borders: partial patch preserves existing sub-keys', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { runProperties: { borders: { val: 'single', size: 4 } } }, + }, + }); + const result = stylesApplyAdapter(editor, runInput({ borders: { color: 'FF0000' } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.borders).toEqual({ val: 'single', size: 4, color: 'FF0000' }); + } + }); + + it('paragraph framePr: partial patch preserves existing sub-keys', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: { framePr: { w: 100, h: 200, wrap: 'around' } } }, + }, + }); + const result = stylesApplyAdapter(editor, paragraphInput({ framePr: { h: 300 } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.after.framePr).toEqual({ w: 100, h: 300, wrap: 'around' }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Merge strategy: edgeMerge (paragraph borders) +// --------------------------------------------------------------------------- + +describe('styles adapter: edgeMerge for paragraph borders', () => { + it('patches one edge, preserves other edges', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { + paragraphProperties: { + borders: { + top: { val: 'single', size: 4 }, + bottom: { val: 'double', size: 8 }, + }, + }, + }, + }, + }); + const result = stylesApplyAdapter( + editor, + paragraphInput({ borders: { top: { color: 'FF0000' } } }), + DEFAULT_OPTIONS, + ); + + expect(result.success).toBe(true); + if (result.success) { + const borders = result.after.borders as Record; + // top: merged — val and size preserved, color added + expect(borders.top).toEqual({ val: 'single', size: 4, color: 'FF0000' }); + // bottom: untouched + expect(borders.bottom).toEqual({ val: 'double', size: 8 }); + } + }); + + it('adds new edge alongside existing edges', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { + paragraphProperties: { + borders: { top: { val: 'single' } }, + }, + }, + }, + }); + const result = stylesApplyAdapter( + editor, + paragraphInput({ borders: { left: { val: 'double', size: 6 } } }), + DEFAULT_OPTIONS, + ); + + expect(result.success).toBe(true); + if (result.success) { + const borders = result.after.borders as Record; + expect(borders.top).toEqual({ val: 'single' }); + expect(borders.left).toEqual({ val: 'double', size: 6 }); + } + }); + + it('no-op when same nested border values applied', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { + paragraphProperties: { + borders: { top: { val: 'single', size: 4 } }, + }, + }, + }, + }); + const result = stylesApplyAdapter( + editor, + paragraphInput({ borders: { top: { val: 'single', size: 4 } } }), + DEFAULT_OPTIONS, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(false); + } + }); +}); + +// --------------------------------------------------------------------------- +// Merge strategy: replace for arrays (tabStops) +// --------------------------------------------------------------------------- + +describe('styles adapter: array replace for tabStops', () => { + it('sets tabStops array from empty', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const tabs = [{ tab: { tabType: 'left', pos: 720 } }]; + const result = stylesApplyAdapter(editor, paragraphInput({ tabStops: tabs }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.before.tabStops).toBe('inherit'); + expect(result.after.tabStops).toEqual(tabs); + } + }); + + it('replaces entire tabStops array', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { + paragraphProperties: { + tabStops: [{ tab: { tabType: 'left', pos: 720 } }], + }, + }, + }, + }); + const newTabs = [{ tab: { tabType: 'right', pos: 1440 } }]; + const result = stylesApplyAdapter(editor, paragraphInput({ tabStops: newTabs }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(true); + expect(result.after.tabStops).toEqual(newTabs); + } + }); + + it('clears tabStops with empty array', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { + paragraphProperties: { + tabStops: [{ tab: { tabType: 'left', pos: 720 } }], + }, + }, + }, + }); + const result = stylesApplyAdapter(editor, paragraphInput({ tabStops: [] }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(true); + expect(result.after.tabStops).toEqual([]); + } + }); + + it('no-op for same tabStops array', () => { + const tabs = [{ tab: { tabType: 'left', pos: 720 } }]; + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: { tabStops: structuredClone(tabs) } }, + }, + }); + const result = stylesApplyAdapter(editor, paragraphInput({ tabStops: tabs }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.changed).toBe(false); + } + }); +}); + +// --------------------------------------------------------------------------- +// Underline key mapping +// --------------------------------------------------------------------------- + +describe('styles adapter: underline key mapping', () => { + it('maps API keys to w: prefixed storage keys on write', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const result = stylesApplyAdapter( + editor, + runInput({ underline: { val: 'single', color: 'FF0000' } }), + DEFAULT_OPTIONS, + ); + + expect(result.success).toBe(true); + + // Verify storage uses w: prefixed keys + const tls = getTranslatedLinkedStyles(editor) as { + docDefaults: { runProperties: Record }; + }; + const stored = tls.docDefaults.runProperties.underline as Record; + expect(stored['w:val']).toBe('single'); + expect(stored['w:color']).toBe('FF0000'); + }); + + it('maps w: prefixed storage keys to API keys in receipt', () => { + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { + runProperties: { + underline: { 'w:val': 'single', 'w:themeColor': 'accent1' }, + }, + }, + }, + }); + const result = stylesApplyAdapter(editor, runInput({ underline: { color: 'FF0000' } }), DEFAULT_OPTIONS); + + expect(result.success).toBe(true); + if (result.success) { + // Before state should use API keys, not storage keys + const before = result.before.underline as Record; + expect(before.val).toBe('single'); + expect(before.themeColor).toBe('accent1'); + expect(before['w:val']).toBeUndefined(); + + // After state should also use API keys + const after = result.after.underline as Record; + expect(after.val).toBe('single'); + expect(after.color).toBe('FF0000'); + expect(after.themeColor).toBe('accent1'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Dry-run immutability (structuredClone guarantee) +// --------------------------------------------------------------------------- + +describe('styles adapter: dry-run immutability', () => { + it('does not mutate nested objects during dry-run (paragraph borders)', () => { + const original = { + borders: { + top: { val: 'single', size: 4 }, + bottom: { val: 'double', size: 8 }, + }, + }; + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: original }, + }, + }); + + stylesApplyAdapter(editor, paragraphInput({ borders: { top: { color: 'FF0000' } } }), DRY_RUN_OPTIONS); + + // Original borders should be completely untouched + expect(original.borders.top).toEqual({ val: 'single', size: 4 }); + expect(original.borders.bottom).toEqual({ val: 'double', size: 8 }); + }); + + it('does not mutate arrays during dry-run (tabStops)', () => { + const original = { + tabStops: [{ tab: { tabType: 'left', pos: 720 } }], + }; + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { paragraphProperties: original }, + }, + }); + + stylesApplyAdapter( + editor, + paragraphInput({ tabStops: [{ tab: { tabType: 'right', pos: 1440 } }] }), + DRY_RUN_OPTIONS, + ); + + // Original tabStops should be completely untouched + expect(original.tabStops).toEqual([{ tab: { tabType: 'left', pos: 720 } }]); + }); + + it('does not mutate mapped-key objects during dry-run (underline)', () => { + const original = { + underline: { 'w:val': 'single', 'w:color': '000000' }, + }; + const editor = createMockEditor({ + stylesXml: makeStylesXml(), + translatedLinkedStyles: { + docDefaults: { runProperties: original }, + }, + }); + + stylesApplyAdapter(editor, runInput({ underline: { color: 'FF0000' } }), DRY_RUN_OPTIONS); + + // Original underline should be completely untouched + expect(original.underline).toEqual({ 'w:val': 'single', 'w:color': '000000' }); + }); +}); + +// --------------------------------------------------------------------------- +// Input aliasing guard (adapter should not retain caller-owned references) +// --------------------------------------------------------------------------- + +describe('styles adapter: input aliasing guard', () => { + it('does not retain caller array references for replace merges', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const tabStops = [{ tab: { tabType: 'left', pos: 720 } }]; + + stylesApplyAdapter(editor, paragraphInput({ tabStops }), DEFAULT_OPTIONS); + + tabStops[0].tab.pos = 9999; + + const tls = getTranslatedLinkedStyles(editor) as { + docDefaults: { paragraphProperties: { tabStops: Array<{ tab: { tabType: string; pos: number } }> } }; + }; + expect(tls.docDefaults.paragraphProperties.tabStops[0].tab.pos).toBe(720); + }); + + it('does not retain caller object references for shallow merges', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const underlinePatch = { val: 'single', color: 'FF0000' }; + + stylesApplyAdapter(editor, runInput({ underline: underlinePatch }), DEFAULT_OPTIONS); + + underlinePatch.color = '00FF00'; + + const tls = getTranslatedLinkedStyles(editor) as { + docDefaults: { runProperties: { underline: Record } }; + }; + expect(tls.docDefaults.runProperties.underline['w:color']).toBe('FF0000'); + }); + + it('does not retain caller object references for edge merges', () => { + const editor = createMockEditor({ stylesXml: makeStylesXml() }); + const bordersPatch = { top: { val: 'single', size: 4 } }; + + stylesApplyAdapter(editor, paragraphInput({ borders: bordersPatch }), DEFAULT_OPTIONS); + + bordersPatch.top.size = 12; + + const tls = getTranslatedLinkedStyles(editor) as { + docDefaults: { paragraphProperties: { borders: Record> } }; + }; + expect(tls.docDefaults.paragraphProperties.borders.top.size).toBe(4); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/styles-adapter.ts b/packages/super-editor/src/document-api-adapters/styles-adapter.ts index e903381dcf..d1f46c0ea3 100644 --- a/packages/super-editor/src/document-api-adapters/styles-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/styles-adapter.ts @@ -16,7 +16,7 @@ import type { StylesStateMap, StylesChannel, NormalizedStylesApplyOptions, - PropertyDefinition, + ValueSchema, } from '@superdoc/document-api'; import { PROPERTY_REGISTRY } from '@superdoc/document-api'; import type { Editor } from '../core/Editor.js'; @@ -63,75 +63,184 @@ const XML_PATH_BY_CHANNEL: Record = { }; // --------------------------------------------------------------------------- -// State formatting helpers +// Underline key mapping (API ↔ storage) // --------------------------------------------------------------------------- -/** A single state value in a before/after receipt. */ -type StateValue = string | number | Record | 'inherit'; +const UNDERLINE_API_TO_STORAGE: Record = { + val: 'w:val', + color: 'w:color', + themeColor: 'w:themeColor', + themeTint: 'w:themeTint', + themeShade: 'w:themeShade', +}; + +const UNDERLINE_STORAGE_TO_API = Object.fromEntries(Object.entries(UNDERLINE_API_TO_STORAGE).map(([k, v]) => [v, k])); + +function mapUnderlineToStorage(apiObj: Record): Record { + const result: Record = {}; + for (const [k, v] of Object.entries(apiObj)) { + result[UNDERLINE_API_TO_STORAGE[k] ?? k] = v; + } + return result; +} + +function mapUnderlineToApi(storageObj: Record): Record { + const result: Record = {}; + for (const [k, v] of Object.entries(storageObj)) { + result[UNDERLINE_STORAGE_TO_API[k] ?? k] = v; + } + return result; +} + +// --------------------------------------------------------------------------- +// Value normalization +// --------------------------------------------------------------------------- + +/** Normalizes hex color strings: uppercase, strip leading '#'. */ +function normalizeHexColor(val: string): string { + return val.replace(/^#/, '').toUpperCase(); +} /** - * 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) + * Returns the set of sub-keys whose string values are hex colors for a given + * property. `val` is hex only on `color`; on borders/shading/underline it is + * an enum token and must NOT be uppercased. */ -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; +const HEX_SUBKEYS_BY_PROPERTY: Record> = { + color: new Set(['val']), + shading: new Set(['color', 'fill']), + underline: new Set(['color', 'w:color']), + borders: new Set(['color']), +}; + +function normalizeObjectSubKeys(obj: Record, key: string): Record { + const hexKeys = HEX_SUBKEYS_BY_PROPERTY[key]; + if (!hexKeys) return obj; + + const result: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (typeof v === 'string' && hexKeys.has(k)) { + result[k] = normalizeHexColor(v); + } else { + result[k] = v; + } + } + return result; +} + +// --------------------------------------------------------------------------- +// JSON deep equality — single shared comparator +// --------------------------------------------------------------------------- + +function jsonDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!jsonDeepEqual(a[i], b[i])) return false; + } + return true; + } + + if (typeof a === 'object') { + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false; + if (!jsonDeepEqual(aObj[key], bObj[key])) return false; + } + return true; + } + + return false; } +// --------------------------------------------------------------------------- +// State formatting helpers +// --------------------------------------------------------------------------- + +type StateValue = string | number | Record | unknown[] | 'inherit'; + /** - * Shallow equality check for before/after state maps. + * Converts a raw storage value to its receipt state representation. + * Uses schema.kind to determine the formatting strategy. */ -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; +function formatState(value: unknown, schema: ValueSchema, key: string): StateValue { + if (value === undefined) return 'inherit'; + + switch (schema.kind) { + case 'boolean': + return (value ? 'on' : 'off') as StateValue; + case 'object': + if (typeof value === 'object' && value !== null) { + const obj = { ...(value as Record) }; + // Map underline storage keys to API keys in receipts + return key === 'underline' ? mapUnderlineToApi(obj) : obj; } - continue; - } - return false; + return value as StateValue; + case 'array': + return Array.isArray(value) ? [...value.map((item) => structuredClone(item))] : (value as StateValue); + default: + return value as StateValue; } - return true; } // --------------------------------------------------------------------------- -// Registry lookup +// Merge strategy dispatch // --------------------------------------------------------------------------- -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; +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +function cloneForStorage(value: T): T { + if (value === null || typeof value !== 'object') { + return value; + } + return structuredClone(value); +} + +function applyReplace(targetProps: Record, key: string, value: unknown): void { + targetProps[key] = cloneForStorage(value); +} + +function applyShallowMerge(targetProps: Record, key: string, value: unknown): void { + const current = asRecord(targetProps[key]); + const patch = value as Record; + + // Handle underline key mapping: API keys → storage keys + if (key === 'underline') { + const storagePatch = cloneForStorage(mapUnderlineToStorage(normalizeObjectSubKeys(patch, key))); + targetProps[key] = { ...current, ...storagePatch }; + return; + } + + targetProps[key] = { ...current, ...cloneForStorage(normalizeObjectSubKeys(patch, key)) }; +} + +function applyEdgeMerge(targetProps: Record, key: string, value: unknown): void { + const current = asRecord(targetProps[key]); + const patch = value as Record>; + const result = { ...current }; + + for (const [edge, edgeValue] of Object.entries(patch)) { + const currentEdge = asRecord(result[edge]); + result[edge] = { ...currentEdge, ...cloneForStorage(normalizeObjectSubKeys(edgeValue, key)) }; + } + + targetProps[key] = result; } // --------------------------------------------------------------------------- // 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, @@ -140,25 +249,32 @@ function applyPatch( 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); + // Iterate patch keys in PROPERTY_REGISTRY declaration order for deterministic receipts + const patchKeys = new Set(Object.keys(patch)); + for (const def of PROPERTY_REGISTRY) { + if (def.channel !== channel || !patchKeys.has(def.key)) continue; + + const key = def.key; + const value = patch[key]; + + before[key] = formatState(targetProps[key], def.schema, key); + + switch (def.mergeStrategy) { + case 'replace': + applyReplace(targetProps, key, value); + break; + case 'shallowMerge': + applyShallowMerge(targetProps, key, value); + break; + case 'edgeMerge': + applyEdgeMerge(targetProps, key, value); + break; } + + after[key] = formatState(targetProps[key], def.schema, key); } - const changed = !stateMapEquals(before, after); + const changed = !jsonDeepEqual(before, after); return { before, after, changed }; } @@ -168,7 +284,6 @@ function applyPatch( /** * Adapter function for `styles.apply` bound to a specific editor instance. - * * Called by the document-api dispatch layer after input validation. */ export function stylesApplyAdapter( @@ -226,15 +341,15 @@ export function stylesApplyAdapter( (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. + // Dry-run: structuredClone for full immutability guarantee. + // Real mutation: ensure hierarchy exists and mutate in-place. let targetProps: Record; if (dryRun) { - targetProps = existingProps ? { ...existingProps } : {}; + targetProps = existingProps ? structuredClone(existingProps) : {}; } else { if (!converter.translatedLinkedStyles) { (converter as unknown as Record).translatedLinkedStyles = {}; @@ -248,7 +363,6 @@ export function stylesApplyAdapter( 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)