From 1adeb4014cf61f6799555f010c73f8d2d8049c2d Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 26 Feb 2026 19:11:19 -0800 Subject: [PATCH 1/2] feat(document-api): inline formatting parity core end-to-end --- .../src/__tests__/conformance/scenarios.ts | 2 +- apps/cli/src/cli/helper-commands.ts | 8 +- apps/cli/src/lib/operation-executor.ts | 2 +- apps/docs/document-api/common-workflows.mdx | 4 +- .../reference/_generated-manifest.json | 2 +- .../reference/capabilities/get.mdx | 109 +++- .../document-api/reference/format/apply.mdx | 28 +- .../document-api/reference/format/index.mdx | 10 +- apps/docs/document-api/reference/index.mdx | 10 +- .../document-api/reference/query/match.mdx | 37 +- apps/docs/document-engine/sdks.mdx | 2 +- apps/mcp/src/tools/format.ts | 8 +- .../scripts/check-contract-parity.ts | 2 +- packages/document-api/src/README.md | 4 +- .../src/capabilities/capabilities.ts | 12 +- .../src/contract/operation-definitions.ts | 2 +- .../src/contract/reference-aliases.ts | 8 +- .../src/contract/reference-doc-map.ts | 2 +- packages/document-api/src/contract/schemas.ts | 79 ++- .../document-api/src/format/format.test.ts | 74 ++- packages/document-api/src/format/format.ts | 17 +- packages/document-api/src/index.test.ts | 12 +- packages/document-api/src/index.ts | 9 +- .../src/inline-semantics/directives.test.ts | 94 +++ .../src/inline-semantics/directives.ts | 150 +++++ .../src/inline-semantics/error-types.ts | 94 +++ .../src/inline-semantics/index.ts | 86 +++ .../src/inline-semantics/property-ids.test.ts | 36 ++ .../src/inline-semantics/property-ids.ts | 30 + .../inline-semantics/token-parsers.test.ts | 213 +++++++ .../src/inline-semantics/token-parsers.ts | 198 +++++++ .../src/inline-semantics/token-sets.ts | 93 +++ .../document-api/src/invoke/invoke.test.ts | 4 +- .../src/overview-examples.test.ts | 21 +- packages/document-api/src/types/discovery.ts | 20 +- .../src/types/query-match.types.ts | 57 +- .../src/types/style-policy.types.ts | 46 +- .../node/src/helpers/__tests__/format.test.ts | 59 +- packages/sdk/langs/node/src/helpers/format.ts | 103 +++- .../langs/python/superdoc/helpers/format.py | 91 ++- .../core/super-converter/SuperConverter.js | 1 + .../v2/importer/docxImporter.js | 5 + .../v3/handlers/import-diagnostics.js | 85 +++ .../v3/handlers/import-diagnostics.test.js | 132 +++++ .../core/super-converter/v3/handlers/utils.js | 89 +++ .../v3/handlers/w/b/b-translator.js | 10 +- .../v3/handlers/w/i/i-translator.js | 7 +- .../w/strict-import-normalization.test.js | 457 ++++++++++++++ .../v3/handlers/w/strike/strike-translator.js | 7 +- .../v3/handlers/w/u/u-translator.js | 76 ++- .../v3/handlers/w/u/u-translator.test.js | 4 +- .../contract-conformance.test.ts | 8 +- .../__conformance__/schema-validator.test.ts | 6 +- .../capabilities-adapter.ts | 14 +- .../plan-engine/executor.test.ts | 89 ++- .../plan-engine/executor.ts | 135 ++++- .../plan-engine/mark-directives.ts | 147 +++++ .../plan-engine/match-style-helpers.test.ts | 152 ++++- .../plan-engine/match-style-helpers.ts | 117 +++- .../plan-engine/query-match-adapter.test.ts | 122 +++- .../plan-engine/query-match-adapter.ts | 49 +- .../plan-engine/style-resolver.test.ts | 124 +++- .../plan-engine/style-resolver.ts | 187 ++++-- .../tests/ex1-clause-change.ts | 4 +- .../tests/formatting/inline-formatting.ts | 560 +++++++++++++----- 65 files changed, 3898 insertions(+), 527 deletions(-) create mode 100644 packages/document-api/src/inline-semantics/directives.test.ts create mode 100644 packages/document-api/src/inline-semantics/directives.ts create mode 100644 packages/document-api/src/inline-semantics/error-types.ts create mode 100644 packages/document-api/src/inline-semantics/index.ts create mode 100644 packages/document-api/src/inline-semantics/property-ids.test.ts create mode 100644 packages/document-api/src/inline-semantics/property-ids.ts create mode 100644 packages/document-api/src/inline-semantics/token-parsers.test.ts create mode 100644 packages/document-api/src/inline-semantics/token-parsers.ts create mode 100644 packages/document-api/src/inline-semantics/token-sets.ts create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/import-diagnostics.js create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/import-diagnostics.test.js create mode 100644 packages/super-editor/src/core/super-converter/v3/handlers/w/strict-import-normalization.test.js create mode 100644 packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.ts diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index ebdaf3d844..5990dc1307 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -634,7 +634,7 @@ export const SUCCESS_SCENARIOS = { '--target-json', JSON.stringify(target), '--inline-json', - JSON.stringify({ bold: true }), + JSON.stringify({ bold: 'on' }), '--out', harness.createOutputPath('doc-style-apply-output'), ], diff --git a/apps/cli/src/cli/helper-commands.ts b/apps/cli/src/cli/helper-commands.ts index 78fa18c430..6a7de503b7 100644 --- a/apps/cli/src/cli/helper-commands.ts +++ b/apps/cli/src/cli/helper-commands.ts @@ -53,7 +53,7 @@ export const CLI_HELPER_COMMANDS: readonly CliHelperCommand[] = [ { tokens: ['format', 'bold'], canonicalOperationId: 'format.apply', - defaultInput: { inline: { bold: true } }, + defaultInput: { inline: { bold: 'on' } }, description: 'Apply bold formatting to a text range.', category: 'format', mutates: true, @@ -65,7 +65,7 @@ export const CLI_HELPER_COMMANDS: readonly CliHelperCommand[] = [ { tokens: ['format', 'italic'], canonicalOperationId: 'format.apply', - defaultInput: { inline: { italic: true } }, + defaultInput: { inline: { italic: 'on' } }, description: 'Apply italic formatting to a text range.', category: 'format', mutates: true, @@ -74,7 +74,7 @@ export const CLI_HELPER_COMMANDS: readonly CliHelperCommand[] = [ { tokens: ['format', 'underline'], canonicalOperationId: 'format.apply', - defaultInput: { inline: { underline: true } }, + defaultInput: { inline: { underline: 'on' } }, description: 'Apply underline formatting to a text range.', category: 'format', mutates: true, @@ -83,7 +83,7 @@ export const CLI_HELPER_COMMANDS: readonly CliHelperCommand[] = [ { tokens: ['format', 'strikethrough'], canonicalOperationId: 'format.apply', - defaultInput: { inline: { strike: true } }, + defaultInput: { inline: { strike: 'on' } }, description: 'Apply strikethrough formatting to a text range.', category: 'format', mutates: true, diff --git a/apps/cli/src/lib/operation-executor.ts b/apps/cli/src/lib/operation-executor.ts index 95318e281c..6948009326 100644 --- a/apps/cli/src/lib/operation-executor.ts +++ b/apps/cli/src/lib/operation-executor.ts @@ -192,7 +192,7 @@ export async function executeOperation(request: ExecuteOperationRequest): Promis extraOptionSpecs: request.extraOptionSpecs, }), ) ?? {}) as Record; - // Merge helper command defaults (e.g., inline: { bold: true } for `format bold`). + // Merge helper command defaults (e.g., inline: { bold: 'on' } for `format bold`). // User-provided values take precedence over defaults. if (request.defaultInput) { input = { ...request.defaultInput, ...input }; diff --git a/apps/docs/document-api/common-workflows.mdx b/apps/docs/document-api/common-workflows.mdx index 24e5b8cfe6..723b9a2cff 100644 --- a/apps/docs/document-api/common-workflows.mdx +++ b/apps/docs/document-api/common-workflows.mdx @@ -70,7 +70,7 @@ const plan = { op: 'format.apply', where: { by: 'ref', ref }, args: { - marks: { bold: true }, + inline: { bold: 'on' }, }, }, ], @@ -122,7 +122,7 @@ const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }; if (caps.operations['format.apply'].available) { editor.doc.format.apply({ target, - marks: { bold: true }, + inline: { bold: 'on' }, }); } diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index a8a7c128b7..1712cbd7c6 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -229,5 +229,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "722ce545fc7c5373e23246fa7bbbc68b381e30bd8e2bc6c21d1616e6c5395ea9" + "sourceHash": "5454f771b591f831325cc10903af71f01a064277b44a8c252b71855ff2b98a7d" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 402b020984..b5c5b11af3 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -42,9 +42,20 @@ _No fields._ ```json { "format": { - "supportedMarks": [ - "bold" - ] + "properties": { + "bold": { + "directives": [ + "example" + ], + "kind": "example" + }, + "italic": { + "directives": [ + "example" + ], + "kind": "example" + } + } }, "global": { "comments": { @@ -735,21 +746,91 @@ _No fields._ "format": { "additionalProperties": false, "properties": { - "supportedMarks": { - "items": { - "enum": [ - "bold", - "italic", - "underline", - "strike" - ], - "type": "string" + "properties": { + "additionalProperties": false, + "properties": { + "bold": { + "additionalProperties": false, + "properties": { + "directives": { + "items": { + "type": "string" + }, + "type": "array" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "kind", + "directives" + ], + "type": "object" + }, + "italic": { + "additionalProperties": false, + "properties": { + "directives": { + "items": { + "type": "string" + }, + "type": "array" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "kind", + "directives" + ], + "type": "object" + }, + "strike": { + "additionalProperties": false, + "properties": { + "directives": { + "items": { + "type": "string" + }, + "type": "array" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "kind", + "directives" + ], + "type": "object" + }, + "underline": { + "additionalProperties": false, + "properties": { + "directives": { + "items": { + "type": "string" + }, + "type": "array" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "kind", + "directives" + ], + "type": "object" + } }, - "type": "array" + "type": "object" } }, "required": [ - "supportedMarks" + "properties" ], "type": "object" }, diff --git a/apps/docs/document-api/reference/format/apply.mdx b/apps/docs/document-api/reference/format/apply.mdx index 09f12134f0..bf1aba37f7 100644 --- a/apps/docs/document-api/reference/format/apply.mdx +++ b/apps/docs/document-api/reference/format/apply.mdx @@ -30,8 +30,8 @@ description: Reference for format.apply ```json { "inline": { - "bold": true, - "italic": true + "bold": "on", + "italic": "on" }, "target": { "blockId": "block-abc123", @@ -116,16 +116,32 @@ _No fields._ "minProperties": 1, "properties": { "bold": { - "type": "boolean" + "enum": [ + "on", + "off", + "clear" + ] }, "italic": { - "type": "boolean" + "enum": [ + "on", + "off", + "clear" + ] }, "strike": { - "type": "boolean" + "enum": [ + "on", + "off", + "clear" + ] }, "underline": { - "type": "boolean" + "enum": [ + "on", + "off", + "clear" + ] } }, "type": "object" diff --git a/apps/docs/document-api/reference/format/index.mdx b/apps/docs/document-api/reference/format/index.mdx index fd00b38cca..477b407ff1 100644 --- a/apps/docs/document-api/reference/format/index.mdx +++ b/apps/docs/document-api/reference/format/index.mdx @@ -10,7 +10,7 @@ description: Format operation reference from the canonical Document API contract [Back to full reference](../index) -Canonical formatting mutation with boolean patch semantics. +Canonical formatting mutation with directive semantics ('on', 'off', 'clear'). | Operation | Member path | Mutates | Idempotency | Tracked | Dry run | | --- | --- | --- | --- | --- | --- | @@ -25,8 +25,8 @@ Canonical formatting mutation with boolean patch semantics. | Alias method | Canonical operation | Behavior | | --- | --- | --- | -| `editor.doc.format.bold(...)` | format.apply | Convenience alias for `format.apply` with `inline.bold: true`. | -| `editor.doc.format.italic(...)` | format.apply | Convenience alias for `format.apply` with `inline.italic: true`. | -| `editor.doc.format.underline(...)` | format.apply | Convenience alias for `format.apply` with `inline.underline: true`. | -| `editor.doc.format.strikethrough(...)` | format.apply | Convenience alias for `format.apply` with `inline.strike: true`. | +| `editor.doc.format.bold(...)` | format.apply | Convenience alias for `format.apply` with `inline.bold: 'on'`. | +| `editor.doc.format.italic(...)` | format.apply | Convenience alias for `format.apply` with `inline.italic: 'on'`. | +| `editor.doc.format.underline(...)` | format.apply | Convenience alias for `format.apply` with `inline.underline: 'on'`. | +| `editor.doc.format.strikethrough(...)` | format.apply | Convenience alias for `format.apply` with `inline.strike: 'on'`. | diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index fa4205236d..d633f5e7a0 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -74,15 +74,15 @@ The tables below are grouped by namespace. | Operation | API member path | Description | | --- | --- | --- | -| format.apply | editor.doc.format.apply(...) | Apply explicit inline style changes (bold, italic, underline, strike) to the target range using boolean patch semantics. | +| format.apply | editor.doc.format.apply(...) | Apply explicit inline style changes (bold, italic, underline, strike) to the target range using directive semantics ('on', 'off', 'clear'). | | format.fontSize | editor.doc.format.fontSize(...) | Set or unset the font size on the target text range. Pass null to remove. | | format.fontFamily | editor.doc.format.fontFamily(...) | Set or unset the font family on the target text range. Pass null to remove. | | format.color | editor.doc.format.color(...) | Set or unset the text color on the target text range. Pass null to remove. | | format.align | editor.doc.format.align(...) | Set or unset paragraph alignment on the block containing the target. Pass null to reset to default. | -| format.bold | editor.doc.format.bold(...) | Convenience alias for `format.apply` with `inline.bold: true`. | -| format.italic | editor.doc.format.italic(...) | Convenience alias for `format.apply` with `inline.italic: true`. | -| format.underline | editor.doc.format.underline(...) | Convenience alias for `format.apply` with `inline.underline: true`. | -| format.strikethrough | editor.doc.format.strikethrough(...) | Convenience alias for `format.apply` with `inline.strike: true`. | +| format.bold | editor.doc.format.bold(...) | Convenience alias for `format.apply` with `inline.bold: 'on'`. | +| format.italic | editor.doc.format.italic(...) | Convenience alias for `format.apply` with `inline.italic: 'on'`. | +| format.underline | editor.doc.format.underline(...) | Convenience alias for `format.apply` with `inline.underline: 'on'`. | +| format.strikethrough | editor.doc.format.strikethrough(...) | Convenience alias for `format.apply` with `inline.strike: 'on'`. | #### Styles diff --git a/apps/docs/document-api/reference/query/match.mdx b/apps/docs/document-api/reference/query/match.mdx index 15c8e77fab..25a1b85fbd 100644 --- a/apps/docs/document-api/reference/query/match.mdx +++ b/apps/docs/document-api/reference/query/match.mdx @@ -55,6 +55,7 @@ description: Reference for query.match | --- | --- | --- | --- | | `evaluatedRevision` | string | yes | | | `items` | object(matchKind="text") \\| object(matchKind="node")[] | yes | | +| `meta` | object | yes | | | `page` | PageInfo | yes | PageInfo | | `total` | integer | yes | | @@ -92,12 +93,20 @@ description: Reference for query.match "ref": "handle:abc123", "styleId": "style-001", "styles": { - "bold": true, "color": "example", - "highlight": "example", - "italic": true, - "strike": true, - "underline": true + "direct": { + "bold": "on", + "italic": "on", + "strike": "on", + "underline": "on" + }, + "effective": { + "bold": true, + "italic": true, + "strike": true, + "underline": true + }, + "highlight": "example" }, "text": "Hello, world." } @@ -119,6 +128,9 @@ description: Reference for query.match "snippet": "...the quick brown fox..." } ], + "meta": { + "effectiveResolved": true + }, "page": { "limit": 50, "offset": 0, @@ -336,6 +348,18 @@ description: Reference for query.match }, "type": "array" }, + "meta": { + "additionalProperties": false, + "properties": { + "effectiveResolved": { + "type": "boolean" + } + }, + "required": [ + "effectiveResolved" + ], + "type": "object" + }, "page": { "$ref": "#/$defs/PageInfo" }, @@ -348,7 +372,8 @@ description: Reference for query.match "evaluatedRevision", "total", "items", - "page" + "page", + "meta" ], "type": "object" } diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 3df028120c..2cbacb9d64 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -209,7 +209,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | Operation | CLI command | Description | | --- | --- | --- | -| `doc.format.apply` | `format apply` | Apply explicit inline style changes (bold, italic, underline, strike) to the target range using boolean patch semantics. | +| `doc.format.apply` | `format apply` | Apply explicit inline style changes (bold, italic, underline, strike) to the target range using directive semantics ('on', 'off', 'clear'). | | `doc.format.fontSize` | `format font-size` | Set or unset the font size on the target text range. Pass null to remove. | | `doc.format.fontFamily` | `format font-family` | Set or unset the font family on the target text range. Pass null to remove. | | `doc.format.color` | `format color` | Set or unset the text color on the target text range. Pass null to remove. | diff --git a/apps/mcp/src/tools/format.ts b/apps/mcp/src/tools/format.ts index 4a5b8ec6cc..1acab87ce9 100644 --- a/apps/mcp/src/tools/format.ts +++ b/apps/mcp/src/tools/format.ts @@ -4,10 +4,10 @@ import type { SessionManager } from '../session-manager.js'; const STYLES = ['bold', 'italic', 'underline', 'strikethrough'] as const; const INLINE_BY_STYLE = { - bold: { bold: true }, - italic: { italic: true }, - underline: { underline: true }, - strikethrough: { strike: true }, + bold: { bold: 'on' }, + italic: { italic: 'on' }, + underline: { underline: 'on' }, + strikethrough: { strike: 'on' }, } as const; export function registerFormatTools(server: McpServer, sessions: SessionManager): void { diff --git a/packages/document-api/scripts/check-contract-parity.ts b/packages/document-api/scripts/check-contract-parity.ts index 82c9e123a6..3501b252b9 100644 --- a/packages/document-api/scripts/check-contract-parity.ts +++ b/packages/document-api/scripts/check-contract-parity.ts @@ -69,7 +69,7 @@ function createNoopAdapters(): DocumentApiAdapters { lists: { enabled: false }, dryRun: { enabled: false }, }, - format: { supportedMarks: [] }, + format: { properties: {} }, operations: {} as ReturnType['operations'], planEngine: { supportedStepOps: [], diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index aa79cdfcde..0139fe9e65 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -144,7 +144,7 @@ Check what the editor supports before attempting mutations: ```ts const caps = editor.doc.capabilities(); if (caps.operations['format.apply'].available) { - editor.doc.format.apply({ target, inline: { bold: true } }); + editor.doc.format.apply({ target, inline: { bold: 'on' } }); } if (caps.global.trackChanges.enabled) { editor.doc.insert({ value: 'tracked' }, { changeMode: 'tracked' }); @@ -293,7 +293,7 @@ Insert a new heading node at a specified location with a given level (1-6). Retu ### `format.apply` -Apply explicit inline style changes (bold, italic, underline, strike) to a `TextAddress` range using boolean patch semantics. Supports dry-run and tracked mode. Availability depends on the corresponding marks being registered in the editor schema. +Apply explicit inline style changes (bold, italic, underline, strike) to a `TextAddress` range using directive semantics (`'on'`, `'off'`, `'clear'`). Supports dry-run and tracked mode. Availability depends on the corresponding marks being registered in the editor schema. - **Input**: `StyleApplyInput` (`{ target, inline: { bold?, italic?, underline?, strike? } }`) - **Options**: `MutationOptions` (`{ changeMode?, dryRun? }`) diff --git a/packages/document-api/src/capabilities/capabilities.ts b/packages/document-api/src/capabilities/capabilities.ts index bce227e30c..21ecac5f98 100644 --- a/packages/document-api/src/capabilities/capabilities.ts +++ b/packages/document-api/src/capabilities/capabilities.ts @@ -54,10 +54,16 @@ export interface PlanEngineCapabilities { * `operations` contains per-operation availability details keyed by {@link OperationId}. * `planEngine` describes plan engine capabilities (step ops, style strategies, limits). */ -/** Format capability snapshot — advertises which boolean mark keys this editor supports. */ +/** Per-property capability describing the interaction model and accepted directives. */ +export interface FormatPropertyCapability { + kind: 'toggle' | 'value' | 'composite'; + directives: readonly string[]; +} + +/** Format capability snapshot — advertises per-property capability objects. */ export interface FormatCapabilities { - /** Mark keys that `format.apply` can set/unset (derived from the shared mark registry). */ - supportedMarks: readonly string[]; + /** Per-property capability objects keyed by mark name. */ + properties: Partial>; } export interface DocumentApiCapabilities { diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index b545323458..3e069af1d7 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -268,7 +268,7 @@ export const OPERATION_DEFINITIONS = { 'format.apply': { memberPath: 'format.apply', description: - 'Apply explicit inline style changes (bold, italic, underline, strike) to the target range using boolean patch semantics.', + "Apply explicit inline style changes (bold, italic, underline, strike) to the target range using directive semantics ('on', 'off', 'clear').", requiresDocumentContext: true, metadata: mutationOperation({ idempotency: 'conditional', diff --git a/packages/document-api/src/contract/reference-aliases.ts b/packages/document-api/src/contract/reference-aliases.ts index ea6da4b05f..c4354b65c1 100644 --- a/packages/document-api/src/contract/reference-aliases.ts +++ b/packages/document-api/src/contract/reference-aliases.ts @@ -23,24 +23,24 @@ export const REFERENCE_OPERATION_ALIASES: readonly ReferenceAliasDefinition[] = memberPath: 'format.bold', canonicalOperationId: 'format.apply', referenceGroup: 'format', - description: 'Convenience alias for `format.apply` with `inline.bold: true`.', + description: "Convenience alias for `format.apply` with `inline.bold: 'on'`.", }, { memberPath: 'format.italic', canonicalOperationId: 'format.apply', referenceGroup: 'format', - description: 'Convenience alias for `format.apply` with `inline.italic: true`.', + description: "Convenience alias for `format.apply` with `inline.italic: 'on'`.", }, { memberPath: 'format.underline', canonicalOperationId: 'format.apply', referenceGroup: 'format', - description: 'Convenience alias for `format.apply` with `inline.underline: true`.', + description: "Convenience alias for `format.apply` with `inline.underline: 'on'`.", }, { memberPath: 'format.strikethrough', canonicalOperationId: 'format.apply', referenceGroup: 'format', - description: 'Convenience alias for `format.apply` with `inline.strike: true`.', + description: "Convenience alias for `format.apply` with `inline.strike: 'on'`.", }, ] as const; diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index cee18fc001..e6b0d180db 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -43,7 +43,7 @@ const GROUP_METADATA: Record; @@ -266,16 +266,30 @@ const SHARED_DEFS: Record = { styleId: { type: 'string' }, styles: objectSchema( { - bold: { type: 'boolean' }, - italic: { type: 'boolean' }, - underline: { type: 'boolean' }, - strike: { type: 'boolean' }, + direct: objectSchema( + { + bold: { enum: [...INLINE_DIRECTIVES] }, + italic: { enum: [...INLINE_DIRECTIVES] }, + underline: { enum: [...INLINE_DIRECTIVES] }, + strike: { enum: [...INLINE_DIRECTIVES] }, + }, + ['bold', 'italic', 'underline', 'strike'], + ), + effective: objectSchema( + { + bold: { type: 'boolean' }, + italic: { type: 'boolean' }, + underline: { type: 'boolean' }, + strike: { type: 'boolean' }, + }, + ['bold', 'italic', 'underline', 'strike'], + ), color: { type: 'string' }, highlight: { type: 'string' }, fontFamily: { type: 'string' }, fontSizePt: { type: 'number' }, }, - ['bold', 'italic', 'underline', 'strike'], + ['direct', 'effective'], ), ref: { type: 'string' }, }, @@ -343,17 +357,23 @@ void matchRunSchema; /** * Builds a DiscoveryResult schema wrapping the given item schema. + * When `metaSchema` is provided, the result includes a required `meta` field. */ -function discoveryResultSchema(itemSchema: JsonSchema): JsonSchema { - return objectSchema( - { - evaluatedRevision: { type: 'string' }, - total: { type: 'integer', minimum: 0 }, - items: arraySchema(itemSchema), - page: pageInfoSchema, - }, - ['evaluatedRevision', 'total', 'items', 'page'], - ); +function discoveryResultSchema(itemSchema: JsonSchema, metaSchema?: JsonSchema): JsonSchema { + const properties: Record = { + evaluatedRevision: { type: 'string' }, + total: { type: 'integer', minimum: 0 }, + items: arraySchema(itemSchema), + page: pageInfoSchema, + }; + const required = ['evaluatedRevision', 'total', 'items', 'page']; + + if (metaSchema) { + properties.meta = metaSchema; + required.push('meta'); + } + + return objectSchema(properties, required); } /** @@ -849,11 +869,21 @@ const operationCapabilitiesSchema = objectSchema( OPERATION_IDS, ); +const formatPropertyCapabilitySchema = objectSchema( + { + kind: { type: 'string' }, + directives: arraySchema({ type: 'string' }), + }, + ['kind', 'directives'], +); + const formatCapabilitiesSchema = objectSchema( { - supportedMarks: arraySchema({ type: 'string', enum: [...MARK_KEYS] }), + properties: objectSchema( + Object.fromEntries(MARK_KEYS.map((key) => [key, formatPropertyCapabilitySchema])) as Record, + ), }, - ['supportedMarks'], + ['properties'], ); const planEngineCapabilitiesSchema = objectSchema( @@ -1079,9 +1109,11 @@ const operationSchemas: Record = { { target: textAddressSchema, inline: (() => { - const markProperties = Object.fromEntries( - MARK_KEYS.map((key) => [key, { type: 'boolean' } as JsonSchema]), - ) as Record; + const directiveSchema: JsonSchema = { enum: [...INLINE_DIRECTIVES] }; + const markProperties = Object.fromEntries(MARK_KEYS.map((key) => [key, directiveSchema])) as Record< + string, + JsonSchema + >; return { type: 'object', properties: markProperties, @@ -1609,7 +1641,10 @@ const operationSchemas: Record = { ['matchKind', 'address', 'blocks'], ); - return discoveryResultSchema({ oneOf: [textMatchItemSchema, nodeMatchItemSchema] }); + // query.match meta schema — effectiveResolved is required. + const queryMatchMetaSchema = objectSchema({ effectiveResolved: { type: 'boolean' } }, ['effectiveResolved']); + + return discoveryResultSchema({ oneOf: [textMatchItemSchema, nodeMatchItemSchema] }, queryMatchMetaSchema); })(), }, 'mutations.preview': { diff --git a/packages/document-api/src/format/format.test.ts b/packages/document-api/src/format/format.test.ts index 2730ef767f..7b42448bc1 100644 --- a/packages/document-api/src/format/format.test.ts +++ b/packages/document-api/src/format/format.test.ts @@ -47,7 +47,7 @@ describe('executeStyleApply validation', () => { it('rejects unknown top-level fields', () => { const adapter = makeAdapter(); - const input = { target: TARGET, inline: { bold: true }, extra: 1 }; + const input = { target: TARGET, inline: { bold: 'on' }, extra: 1 }; expect(() => executeStyleApply(adapter, input as any)).toThrow('extra'); }); @@ -57,85 +57,85 @@ describe('executeStyleApply validation', () => { it('rejects missing target', () => { const adapter = makeAdapter(); - const input = { inline: { bold: true } }; + const input = { inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('requires a target'); }); it('rejects invalid target (string)', () => { const adapter = makeAdapter(); - const input = { target: 'not-an-address', inline: { bold: true } }; + const input = { target: 'not-an-address', inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects invalid target (number)', () => { const adapter = makeAdapter(); - const input = { target: 42, inline: { bold: true } }; + const input = { target: 42, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects invalid target (null)', () => { const adapter = makeAdapter(); - const input = { target: null, inline: { bold: true } }; + const input = { target: null, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target missing kind', () => { const adapter = makeAdapter(); - const input = { target: { blockId: 'p1', range: { start: 0, end: 5 } }, inline: { bold: true } }; + const input = { target: { blockId: 'p1', range: { start: 0, end: 5 } }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target with wrong kind', () => { const adapter = makeAdapter(); - const input = { target: { kind: 'block', blockId: 'p1', range: { start: 0, end: 5 } }, inline: { bold: true } }; + const input = { target: { kind: 'block', blockId: 'p1', range: { start: 0, end: 5 } }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target missing blockId', () => { const adapter = makeAdapter(); - const input = { target: { kind: 'text', range: { start: 0, end: 5 } }, inline: { bold: true } }; + const input = { target: { kind: 'text', range: { start: 0, end: 5 } }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target with non-string blockId', () => { const adapter = makeAdapter(); - const input = { target: { kind: 'text', blockId: 123, range: { start: 0, end: 5 } }, inline: { bold: true } }; + const input = { target: { kind: 'text', blockId: 123, range: { start: 0, end: 5 } }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target missing range', () => { const adapter = makeAdapter(); - const input = { target: { kind: 'text', blockId: 'p1' }, inline: { bold: true } }; + const input = { target: { kind: 'text', blockId: 'p1' }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target with non-object range', () => { const adapter = makeAdapter(); - const input = { target: { kind: 'text', blockId: 'p1', range: 'bad' }, inline: { bold: true } }; + const input = { target: { kind: 'text', blockId: 'p1', range: 'bad' }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target with non-integer start in range', () => { const adapter = makeAdapter(); - const input = { target: { kind: 'text', blockId: 'p1', range: { start: 1.5, end: 5 } }, inline: { bold: true } }; + const input = { target: { kind: 'text', blockId: 'p1', range: { start: 1.5, end: 5 } }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target with non-integer end in range', () => { const adapter = makeAdapter(); - const input = { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5.5 } }, inline: { bold: true } }; + const input = { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5.5 } }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('rejects target with start > end in range', () => { const adapter = makeAdapter(); - const input = { target: { kind: 'text', blockId: 'p1', range: { start: 10, end: 5 } }, inline: { bold: true } }; + const input = { target: { kind: 'text', blockId: 'p1', range: { start: 10, end: 5 } }, inline: { bold: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('text address'); }); it('accepts valid target', () => { const adapter = makeAdapter(); - const input: StyleApplyInput = { target: TARGET, inline: { bold: true } }; + const input: StyleApplyInput = { target: TARGET, inline: { bold: 'on' } }; const result = executeStyleApply(adapter, input); expect(result.success).toBe(true); }); @@ -144,7 +144,7 @@ describe('executeStyleApply validation', () => { const adapter = makeAdapter(); const input: StyleApplyInput = { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 0 } }, - inline: { bold: true }, + inline: { bold: 'on' }, }; const result = executeStyleApply(adapter, input); expect(result.success).toBe(true); @@ -180,20 +180,26 @@ describe('executeStyleApply validation', () => { it('rejects unknown inline keys', () => { const adapter = makeAdapter(); - const input = { target: TARGET, inline: { bold: true, superscript: true } }; + const input = { target: TARGET, inline: { bold: 'on', superscript: 'on' } }; expect(() => executeStyleApply(adapter, input as any)).toThrow('Unknown inline style key "superscript"'); }); - it('rejects non-boolean inline values', () => { + it('rejects invalid directive string values', () => { const adapter = makeAdapter(); const input = { target: TARGET, inline: { bold: 'yes' } }; - expect(() => executeStyleApply(adapter, input as any)).toThrow('must be a boolean'); + expect(() => executeStyleApply(adapter, input as any)).toThrow("expected 'on'|'off'|'clear'"); }); it('rejects numeric inline values', () => { const adapter = makeAdapter(); const input = { target: TARGET, inline: { bold: 1 } }; - expect(() => executeStyleApply(adapter, input as any)).toThrow('must be a boolean'); + expect(() => executeStyleApply(adapter, input as any)).toThrow("expected 'on'|'off'|'clear'"); + }); + + it('rejects boolean inline values (must be string directive)', () => { + const adapter = makeAdapter(); + const input = { target: TARGET, inline: { bold: true } }; + expect(() => executeStyleApply(adapter, input as any)).toThrow("expected 'on'|'off'|'clear'"); }); // ------------------------------------------------------------------------- @@ -202,7 +208,7 @@ describe('executeStyleApply validation', () => { it('delegates single mark to adapter.apply', () => { const adapter = makeAdapter(); - const input: StyleApplyInput = { target: TARGET, inline: { bold: true } }; + const input: StyleApplyInput = { target: TARGET, inline: { bold: 'on' } }; const result = executeStyleApply(adapter, input); expect(result.success).toBe(true); expect(adapter.apply).toHaveBeenCalledWith(input, { changeMode: 'direct', dryRun: false }); @@ -210,33 +216,33 @@ describe('executeStyleApply validation', () => { it('passes through tracked changeMode option', () => { const adapter = makeAdapter(); - const input: StyleApplyInput = { target: TARGET, inline: { italic: false } }; + const input: StyleApplyInput = { target: TARGET, inline: { italic: 'off' } }; executeStyleApply(adapter, input, { changeMode: 'tracked' }); expect(adapter.apply).toHaveBeenCalledWith(input, { changeMode: 'tracked', dryRun: false }); }); it('passes through dryRun option', () => { const adapter = makeAdapter(); - const input: StyleApplyInput = { target: TARGET, inline: { underline: true } }; + const input: StyleApplyInput = { target: TARGET, inline: { underline: 'on' } }; executeStyleApply(adapter, input, { dryRun: true }); expect(adapter.apply).toHaveBeenCalledWith(input, { changeMode: 'direct', dryRun: true }); }); // ------------------------------------------------------------------------- - // Happy paths — multi-mark (boolean patch semantics) + // Happy paths — multi-mark (directive patch semantics) // ------------------------------------------------------------------------- it('accepts multiple inline in one call', () => { const adapter = makeAdapter(); - const input: StyleApplyInput = { target: TARGET, inline: { bold: true, italic: true } }; + const input: StyleApplyInput = { target: TARGET, inline: { bold: 'on', italic: 'on' } }; const result = executeStyleApply(adapter, input); expect(result.success).toBe(true); expect(adapter.apply).toHaveBeenCalledWith(input, expect.objectContaining({})); }); - it('accepts mixed set/unset in one call', () => { + it('accepts mixed on/off in one call', () => { const adapter = makeAdapter(); - const input: StyleApplyInput = { target: TARGET, inline: { bold: true, italic: false } }; + const input: StyleApplyInput = { target: TARGET, inline: { bold: 'on', italic: 'off' } }; const result = executeStyleApply(adapter, input); expect(result.success).toBe(true); expect(adapter.apply).toHaveBeenCalledWith(input, expect.objectContaining({})); @@ -246,16 +252,24 @@ describe('executeStyleApply validation', () => { const adapter = makeAdapter(); const input: StyleApplyInput = { target: TARGET, - inline: { bold: true, italic: false, underline: true, strike: false }, + inline: { bold: 'on', italic: 'off', underline: 'clear', strike: 'off' }, }; const result = executeStyleApply(adapter, input); expect(result.success).toBe(true); expect(adapter.apply).toHaveBeenCalledWith(input, expect.objectContaining({})); }); - it('accepts mark removal (false)', () => { + it('accepts explicit OFF directive', () => { + const adapter = makeAdapter(); + const input: StyleApplyInput = { target: TARGET, inline: { bold: 'off' } }; + const result = executeStyleApply(adapter, input); + expect(result.success).toBe(true); + expect(adapter.apply).toHaveBeenCalledWith(input, expect.objectContaining({})); + }); + + it('accepts clear directive', () => { const adapter = makeAdapter(); - const input: StyleApplyInput = { target: TARGET, inline: { bold: false } }; + const input: StyleApplyInput = { target: TARGET, inline: { bold: 'clear' } }; const result = executeStyleApply(adapter, input); expect(result.success).toBe(true); expect(adapter.apply).toHaveBeenCalledWith(input, expect.objectContaining({})); diff --git a/packages/document-api/src/format/format.ts b/packages/document-api/src/format/format.ts index a4ec2343ed..e3116168dc 100644 --- a/packages/document-api/src/format/format.ts +++ b/packages/document-api/src/format/format.ts @@ -1,6 +1,6 @@ import { normalizeMutationOptions, type MutationOptions } from '../write/write.js'; import type { TextAddress, TextMutationReceipt, SetMarks } from '../types/index.js'; -import { MARK_KEY_SET } from '../types/style-policy.types.js'; +import { MARK_KEY_SET, INLINE_DIRECTIVE_SET } from '../types/style-policy.types.js'; import { DocumentApiValidationError } from '../errors.js'; import { isRecord, isTextAddress, assertNoUnknownFields } from '../validation-primitives.js'; @@ -48,11 +48,11 @@ export interface FormatStrikethroughInput { /** * Input payload for `format.apply`. * - * `inline` uses boolean patch semantics: `true` sets, `false` removes, omitted leaves unchanged. + * `inline` uses tri-state directive semantics: `'on'` sets, `'off'` overrides to OFF, `'clear'` removes direct formatting. */ export interface StyleApplyInput { target: TextAddress; - /** Boolean inline-style patch — at least one known key required. */ + /** Tri-state inline directive patch — at least one known key required. */ inline: SetMarks; } @@ -94,10 +94,11 @@ export interface FormatAlignInput { /** * Engine-specific adapter for format operations. * - * `apply()` handles boolean toggle marks. + * `apply()` handles inline toggle marks via tri-state directives. * Value-based methods handle fontSize, fontFamily, color, and paragraph alignment. */ export interface FormatAdapter { + /** Apply explicit inline-style changes using tri-state directive semantics. */ apply(input: StyleApplyInput, options?: MutationOptions): TextMutationReceipt; fontSize(input: FormatFontSizeInput, options?: MutationOptions): TextMutationReceipt; fontFamily(input: FormatFontFamilyInput, options?: MutationOptions): TextMutationReceipt; @@ -141,7 +142,7 @@ const STYLE_APPLY_INPUT_ALLOWED_KEYS = new Set(['target', 'inline']); * 3. `inline` presence and type * 4. At least one known inline key * 5. No unknown inline keys - * 6. All inline values are booleans + * 6. All inline values are valid directives ('on' | 'off' | 'clear') */ function validateStyleApplyInput(input: unknown): asserts input is StyleApplyInput { if (!isRecord(input)) { @@ -194,10 +195,10 @@ function validateStyleApplyInput(input: unknown): asserts input is StyleApplyInp ); } const value = inline[key]; - if (typeof value !== 'boolean') { + if (typeof value !== 'string' || !INLINE_DIRECTIVE_SET.has(value)) { throw new DocumentApiValidationError( 'INVALID_INPUT', - `Inline style "${key}" must be a boolean, got ${typeof value}.`, + `inline.${key}: expected 'on'|'off'|'clear', got ${typeof value === 'string' ? `'${value}'` : typeof value} ${JSON.stringify(value)}.`, { field: 'inline', key, @@ -212,7 +213,7 @@ function validateStyleApplyInput(input: unknown): asserts input is StyleApplyInp * Executes `format.apply` using the provided adapter. * * Validates input (locator + inline), then delegates to the adapter's `apply()` method. - * Inline styles use boolean patch semantics: `true` sets a style, `false` removes it, omitted keys are unchanged. + * Inline styles use tri-state directive semantics: `'on'` sets, `'off'` overrides, `'clear'` removes direct formatting. * All inline changes within one call are applied in a single ProseMirror transaction. */ export function executeStyleApply( diff --git a/packages/document-api/src/index.test.ts b/packages/document-api/src/index.test.ts index 0a8c9db33e..a32199e800 100644 --- a/packages/document-api/src/index.test.ts +++ b/packages/document-api/src/index.test.ts @@ -273,7 +273,7 @@ function makeCapabilitiesAdapter(overrides?: Partial): lists: { enabled: false }, dryRun: { enabled: false }, }, - format: { supportedMarks: [] }, + format: { properties: {} }, operations: {} as DocumentApiCapabilities['operations'], planEngine: { supportedStepOps: [], @@ -584,7 +584,7 @@ describe('createDocumentApi', () => { const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; api.format.bold({ target }, { changeMode: 'tracked' }); expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { bold: true } }, + { target, inline: { bold: 'on' } }, { changeMode: 'tracked', dryRun: false }, ); }); @@ -607,7 +607,7 @@ describe('createDocumentApi', () => { const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; api.format.italic({ target }, { changeMode: 'direct' }); expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { italic: true } }, + { target, inline: { italic: 'on' } }, { changeMode: 'direct', dryRun: false }, ); }); @@ -630,7 +630,7 @@ describe('createDocumentApi', () => { const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; api.format.underline({ target }, { changeMode: 'direct' }); expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { underline: true } }, + { target, inline: { underline: 'on' } }, { changeMode: 'direct', dryRun: false }, ); }); @@ -653,7 +653,7 @@ describe('createDocumentApi', () => { const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; api.format.strikethrough({ target }, { changeMode: 'tracked' }); expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { strike: true } }, + { target, inline: { strike: 'on' } }, { changeMode: 'tracked', dryRun: false }, ); }); @@ -1664,7 +1664,7 @@ describe('createDocumentApi', () => { const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 2 } } as const; api.format.bold({ target }); expect(formatAdpt.apply).toHaveBeenCalledWith( - { target, inline: { bold: true } }, + { target, inline: { bold: 'on' } }, { changeMode: 'direct', dryRun: false }, ); }); diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index 0a394af436..9638c91dff 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -5,6 +5,7 @@ export * from './types/index.js'; export * from './contract/index.js'; export * from './capabilities/capabilities.js'; +export * from './inline-semantics/index.js'; import type { CreateParagraphInput, @@ -532,16 +533,16 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { }, format: { bold(input: FormatBoldInput, options?: MutationOptions): TextMutationReceipt { - return executeStyleApply(adapters.format, { ...input, inline: { bold: true } }, options); + return executeStyleApply(adapters.format, { ...input, inline: { bold: 'on' } }, options); }, italic(input: FormatItalicInput, options?: MutationOptions): TextMutationReceipt { - return executeStyleApply(adapters.format, { ...input, inline: { italic: true } }, options); + return executeStyleApply(adapters.format, { ...input, inline: { italic: 'on' } }, options); }, underline(input: FormatUnderlineInput, options?: MutationOptions): TextMutationReceipt { - return executeStyleApply(adapters.format, { ...input, inline: { underline: true } }, options); + return executeStyleApply(adapters.format, { ...input, inline: { underline: 'on' } }, options); }, strikethrough(input: FormatStrikethroughInput, options?: MutationOptions): TextMutationReceipt { - return executeStyleApply(adapters.format, { ...input, inline: { strike: true } }, options); + return executeStyleApply(adapters.format, { ...input, inline: { strike: 'on' } }, options); }, apply(input: StyleApplyInput, options?: MutationOptions): TextMutationReceipt { return executeStyleApply(adapters.format, input, options); diff --git a/packages/document-api/src/inline-semantics/directives.test.ts b/packages/document-api/src/inline-semantics/directives.test.ts new file mode 100644 index 0000000000..642d57cf08 --- /dev/null +++ b/packages/document-api/src/inline-semantics/directives.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { + applyDirectiveTransition, + wouldDirectiveChange, + derivePropertyStateFromDirect, + derivePropertyStateWithCascade, +} from './directives.js'; +import type { DirectState } from './directives.js'; + +// --------------------------------------------------------------------------- +// Transition matrix — exhaustive (3 current × 3 directive = 9 combinations) +// --------------------------------------------------------------------------- + +describe('applyDirectiveTransition', () => { + const cases: Array<[DirectState, DirectState, DirectState]> = [ + ['on', 'on', 'on'], + ['on', 'off', 'off'], + ['on', 'clear', 'clear'], + ['off', 'on', 'on'], + ['off', 'off', 'off'], + ['off', 'clear', 'clear'], + ['clear', 'on', 'on'], + ['clear', 'off', 'off'], + ['clear', 'clear', 'clear'], + ]; + + it.each(cases)('(%s, %s) → %s', (current, directive, expected) => { + expect(applyDirectiveTransition(current, directive)).toBe(expected); + }); +}); + +describe('wouldDirectiveChange', () => { + it('returns false for no-ops', () => { + expect(wouldDirectiveChange('on', 'on')).toBe(false); + expect(wouldDirectiveChange('off', 'off')).toBe(false); + expect(wouldDirectiveChange('clear', 'clear')).toBe(false); + }); + + it('returns true for actual changes', () => { + expect(wouldDirectiveChange('on', 'off')).toBe(true); + expect(wouldDirectiveChange('on', 'clear')).toBe(true); + expect(wouldDirectiveChange('off', 'on')).toBe(true); + expect(wouldDirectiveChange('off', 'clear')).toBe(true); + expect(wouldDirectiveChange('clear', 'on')).toBe(true); + expect(wouldDirectiveChange('clear', 'off')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Direct-to-effective derivation (headless/conservative fallback) +// --------------------------------------------------------------------------- + +describe('derivePropertyStateFromDirect', () => { + it('on → effective: true, provenance: direct-on', () => { + const state = derivePropertyStateFromDirect('on'); + expect(state).toEqual({ direct: 'on', effective: true, provenance: 'direct-on' }); + }); + + it('off → effective: false, provenance: direct-off', () => { + const state = derivePropertyStateFromDirect('off'); + expect(state).toEqual({ direct: 'off', effective: false, provenance: 'direct-off' }); + }); + + it('clear → effective: false, provenance: unresolved', () => { + const state = derivePropertyStateFromDirect('clear'); + expect(state).toEqual({ direct: 'clear', effective: false, provenance: 'unresolved' }); + }); +}); + +// --------------------------------------------------------------------------- +// Cascade-resolved effective derivation +// --------------------------------------------------------------------------- + +describe('derivePropertyStateWithCascade', () => { + it('on with cascade false → still effective: true (direct wins)', () => { + const state = derivePropertyStateWithCascade('on', false); + expect(state).toEqual({ direct: 'on', effective: true, provenance: 'direct-on' }); + }); + + it('off with cascade true → still effective: false (direct wins)', () => { + const state = derivePropertyStateWithCascade('off', true); + expect(state).toEqual({ direct: 'off', effective: false, provenance: 'direct-off' }); + }); + + it('clear with cascade true → effective: true, provenance: style-cascade', () => { + const state = derivePropertyStateWithCascade('clear', true); + expect(state).toEqual({ direct: 'clear', effective: true, provenance: 'style-cascade' }); + }); + + it('clear with cascade false → effective: false, provenance: style-cascade', () => { + const state = derivePropertyStateWithCascade('clear', false); + expect(state).toEqual({ direct: 'clear', effective: false, provenance: 'style-cascade' }); + }); +}); diff --git a/packages/document-api/src/inline-semantics/directives.ts b/packages/document-api/src/inline-semantics/directives.ts new file mode 100644 index 0000000000..b30e84ba42 --- /dev/null +++ b/packages/document-api/src/inline-semantics/directives.ts @@ -0,0 +1,150 @@ +/** + * Directive state model and transition semantics for inline properties. + * + * Defines the canonical DirectState type, InlinePropertyState for full + * read-side reporting, and the transition matrix helpers. + */ + +import type { CorePropertyId } from './property-ids.js'; + +// --------------------------------------------------------------------------- +// State model +// --------------------------------------------------------------------------- + +/** Tri-state directive representing a run's direct inline formatting state. */ +export type DirectState = 'on' | 'off' | 'clear'; + +/** Where the effective value came from. */ +export type Provenance = 'direct-on' | 'direct-off' | 'style-cascade' | 'unresolved'; + +/** + * Full inline property state for a single property on a single run. + * Computed internally; `provenance` is not surfaced in the public contract yet. + */ +export interface InlinePropertyState { + direct: DirectState; + effective: boolean; + provenance: Provenance; +} + +// --------------------------------------------------------------------------- +// Direct-to-effective derivation (conservative fallback) +// --------------------------------------------------------------------------- + +/** + * Derives effective and provenance from direct state without style-engine cascade. + * + * Used when converter context is unavailable (headless mode, unit tests, non-DOCX). + * - `'on'` → effective: true, provenance: 'direct-on' + * - `'off'` → effective: false, provenance: 'direct-off' + * - `'clear'` → effective: false, provenance: 'unresolved' + */ +export function derivePropertyStateFromDirect(direct: DirectState): InlinePropertyState { + switch (direct) { + case 'on': + return { direct, effective: true, provenance: 'direct-on' }; + case 'off': + return { direct, effective: false, provenance: 'direct-off' }; + case 'clear': + return { direct, effective: false, provenance: 'unresolved' }; + } +} + +/** + * Creates a full property state with style-engine-resolved effective value. + * + * Used when converter context is available and the style-engine cascade + * has resolved the effective visual state. + */ +export function derivePropertyStateWithCascade(direct: DirectState, cascadeEffective: boolean): InlinePropertyState { + switch (direct) { + case 'on': + return { direct, effective: true, provenance: 'direct-on' }; + case 'off': + return { direct, effective: false, provenance: 'direct-off' }; + case 'clear': + return { direct, effective: cascadeEffective, provenance: 'style-cascade' }; + } +} + +// --------------------------------------------------------------------------- +// Transition matrix +// --------------------------------------------------------------------------- + +/** + * Determines the resulting direct state after applying a directive. + * + * The transition matrix is symmetric across all core-4 properties for the + * tri-state directive model. The actual PM mark operations differ per property + * (handled by the PM-binding layer), but the state transitions are uniform. + * + * | Current | Directive | Result | + * |---------|-----------|--------| + * | on | on | on (no-op) | + * | on | off | off | + * | on | clear | clear | + * | off | on | on | + * | off | off | off (no-op) | + * | off | clear | clear | + * | clear | on | on | + * | clear | off | off | + * | clear | clear | clear (no-op) | + */ +export function applyDirectiveTransition(current: DirectState, directive: DirectState): DirectState { + return directive; +} + +/** + * Returns true if applying the directive to the current state would produce + * an actual document change (not a no-op). + */ +export function wouldDirectiveChange(current: DirectState, directive: DirectState): boolean { + return current !== directive; +} + +// --------------------------------------------------------------------------- +// Effective resolution input contract (§4.1) +// --------------------------------------------------------------------------- + +/** Paragraph context for effective resolution. */ +export interface ResolutionParagraphContext { + styleId?: string; + properties: Record; +} + +/** Run context for effective resolution. */ +export interface ResolutionRunContext { + styleId?: string; + directProperties: Record; +} + +/** Numbering context (null when run is not in a list). */ +export interface ResolutionNumberingContext { + numId: string; + ilvl: number; +} + +/** Table context (null when run is not in a table). */ +export interface ResolutionTableContext { + tableStyleId?: string; + rowIndex: number; + cellIndex: number; + numRows: number; + numCells: number; +} + +/** Full resolution input for computing effective inline states. */ +export interface EffectiveResolutionInput { + paragraph: ResolutionParagraphContext; + run: ResolutionRunContext; + numbering: ResolutionNumberingContext | null; + table: ResolutionTableContext | null; + defaults: { docDefaults: Record; theme?: Record }; + revision: string; +} + +/** + * Resolver function signature for computing effective inline state. + * Returns the effective boolean for a single property. + */ +export type EffectiveResolver = (property: CorePropertyId, input: EffectiveResolutionInput) => boolean; diff --git a/packages/document-api/src/inline-semantics/error-types.ts b/packages/document-api/src/inline-semantics/error-types.ts new file mode 100644 index 0000000000..e15ac9e502 --- /dev/null +++ b/packages/document-api/src/inline-semantics/error-types.ts @@ -0,0 +1,94 @@ +/** + * Typed error/diagnostic records for inline formatting. + * + * Three frozen schemas: + * - `INVALID_INLINE_TOKEN` — shared (runtime: thrown; import: collected as diagnostic) + * - `STYLE_RESOLUTION_FAILED` — runtime-only (thrown) + * - `INVALID_TARGET` — runtime-only (thrown) + */ + +import type { CorePropertyId, CoreTogglePropertyId } from './property-ids.js'; + +// --------------------------------------------------------------------------- +// INVALID_INLINE_TOKEN — discriminated union by `property` +// --------------------------------------------------------------------------- + +interface InvalidInlineTokenBase { + code: 'INVALID_INLINE_TOKEN'; + /** The invalid value (`null` if attribute absent where value is required). */ + token: string | null; + /** Attribute-level OOXML xpath (e.g., `.../w:u/@w:themeTint`). */ + xpath: string; + /** Optional human-readable context. */ + context?: string; +} + +/** Toggle property variant (bold/italic/strike) — only `w:val` can fail. */ +export interface InvalidInlineTokenToggle extends InvalidInlineTokenBase { + property: CoreTogglePropertyId; + attribute: 'val'; +} + +/** Underline variant — `w:val` plus rich attrs can each fail independently. */ +export interface InvalidInlineTokenUnderline extends InvalidInlineTokenBase { + property: 'underline'; + attribute: 'val' | 'color' | 'themeColor' | 'themeTint' | 'themeShade'; +} + +/** + * Discriminated union for invalid inline token errors/diagnostics. + * Dual use: thrown at runtime, collected as diagnostic during import. + */ +export type InvalidInlineTokenError = InvalidInlineTokenToggle | InvalidInlineTokenUnderline; + +/** + * Type alias for import-site readability. + * `InlineTokenDiagnostic` IS `InvalidInlineTokenError` — one type, one schema, zero drift. + */ +export type InlineTokenDiagnostic = InvalidInlineTokenError; + +// --------------------------------------------------------------------------- +// STYLE_RESOLUTION_FAILED — runtime-only +// --------------------------------------------------------------------------- + +/** Required resolution input fields (§4.1 applicability matrix). */ +export const REQUIRED_RESOLUTION_FIELDS = ['defaults', 'paragraph', 'run', 'revision'] as const; +export type RequiredResolutionField = (typeof REQUIRED_RESOLUTION_FIELDS)[number]; + +export interface StyleResolutionFailedError { + code: 'STYLE_RESOLUTION_FAILED'; + property: CorePropertyId; + field: RequiredResolutionField; + /** PM document position of the failing run. */ + runPosition: number; + /** Block reference identifier for debugging. */ + blockRef: string; + /** Optional human-readable context. */ + context?: string; +} + +// --------------------------------------------------------------------------- +// INVALID_TARGET — discriminated union by `reason` +// --------------------------------------------------------------------------- + +interface InvalidTargetBase { + code: 'INVALID_TARGET'; + /** Optional human-readable context. */ + context?: string; +} + +export interface InvalidTargetOutOfRange extends InvalidTargetBase { + reason: 'out_of_range'; + target: { from: number; to: number }; +} + +export interface InvalidTargetInvalidSelector extends InvalidTargetBase { + reason: 'invalid_selector'; + target: { selector: string }; +} + +/** + * Discriminated union for invalid mutation target errors. + * Collapsed ranges (from === to) are NOT an error — they are a silent no-op. + */ +export type InvalidTargetError = InvalidTargetOutOfRange | InvalidTargetInvalidSelector; diff --git a/packages/document-api/src/inline-semantics/index.ts b/packages/document-api/src/inline-semantics/index.ts new file mode 100644 index 0000000000..5c9260c73c --- /dev/null +++ b/packages/document-api/src/inline-semantics/index.ts @@ -0,0 +1,86 @@ +/** + * Inline semantics — shared semantic layer (PM-independent). + * + * This is the single source of truth for inline property definitions, + * token acceptance sets, strict token parsers, error types, and directive + * transition semantics. + * + * Consumed by: contract validation, SDK, CLI, docs, conformance tests, + * and (transitively) the PM-binding layer in super-editor. + */ + +// Property identifiers +export { + CORE_PROPERTY_IDS, + CORE_PROPERTY_ID_SET, + CORE_TOGGLE_PROPERTY_IDS, + CORE_TOGGLE_PROPERTY_ID_SET, +} from './property-ids.js'; + +export type { CorePropertyId, CoreTogglePropertyId } from './property-ids.js'; + +// Token acceptance sets +export { + ST_ON_OFF_VALUES, + ST_ON_OFF_VALUE_SET, + ST_ON_OFF_ON_VALUES, + ST_ON_OFF_OFF_VALUES, + ST_UNDERLINE_VALUES, + ST_UNDERLINE_VALUE_SET, + ST_THEME_COLOR_VALUES, + ST_THEME_COLOR_VALUE_SET, +} from './token-sets.js'; + +export type { StOnOffValue, StUnderlineValue, StThemeColorValue } from './token-sets.js'; + +// Token parsers +export { + parseStOnOff, + parseStUnderline, + parseUnderlineColor, + parseUnderlineThemeColor, + parseUnderlineThemeModifier, +} from './token-parsers.js'; + +export type { + TokenParseOk, + TokenParseError, + TokenParseResult, + StOnOffParsed, + StUnderlineParsed, +} from './token-parsers.js'; + +// Error types +export { REQUIRED_RESOLUTION_FIELDS } from './error-types.js'; + +export type { + InvalidInlineTokenToggle, + InvalidInlineTokenUnderline, + InvalidInlineTokenError, + InlineTokenDiagnostic, + StyleResolutionFailedError, + InvalidTargetOutOfRange, + InvalidTargetInvalidSelector, + InvalidTargetError, + RequiredResolutionField, +} from './error-types.js'; + +// Directive state model and transitions +export { + derivePropertyStateFromDirect, + derivePropertyStateWithCascade, + applyDirectiveTransition, + wouldDirectiveChange, +} from './directives.js'; + +export type { + DirectState, + Provenance, + InlinePropertyState, + ResolutionParagraphContext, + ResolutionRunContext, + ResolutionNumberingContext, + ResolutionTableContext, + EffectiveResolutionInput, + EffectiveResolver, +} from './directives.js'; diff --git a/packages/document-api/src/inline-semantics/property-ids.test.ts b/packages/document-api/src/inline-semantics/property-ids.test.ts new file mode 100644 index 0000000000..82a8f7825a --- /dev/null +++ b/packages/document-api/src/inline-semantics/property-ids.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { + CORE_PROPERTY_IDS, + CORE_PROPERTY_ID_SET, + CORE_TOGGLE_PROPERTY_IDS, + CORE_TOGGLE_PROPERTY_ID_SET, +} from './property-ids.js'; + +describe('CORE_PROPERTY_IDS', () => { + it('contains exactly core-4 in spec order', () => { + expect(CORE_PROPERTY_IDS).toEqual(['bold', 'italic', 'underline', 'strike']); + }); + + it('set matches array', () => { + expect(CORE_PROPERTY_ID_SET.size).toBe(CORE_PROPERTY_IDS.length); + for (const id of CORE_PROPERTY_IDS) { + expect(CORE_PROPERTY_ID_SET.has(id)).toBe(true); + } + }); +}); + +describe('CORE_TOGGLE_PROPERTY_IDS', () => { + it('contains only pure-toggle properties (excludes underline)', () => { + expect(CORE_TOGGLE_PROPERTY_IDS).toEqual(['bold', 'italic', 'strike']); + }); + + it('is a strict subset of CORE_PROPERTY_IDS', () => { + for (const id of CORE_TOGGLE_PROPERTY_IDS) { + expect(CORE_PROPERTY_ID_SET.has(id)).toBe(true); + } + }); + + it('set matches array', () => { + expect(CORE_TOGGLE_PROPERTY_ID_SET.size).toBe(CORE_TOGGLE_PROPERTY_IDS.length); + }); +}); diff --git a/packages/document-api/src/inline-semantics/property-ids.ts b/packages/document-api/src/inline-semantics/property-ids.ts new file mode 100644 index 0000000000..1afb71ef32 --- /dev/null +++ b/packages/document-api/src/inline-semantics/property-ids.ts @@ -0,0 +1,30 @@ +/** + * Canonical inline property identifiers — single source of truth. + * + * All layers (contract, error schemas, SDK, CLI, registry, tests) must use + * these constants. No synonyms (e.g., "strikethrough") in contract-facing surfaces. + */ + +/** + * All supported core inline property IDs. + * Order: bold, italic, underline, strike (matches OOXML spec element ordering). + */ +export const CORE_PROPERTY_IDS = ['bold', 'italic', 'underline', 'strike'] as const; + +/** A single core inline property ID. */ +export type CorePropertyId = (typeof CORE_PROPERTY_IDS)[number]; + +/** Runtime set for O(1) property ID validation. */ +export const CORE_PROPERTY_ID_SET: ReadonlySet = new Set(CORE_PROPERTY_IDS); + +/** + * Pure-toggle property IDs (ON/OFF use identical attribute patterns). + * Underline is excluded because it is a composite property (toggle + rich attrs). + */ +export const CORE_TOGGLE_PROPERTY_IDS = ['bold', 'italic', 'strike'] as const; + +/** A single pure-toggle property ID. */ +export type CoreTogglePropertyId = (typeof CORE_TOGGLE_PROPERTY_IDS)[number]; + +/** Runtime set for O(1) toggle property ID validation. */ +export const CORE_TOGGLE_PROPERTY_ID_SET: ReadonlySet = new Set(CORE_TOGGLE_PROPERTY_IDS); diff --git a/packages/document-api/src/inline-semantics/token-parsers.test.ts b/packages/document-api/src/inline-semantics/token-parsers.test.ts new file mode 100644 index 0000000000..7d8bc9bc14 --- /dev/null +++ b/packages/document-api/src/inline-semantics/token-parsers.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest'; +import { + parseStOnOff, + parseStUnderline, + parseUnderlineColor, + parseUnderlineThemeColor, + parseUnderlineThemeModifier, +} from './token-parsers.js'; + +const XPATH_B = '/w:document/w:body/w:p/w:r/w:rPr/w:b/@w:val'; +const XPATH_U = '/w:document/w:body/w:p/w:r/w:rPr/w:u/@w:val'; +const XPATH_U_COLOR = '/w:document/w:body/w:p/w:r/w:rPr/w:u/@w:color'; +const XPATH_U_THEME = '/w:document/w:body/w:p/w:r/w:rPr/w:u/@w:themeColor'; +const XPATH_U_TINT = '/w:document/w:body/w:p/w:r/w:rPr/w:u/@w:themeTint'; +const XPATH_U_SHADE = '/w:document/w:body/w:p/w:r/w:rPr/w:u/@w:themeShade'; + +// --------------------------------------------------------------------------- +// ST_OnOff (bold/italic/strike) +// --------------------------------------------------------------------------- + +describe('parseStOnOff', () => { + it('bare element (null val) → ON', () => { + const result = parseStOnOff('bold', null, XPATH_B); + expect(result).toEqual({ ok: true, value: { direct: 'on' } }); + }); + + const onValues = ['true', '1', 'on'] as const; + it.each(onValues)('w:val="%s" → ON', (val) => { + expect(parseStOnOff('bold', val, XPATH_B)).toEqual({ ok: true, value: { direct: 'on' } }); + }); + + const offValues = ['false', '0', 'off'] as const; + it.each(offValues)('w:val="%s" → OFF', (val) => { + expect(parseStOnOff('italic', val, XPATH_B)).toEqual({ ok: true, value: { direct: 'off' } }); + }); + + it.each(['True', 'FALSE', 'ON', 'OFF', 'yes', 'no', '', 'garbage'])('w:val="%s" → INVALID', (val) => { + const result = parseStOnOff('strike', val, XPATH_B); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('INVALID_INLINE_TOKEN'); + expect(result.error.property).toBe('strike'); + expect(result.error.attribute).toBe('val'); + expect(result.error.token).toBe(val); + } + }); +}); + +// --------------------------------------------------------------------------- +// ST_Underline (underline w:val) +// --------------------------------------------------------------------------- + +describe('parseStUnderline', () => { + it('bare element (null val) → ON single', () => { + const result = parseStUnderline(null, XPATH_U); + expect(result).toEqual({ ok: true, value: { direct: 'on', underlineType: 'single' } }); + }); + + it('w:val="none" → OFF', () => { + const result = parseStUnderline('none', XPATH_U); + expect(result).toEqual({ ok: true, value: { direct: 'off', underlineType: 'none' } }); + }); + + const onTypes = [ + 'single', + 'double', + 'thick', + 'dotted', + 'dottedHeavy', + 'dash', + 'dashedHeavy', + 'dashLong', + 'dashLongHeavy', + 'dotDash', + 'dashDotHeavy', + 'dotDotDash', + 'dashDotDotHeavy', + 'wave', + 'wavyHeavy', + 'wavyDouble', + 'words', + ] as const; + + it.each(onTypes)('w:val="%s" → ON', (val) => { + const result = parseStUnderline(val, XPATH_U); + expect(result).toEqual({ ok: true, value: { direct: 'on', underlineType: val } }); + }); + + it.each(['Single', 'DOUBLE', 'garbage', '', 'true', 'false'])('w:val="%s" → INVALID', (val) => { + const result = parseStUnderline(val, XPATH_U); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('INVALID_INLINE_TOKEN'); + expect(result.error.property).toBe('underline'); + expect(result.error.attribute).toBe('val'); + expect(result.error.token).toBe(val); + } + }); +}); + +// --------------------------------------------------------------------------- +// Underline color +// --------------------------------------------------------------------------- + +describe('parseUnderlineColor', () => { + it('null → undefined', () => { + expect(parseUnderlineColor(null, XPATH_U_COLOR)).toEqual({ ok: true, value: undefined }); + }); + + it('"auto" → undefined', () => { + expect(parseUnderlineColor('auto', XPATH_U_COLOR)).toEqual({ ok: true, value: undefined }); + }); + + it('"FF0000" → "#ff0000"', () => { + expect(parseUnderlineColor('FF0000', XPATH_U_COLOR)).toEqual({ ok: true, value: '#ff0000' }); + }); + + it('"#FF0000" → "#ff0000"', () => { + expect(parseUnderlineColor('#FF0000', XPATH_U_COLOR)).toEqual({ ok: true, value: '#ff0000' }); + }); + + it('"ZZZZZZ" → INVALID', () => { + const result = parseUnderlineColor('ZZZZZZ', XPATH_U_COLOR); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.attribute).toBe('color'); + expect(result.error.token).toBe('ZZZZZZ'); + } + }); + + it('"F00" (3-digit) → INVALID', () => { + const result = parseUnderlineColor('F00', XPATH_U_COLOR); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.attribute).toBe('color'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Underline themeColor +// --------------------------------------------------------------------------- + +describe('parseUnderlineThemeColor', () => { + it('null → undefined', () => { + expect(parseUnderlineThemeColor(null, XPATH_U_THEME)).toEqual({ ok: true, value: undefined }); + }); + + const validColors = [ + 'dark1', + 'light1', + 'dark2', + 'light2', + 'accent1', + 'accent2', + 'accent3', + 'accent4', + 'accent5', + 'accent6', + 'hyperlink', + 'followedHyperlink', + 'background1', + 'text1', + 'background2', + 'text2', + 'none', + ] as const; + + it.each(validColors)('"%s" → stored as-is', (val) => { + expect(parseUnderlineThemeColor(val, XPATH_U_THEME)).toEqual({ ok: true, value: val }); + }); + + it('"notAThemeColor" → INVALID', () => { + const result = parseUnderlineThemeColor('notAThemeColor', XPATH_U_THEME); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.attribute).toBe('themeColor'); + }); + + it('"Accent1" (wrong case) → INVALID', () => { + const result = parseUnderlineThemeColor('Accent1', XPATH_U_THEME); + expect(result.ok).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Underline themeTint / themeShade +// --------------------------------------------------------------------------- + +describe('parseUnderlineThemeModifier', () => { + it('null → undefined', () => { + expect(parseUnderlineThemeModifier(null, 'themeTint', XPATH_U_TINT)).toEqual({ ok: true, value: undefined }); + }); + + it('"80" → "80" (uppercase)', () => { + expect(parseUnderlineThemeModifier('80', 'themeTint', XPATH_U_TINT)).toEqual({ ok: true, value: '80' }); + }); + + it('"0f" → "0F" (uppercase)', () => { + expect(parseUnderlineThemeModifier('0f', 'themeShade', XPATH_U_SHADE)).toEqual({ ok: true, value: '0F' }); + }); + + it('"GG" → INVALID', () => { + const result = parseUnderlineThemeModifier('GG', 'themeTint', XPATH_U_TINT); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.attribute).toBe('themeTint'); + }); + + it('"123" (3-digit) → INVALID', () => { + const result = parseUnderlineThemeModifier('123', 'themeShade', XPATH_U_SHADE); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.attribute).toBe('themeShade'); + }); +}); diff --git a/packages/document-api/src/inline-semantics/token-parsers.ts b/packages/document-api/src/inline-semantics/token-parsers.ts new file mode 100644 index 0000000000..51a68be4c1 --- /dev/null +++ b/packages/document-api/src/inline-semantics/token-parsers.ts @@ -0,0 +1,198 @@ +/** + * Strict OOXML token parsers for inline properties. + * + * Each parser produces a typed result: either a canonical value or a structured + * `InvalidInlineTokenError` record. The same parser is used by both runtime and + * import paths — the caller decides fatality (runtime: throw; import: collect). + */ + +import type { CoreTogglePropertyId } from './property-ids.js'; +import type { InvalidInlineTokenError, InvalidInlineTokenToggle, InvalidInlineTokenUnderline } from './error-types.js'; +import type { DirectState } from './directives.js'; +import { ST_ON_OFF_VALUE_SET, ST_ON_OFF_ON_VALUES, ST_ON_OFF_OFF_VALUES, ST_UNDERLINE_VALUE_SET, ST_THEME_COLOR_VALUE_SET } from './token-sets.js'; + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +export type TokenParseOk = { ok: true; value: T }; +export type TokenParseError = { ok: false; error: InvalidInlineTokenError }; +export type TokenParseResult = TokenParseOk | TokenParseError; + +// --------------------------------------------------------------------------- +// ST_OnOff parser (bold, italic, strike) +// --------------------------------------------------------------------------- + +/** + * Parsed result for an ST_OnOff property. + * `direct` is the canonical tri-state directive; `canonical` is the + * normalized OOXML value for export (`null` = ON bare element, `'0'` = OFF). + */ +export interface StOnOffParsed { + direct: DirectState; +} + +/** + * Parses a `w:val` attribute value for an ST_OnOff property. + * + * @param property - The property being parsed (bold/italic/strike). + * @param val - The `w:val` attribute value, or `null` for bare element (e.g., ``). + * @param xpath - Attribute-level xpath for error reporting. + */ +export function parseStOnOff( + property: CoreTogglePropertyId, + val: string | null, + xpath: string, +): TokenParseResult { + // Bare element (absent w:val) normalizes to ON + if (val === null) { + return { ok: true, value: { direct: 'on' } }; + } + + if (ST_ON_OFF_ON_VALUES.has(val)) { + return { ok: true, value: { direct: 'on' } }; + } + + if (ST_ON_OFF_OFF_VALUES.has(val)) { + return { ok: true, value: { direct: 'off' } }; + } + + // If we reach here, the value is not in ON or OFF sets → invalid token. + const error: InvalidInlineTokenToggle = { + code: 'INVALID_INLINE_TOKEN', + property, + attribute: 'val', + token: val, + xpath, + }; + return { ok: false, error }; +} + +// --------------------------------------------------------------------------- +// ST_Underline parser (underline w:val) +// --------------------------------------------------------------------------- + +export interface StUnderlineParsed { + direct: DirectState; + /** Underline type for ON states; `'none'` for OFF; `undefined` for absent. */ + underlineType?: string; +} + +/** + * Parses a `w:val` attribute for ST_Underline. + * + * @param val - The `w:val` attribute value, or `null` for bare ``. + * @param xpath - Attribute-level xpath for error reporting. + */ +export function parseStUnderline(val: string | null, xpath: string): TokenParseResult { + // Bare element (absent w:val) normalizes to ON with default style + if (val === null) { + return { ok: true, value: { direct: 'on', underlineType: 'single' } }; + } + + if (val === 'none') { + return { ok: true, value: { direct: 'off', underlineType: 'none' } }; + } + + if (ST_UNDERLINE_VALUE_SET.has(val)) { + return { ok: true, value: { direct: 'on', underlineType: val } }; + } + + // Invalid token + const error: InvalidInlineTokenUnderline = { + code: 'INVALID_INLINE_TOKEN', + property: 'underline', + attribute: 'val', + token: val, + xpath, + }; + return { ok: false, error }; +} + +// --------------------------------------------------------------------------- +// Underline rich attribute parsers +// --------------------------------------------------------------------------- + +/** + * Parses and normalizes a `w:color` attribute on ``. + * + * Accepts: 6-digit hex (with or without `#`), `auto`. + * - Valid hex → lowercase 6-digit with `#` prefix. + * - `auto` → `undefined` (theme-resolved, not stored as direct). + * - Invalid → error. + */ +export function parseUnderlineColor(val: string | null, xpath: string): TokenParseResult { + if (val === null) { + return { ok: true, value: undefined }; + } + + if (val === 'auto') { + return { ok: true, value: undefined }; + } + + // Strip optional # prefix for validation + const hex = val.startsWith('#') ? val.slice(1) : val; + + if (/^[0-9a-fA-F]{6}$/.test(hex)) { + return { ok: true, value: `#${hex.toLowerCase()}` }; + } + + const error: InvalidInlineTokenUnderline = { + code: 'INVALID_INLINE_TOKEN', + property: 'underline', + attribute: 'color', + token: val, + xpath, + }; + return { ok: false, error }; +} + +/** + * Parses a `w:themeColor` attribute on ``. + * Must be an exact match from ST_ThemeColor (case-sensitive). + */ +export function parseUnderlineThemeColor(val: string | null, xpath: string): TokenParseResult { + if (val === null) { + return { ok: true, value: undefined }; + } + + if (ST_THEME_COLOR_VALUE_SET.has(val)) { + return { ok: true, value: val }; + } + + const error: InvalidInlineTokenUnderline = { + code: 'INVALID_INLINE_TOKEN', + property: 'underline', + attribute: 'themeColor', + token: val, + xpath, + }; + return { ok: false, error }; +} + +/** + * Parses a `w:themeTint` or `w:themeShade` attribute on ``. + * Must be a 2-digit hex string (00–FF). Normalizes to uppercase. + */ +export function parseUnderlineThemeModifier( + val: string | null, + attribute: 'themeTint' | 'themeShade', + xpath: string, +): TokenParseResult { + if (val === null) { + return { ok: true, value: undefined }; + } + + if (/^[0-9a-fA-F]{2}$/.test(val)) { + return { ok: true, value: val.toUpperCase() }; + } + + const error: InvalidInlineTokenUnderline = { + code: 'INVALID_INLINE_TOKEN', + property: 'underline', + attribute, + token: val, + xpath, + }; + return { ok: false, error }; +} diff --git a/packages/document-api/src/inline-semantics/token-sets.ts b/packages/document-api/src/inline-semantics/token-sets.ts new file mode 100644 index 0000000000..0018cdfdd9 --- /dev/null +++ b/packages/document-api/src/inline-semantics/token-sets.ts @@ -0,0 +1,93 @@ +/** + * OOXML token acceptance sets — strict, case-sensitive, per-property. + * + * These are the exhaustive sets of accepted values for inline property tokens. + * Any value not in these sets is an invalid token and must produce a structured + * diagnostic (import) or error (runtime). + */ + +// --------------------------------------------------------------------------- +// ST_OnOff (bold, italic, strike) +// --------------------------------------------------------------------------- + +/** + * Accepted `w:val` values for ST_OnOff properties (case-sensitive, per OOXML spec). + * Absent `w:val` (bare element like ``) normalizes to ON — handled by parsers, not here. + */ +export const ST_ON_OFF_VALUES = ['true', 'false', '1', '0', 'on', 'off'] as const; +export type StOnOffValue = (typeof ST_ON_OFF_VALUES)[number]; + +/** Runtime set for O(1) ST_OnOff validation. */ +export const ST_ON_OFF_VALUE_SET: ReadonlySet = new Set(ST_ON_OFF_VALUES); + +/** ST_OnOff values that normalize to ON. */ +export const ST_ON_OFF_ON_VALUES: ReadonlySet = new Set(['true', '1', 'on']); + +/** ST_OnOff values that normalize to OFF. */ +export const ST_ON_OFF_OFF_VALUES: ReadonlySet = new Set(['false', '0', 'off']); + +// --------------------------------------------------------------------------- +// ST_Underline (underline w:val) +// --------------------------------------------------------------------------- + +/** + * Accepted `w:val` values for ST_Underline (exhaustive, case-sensitive). + * `'none'` maps to OFF; all others map to ON with the specified underline type. + */ +export const ST_UNDERLINE_VALUES = [ + 'single', + 'double', + 'thick', + 'dotted', + 'dottedHeavy', + 'dash', + 'dashedHeavy', + 'dashLong', + 'dashLongHeavy', + 'dotDash', + 'dashDotHeavy', + 'dotDotDash', + 'dashDotDotHeavy', + 'wave', + 'wavyHeavy', + 'wavyDouble', + 'words', + 'none', +] as const; + +export type StUnderlineValue = (typeof ST_UNDERLINE_VALUES)[number]; + +/** Runtime set for O(1) ST_Underline validation. */ +export const ST_UNDERLINE_VALUE_SET: ReadonlySet = new Set(ST_UNDERLINE_VALUES); + +// --------------------------------------------------------------------------- +// ST_ThemeColor (underline rich attrs: w:themeColor) +// --------------------------------------------------------------------------- + +/** + * Accepted values for ST_ThemeColor (exhaustive, case-sensitive, per ECMA-376 §17.18.97). + */ +export const ST_THEME_COLOR_VALUES = [ + 'dark1', + 'light1', + 'dark2', + 'light2', + 'accent1', + 'accent2', + 'accent3', + 'accent4', + 'accent5', + 'accent6', + 'hyperlink', + 'followedHyperlink', + 'background1', + 'text1', + 'background2', + 'text2', + 'none', +] as const; + +export type StThemeColorValue = (typeof ST_THEME_COLOR_VALUES)[number]; + +/** Runtime set for O(1) ST_ThemeColor validation. */ +export const ST_THEME_COLOR_VALUE_SET: ReadonlySet = new Set(ST_THEME_COLOR_VALUES); diff --git a/packages/document-api/src/invoke/invoke.test.ts b/packages/document-api/src/invoke/invoke.test.ts index 08d7f4623a..a269d35627 100644 --- a/packages/document-api/src/invoke/invoke.test.ts +++ b/packages/document-api/src/invoke/invoke.test.ts @@ -38,7 +38,7 @@ function makeAdapters() { lists: { enabled: false }, dryRun: { enabled: false }, }, - format: { supportedMarks: [] }, + format: { properties: {} }, operations: {} as DocumentApiCapabilities['operations'], planEngine: { supportedStepOps: [], @@ -304,7 +304,7 @@ describe('invoke', () => { const api = createDocumentApi(adapters); const input = { target: { kind: 'text' as const, blockId: 'p1', range: { start: 0, end: 2 } }, - inline: { bold: true }, + inline: { bold: 'on' }, }; const direct = api.format.apply(input); const invoked = api.invoke({ operationId: 'format.apply', input }); diff --git a/packages/document-api/src/overview-examples.test.ts b/packages/document-api/src/overview-examples.test.ts index d94b16994f..fa073ebb3a 100644 --- a/packages/document-api/src/overview-examples.test.ts +++ b/packages/document-api/src/overview-examples.test.ts @@ -209,7 +209,14 @@ function makeCapabilitiesAdapter(): { get: ReturnType } { lists: { enabled: true }, dryRun: { enabled: true }, }, - format: { supportedMarks: ['bold', 'italic', 'underline', 'strike'] }, + format: { + properties: { + bold: { kind: 'toggle', directives: ['on', 'off', 'clear'] }, + italic: { kind: 'toggle', directives: ['on', 'off', 'clear'] }, + underline: { kind: 'toggle', directives: ['on', 'off', 'clear'] }, + strike: { kind: 'toggle', directives: ['on', 'off', 'clear'] }, + }, + }, operations: Object.fromEntries( [ 'find', @@ -300,10 +307,8 @@ function makeApi() { range: { start: 0, end: 3 }, text: 'foo', styles: { - bold: false, - italic: false, - underline: false, - strike: false, + direct: { bold: 'clear', italic: 'clear', underline: 'clear', strike: 'clear' }, + effective: { bold: false, italic: false, underline: false, strike: false }, }, ref: 'ref:run-1', }, @@ -400,7 +405,7 @@ describe('overview.mdx examples', () => { id: 'style-terms', op: 'format.apply', where: { by: 'ref' as const, ref }, - args: { inline: { bold: true } }, + args: { inline: { bold: 'on' } }, }, ], }; @@ -456,7 +461,7 @@ describe('overview.mdx examples', () => { const target = { kind: 'text', blockId: 'p1', range: { start: 0, end: 3 } }; if (caps.operations['format.apply'].available) { - doc.format.apply({ target, inline: { bold: true } }); + doc.format.apply({ target, inline: { bold: 'on' } }); } if (caps.global.trackChanges.enabled) { @@ -571,7 +576,7 @@ describe('src/README.md workflow examples', () => { const caps = doc.capabilities(); if (caps.operations['format.apply'].available) { - doc.format.apply({ target, inline: { bold: true } }); + doc.format.apply({ target, inline: { bold: 'on' } }); } if (caps.global.trackChanges.enabled) { doc.insert({ value: 'tracked' }, { changeMode: 'tracked' }); diff --git a/packages/document-api/src/types/discovery.ts b/packages/document-api/src/types/discovery.ts index 58a0ac1210..6077d5e045 100644 --- a/packages/document-api/src/types/discovery.ts +++ b/packages/document-api/src/types/discovery.ts @@ -78,8 +78,11 @@ export type DiscoveryItem = { * Standard discovery result envelope returned by all discovery operations. * * Provides revision tracking, total count, paginated items, and page metadata. + * The optional `TMeta` type parameter allows operation-specific metadata + * (e.g., `query.match` uses it for `effectiveResolved`). Defaults to `undefined` + * for operations that don't use it — backwards compatible. */ -export interface DiscoveryResult { +export interface DiscoveryResult { /** Document revision at which the query was evaluated. */ evaluatedRevision: string; /** Total number of matching entities (before pagination). */ @@ -88,6 +91,8 @@ export interface DiscoveryResult { items: TItem[]; /** Pagination metadata. Invariant: `page.returned === items.length`. */ page: PageInfo; + /** Operation-specific metadata. Present only when `TMeta` is specified. */ + meta?: TMeta; } /** @@ -95,7 +100,7 @@ export interface DiscoveryResult { * * This is the return type of every standardized discovery operation. */ -export type DiscoveryOutput = DiscoveryResult>; +export type DiscoveryOutput = DiscoveryResult, TMeta>; // --------------------------------------------------------------------------- // Builder helpers @@ -124,23 +129,28 @@ export function buildDiscoveryItem( * * @throws {Error} if `page.returned !== items.length` */ -export function buildDiscoveryResult(params: { +export function buildDiscoveryResult(params: { evaluatedRevision: string; total: number; items: TItem[]; page: PageInfo; -}): DiscoveryResult { + meta?: TMeta; +}): DiscoveryResult { if (params.page.returned !== params.items.length) { throw new Error( `DiscoveryResult invariant violated: page.returned (${params.page.returned}) !== items.length (${params.items.length})`, ); } - return { + const result: DiscoveryResult = { evaluatedRevision: params.evaluatedRevision, total: params.total, items: params.items, page: params.page, }; + if (params.meta !== undefined) { + result.meta = params.meta; + } + return result; } /** diff --git a/packages/document-api/src/types/query-match.types.ts b/packages/document-api/src/types/query-match.types.ts index 14936b958d..862fadaffa 100644 --- a/packages/document-api/src/types/query-match.types.ts +++ b/packages/document-api/src/types/query-match.types.ts @@ -10,7 +10,8 @@ import type { BlockNodeType, NodeAddress } from './base.js'; import type { TextSelector, NodeSelector } from './query.js'; -import type { DiscoveryItem, DiscoveryOutput } from './discovery.js'; +import type { DiscoveryItem, DiscoveryOutput, DiscoveryResult } from './discovery.js'; +import type { InlineToggleDirective } from './style-policy.types.js'; export type CardinalityRequirement = 'any' | 'first' | 'exactlyOne' | 'all'; @@ -40,18 +41,32 @@ export interface HighlightRange { // Match styles (D1, D15) // --------------------------------------------------------------------------- -/** - * Inline style state for a single run. - * - * Core-4 booleans are always present. Optional presentational fields are - * emitted only when the source document provides them (omit-not-undefined). - * See D15 for normalization rules. - */ -export interface MatchStyle { +/** Direct toggle states — what the run explicitly declares at run level. */ +export interface MatchDirectStyles { + bold: InlineToggleDirective; + italic: InlineToggleDirective; + underline: InlineToggleDirective; + strike: InlineToggleDirective; +} + +/** Effective visual states — what the user sees after style cascade resolution. */ +export interface MatchEffectiveStyles { bold: boolean; italic: boolean; underline: boolean; strike: boolean; +} + +/** + * Two-layer inline style state for a single run. + * + * - `direct`: tri-state directives reflecting the run's explicit formatting. + * - `effective`: boolean visual state after style cascade resolution. + * - Non-toggle properties remain at top level. + */ +export interface MatchStyle { + direct: MatchDirectStyles; + effective: MatchEffectiveStyles; /** 6-digit lowercase hex with `#` prefix (e.g., `#ff0000`). */ color?: string; /** 6-digit lowercase hex with `#` prefix. */ @@ -175,6 +190,22 @@ export type TextMatchItem = DiscoveryItem; /** Node match item (blocks.length === 0). */ export type NodeMatchItem = DiscoveryItem; +// --------------------------------------------------------------------------- +// Query-level metadata (§4.3) +// --------------------------------------------------------------------------- + +/** + * Metadata for `query.match` — always present on the output envelope. + * + * - `effectiveResolved: true` — converter context was available; `styles.effective` + * on every run reflects full cascade resolution. + * - `effectiveResolved: false` — converter context was unavailable (headless/non-DOCX); + * `styles.effective` is derived from `direct` only. + */ +export interface QueryMatchMeta { + effectiveResolved: boolean; +} + // --------------------------------------------------------------------------- // Input / Output // --------------------------------------------------------------------------- @@ -193,9 +224,15 @@ export interface QueryMatchInput { /** * Standardized discovery output for `query.match`. * + * Narrows the shared `DiscoveryResult` envelope so that `meta: QueryMatchMeta` + * is **required** (not optional). SDK/client code consuming `query.match` gets + * `output.meta.effectiveResolved` as a non-optional boolean. + * * Items are `DiscoveryItem`: * - `id`: deterministic identity, revision-scoped (format: `m:`, D7) * - `handle`: mutation-ready `ResolvedHandle` with `ref`, `refStability`, `targetKind` * - Plus domain fields (`address`, `blocks`, `snippet`, `highlightRange`) */ -export type QueryMatchOutput = DiscoveryOutput; +export type QueryMatchOutput = Omit, QueryMatchMeta>, 'meta'> & { + meta: QueryMatchMeta; +}; diff --git a/packages/document-api/src/types/style-policy.types.ts b/packages/document-api/src/types/style-policy.types.ts index 38ed2b7ef8..cd530c2f4e 100644 --- a/packages/document-api/src/types/style-policy.types.ts +++ b/packages/document-api/src/types/style-policy.types.ts @@ -4,22 +4,48 @@ * Defines how inline and paragraph styles are handled during text rewrites. */ +import { CORE_PROPERTY_IDS, CORE_PROPERTY_ID_SET } from '../inline-semantics/property-ids.js'; +import type { CorePropertyId } from '../inline-semantics/property-ids.js'; + export type NonUniformStrategy = 'error' | 'useLeadingRun' | 'majority' | 'union'; -/** Canonical mark key set — single source of truth for contract, runtime, and schema. */ -export const MARK_KEYS = ['bold', 'italic', 'underline', 'strike'] as const; +/** + * Canonical mark key set — derived from inline-semantics property IDs. + * Retained for backward compatibility; prefer {@link CORE_PROPERTY_IDS} in new code. + */ +export const MARK_KEYS = CORE_PROPERTY_IDS; + +/** A single canonical mark key. Equivalent to {@link CorePropertyId}. */ +export type MarkKey = CorePropertyId; + +/** Runtime set for O(1) mark key validation. Equivalent to {@link CORE_PROPERTY_ID_SET}. */ +export const MARK_KEY_SET: ReadonlySet = CORE_PROPERTY_ID_SET; -/** A single canonical mark key. Derived from {@link MARK_KEYS}. */ -export type MarkKey = (typeof MARK_KEYS)[number]; +// --------------------------------------------------------------------------- +// Inline toggle directive model — tri-state: on | off | clear +// --------------------------------------------------------------------------- -/** Runtime set for O(1) mark key validation. */ -export const MARK_KEY_SET: ReadonlySet = new Set(MARK_KEYS); +/** Canonical directive vocabulary for inline toggle properties. */ +export const INLINE_DIRECTIVES = ['on', 'off', 'clear'] as const; +/** A single inline toggle directive. */ +export type InlineToggleDirective = (typeof INLINE_DIRECTIVES)[number]; + +/** Runtime set for O(1) directive validation. */ +export const INLINE_DIRECTIVE_SET: ReadonlySet = new Set(INLINE_DIRECTIVES); + +/** + * Inline toggle directives for core-4 marks. + * + * - `'on'` — write direct ON formatting. + * - `'off'` — write explicit run-level OFF/negation override. + * - `'clear'` — remove direct run-level property (inherit from style cascade). + */ export interface SetMarks { - bold?: boolean; - italic?: boolean; - underline?: boolean; - strike?: boolean; + bold?: InlineToggleDirective; + italic?: InlineToggleDirective; + underline?: InlineToggleDirective; + strike?: InlineToggleDirective; } export interface InlineStylePolicy { diff --git a/packages/sdk/langs/node/src/helpers/__tests__/format.test.ts b/packages/sdk/langs/node/src/helpers/__tests__/format.test.ts index d7232e5acd..e8a48f949f 100644 --- a/packages/sdk/langs/node/src/helpers/__tests__/format.test.ts +++ b/packages/sdk/langs/node/src/helpers/__tests__/format.test.ts @@ -1,5 +1,18 @@ import { describe, expect, test, mock } from 'bun:test'; -import { formatBold, formatItalic, formatUnderline, formatStrikethrough } from '../format.js'; +import { + formatBold, + formatItalic, + formatUnderline, + formatStrikethrough, + unformatBold, + unformatItalic, + unformatUnderline, + unformatStrikethrough, + clearBold, + clearItalic, + clearUnderline, + clearStrikethrough, +} from '../format.js'; import type { OperationSpec, InvokeOptions } from '../../runtime/transport-common.js'; type InvokeFn = (spec: OperationSpec, params?: Record, options?: InvokeOptions) => Promise; @@ -17,44 +30,44 @@ function createMockInvoke(): { } describe('format helpers', () => { - test('formatBold calls format.apply with inline.bold=true', async () => { + test("formatBold calls format.apply with inline.bold='on'", async () => { const { invoke, calls } = createMockInvoke(); await formatBold(invoke, { blockId: 'p1', start: 0, end: 5 }); expect(calls).toHaveLength(1); expect(calls[0].spec.operationId).toBe('doc.format.apply'); - expect(calls[0].params.inline).toEqual({ bold: true }); + expect(calls[0].params.inline).toEqual({ bold: 'on' }); expect(calls[0].params.target).toEqual({ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }); expect(calls[0].params.blockId).toBeUndefined(); expect(calls[0].params.start).toBeUndefined(); expect(calls[0].params.end).toBeUndefined(); }); - test('formatItalic calls format.apply with inline.italic=true', async () => { + test("formatItalic calls format.apply with inline.italic='on'", async () => { const { invoke, calls } = createMockInvoke(); await formatItalic(invoke, { blockId: 'p1', start: 0, end: 5 }); expect(calls).toHaveLength(1); expect(calls[0].spec.operationId).toBe('doc.format.apply'); - expect(calls[0].params.inline).toEqual({ italic: true }); + expect(calls[0].params.inline).toEqual({ italic: 'on' }); }); - test('formatUnderline calls format.apply with inline.underline=true', async () => { + test("formatUnderline calls format.apply with inline.underline='on'", async () => { const { invoke, calls } = createMockInvoke(); await formatUnderline(invoke, { blockId: 'p1', start: 0, end: 5 }); expect(calls).toHaveLength(1); expect(calls[0].spec.operationId).toBe('doc.format.apply'); - expect(calls[0].params.inline).toEqual({ underline: true }); + expect(calls[0].params.inline).toEqual({ underline: 'on' }); }); - test('formatStrikethrough calls format.apply with inline.strike=true', async () => { + test("formatStrikethrough calls format.apply with inline.strike='on'", async () => { const { invoke, calls } = createMockInvoke(); await formatStrikethrough(invoke, { blockId: 'p1', start: 0, end: 5 }); expect(calls).toHaveLength(1); expect(calls[0].spec.operationId).toBe('doc.format.apply'); - expect(calls[0].params.inline).toEqual({ strike: true }); + expect(calls[0].params.inline).toEqual({ strike: 'on' }); }); test('helpers pass through target address', async () => { @@ -86,7 +99,7 @@ describe('format helpers', () => { await formatBold(invoke); expect(calls).toHaveLength(1); - expect(calls[0].params.inline).toEqual({ bold: true }); + expect(calls[0].params.inline).toEqual({ bold: 'on' }); }); test('all helpers use the same operation spec', async () => { @@ -108,4 +121,30 @@ describe('format helpers', () => { expect(calls[0].spec.commandTokens).toEqual(['format', 'apply']); }); + + test('unformat helpers apply OFF directives', async () => { + const { invoke, calls } = createMockInvoke(); + await unformatBold(invoke, { blockId: 'p1', start: 0, end: 5 }); + await unformatItalic(invoke, { blockId: 'p1', start: 0, end: 5 }); + await unformatUnderline(invoke, { blockId: 'p1', start: 0, end: 5 }); + await unformatStrikethrough(invoke, { blockId: 'p1', start: 0, end: 5 }); + + expect(calls[0].params.inline).toEqual({ bold: 'off' }); + expect(calls[1].params.inline).toEqual({ italic: 'off' }); + expect(calls[2].params.inline).toEqual({ underline: 'off' }); + expect(calls[3].params.inline).toEqual({ strike: 'off' }); + }); + + test('clear helpers apply CLEAR directives', async () => { + const { invoke, calls } = createMockInvoke(); + await clearBold(invoke, { blockId: 'p1', start: 0, end: 5 }); + await clearItalic(invoke, { blockId: 'p1', start: 0, end: 5 }); + await clearUnderline(invoke, { blockId: 'p1', start: 0, end: 5 }); + await clearStrikethrough(invoke, { blockId: 'p1', start: 0, end: 5 }); + + expect(calls[0].params.inline).toEqual({ bold: 'clear' }); + expect(calls[1].params.inline).toEqual({ italic: 'clear' }); + expect(calls[2].params.inline).toEqual({ underline: 'clear' }); + expect(calls[3].params.inline).toEqual({ strike: 'clear' }); + }); }); diff --git a/packages/sdk/langs/node/src/helpers/format.ts b/packages/sdk/langs/node/src/helpers/format.ts index 32be6131b3..8c5d52ee0c 100644 --- a/packages/sdk/langs/node/src/helpers/format.ts +++ b/packages/sdk/langs/node/src/helpers/format.ts @@ -2,22 +2,25 @@ * Format helper methods for the Node SDK. * * These are hand-written convenience wrappers that call the canonical - * `format.apply` operation with pre-filled inline styles. They are NOT generated + * `format.apply` operation with pre-filled inline directives. They are NOT generated * from the contract and will not be overwritten by `pnpm run generate:all`. * * Usage: * ```ts * import { createSuperDocClient } from 'superdoc'; - * import { formatBold, formatItalic } from 'superdoc/helpers/format'; + * import { formatBold, unformatBold, clearBold } from 'superdoc/helpers/format'; * * const client = createSuperDocClient(); * await client.connect(); * - * // Canonical form: + * // Apply bold ON: * await formatBold(client.doc, { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } } }); * - * // Flat-flag shorthand (normalized before dispatch): - * await formatBold(client.doc, { blockId: 'p1', start: 0, end: 5 }); + * // Apply explicit bold OFF (override style inheritance): + * await unformatBold(client.doc, { blockId: 'p1', start: 0, end: 5 }); + * + * // Clear direct bold formatting (inherit from style cascade): + * await clearBold(client.doc, { blockId: 'p1', start: 0, end: 5 }); * ``` */ @@ -87,42 +90,82 @@ function normalizeFormatParams(params: FormatHelperParams): Record; } -function mergeInlineStyles(params: FormatHelperParams, inline: Record): Record { +function mergeInlineStyles(params: FormatHelperParams, inline: Record): Record { return { ...normalizeFormatParams(params), inline }; } -/** - * Apply bold formatting to a text range. - * - * Equivalent to `format.apply` with `inline: { bold: true }`. - */ +// --------------------------------------------------------------------------- +// format* helpers — apply ON directive +// --------------------------------------------------------------------------- + +/** Apply bold ON. Equivalent to `format.apply` with `inline: { bold: 'on' }`. */ export function formatBold(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { - return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { bold: true }), options); + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { bold: 'on' }), options); } -/** - * Apply italic formatting to a text range. - * - * Equivalent to `format.apply` with `inline: { italic: true }`. - */ +/** Apply italic ON. Equivalent to `format.apply` with `inline: { italic: 'on' }`. */ export function formatItalic(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { - return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { italic: true }), options); + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { italic: 'on' }), options); } -/** - * Apply underline formatting to a text range. - * - * Equivalent to `format.apply` with `inline: { underline: true }`. - */ +/** Apply underline ON. Equivalent to `format.apply` with `inline: { underline: 'on' }`. */ export function formatUnderline(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { - return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { underline: true }), options); + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { underline: 'on' }), options); } -/** - * Apply strikethrough formatting to a text range. - * - * Equivalent to `format.apply` with `inline: { strike: true }`. - */ +/** Apply strikethrough ON. Equivalent to `format.apply` with `inline: { strike: 'on' }`. */ export function formatStrikethrough(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { - return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { strike: true }), options); + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { strike: 'on' }), options); +} + +// --------------------------------------------------------------------------- +// unformat* helpers — apply explicit OFF directive (style override) +// --------------------------------------------------------------------------- + +/** Apply bold OFF. Equivalent to `format.apply` with `inline: { bold: 'off' }`. */ +export function unformatBold(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { bold: 'off' }), options); +} + +/** Apply italic OFF. Equivalent to `format.apply` with `inline: { italic: 'off' }`. */ +export function unformatItalic(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { italic: 'off' }), options); +} + +/** Apply underline OFF. Equivalent to `format.apply` with `inline: { underline: 'off' }`. */ +export function unformatUnderline(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { underline: 'off' }), options); +} + +/** Apply strikethrough OFF. Equivalent to `format.apply` with `inline: { strike: 'off' }`. */ +export function unformatStrikethrough( + invoke: RuntimeInvokeFn, + params: FormatHelperParams = {}, + options?: InvokeOptions, +) { + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { strike: 'off' }), options); +} + +// --------------------------------------------------------------------------- +// clear* helpers — remove direct formatting (inherit from style cascade) +// --------------------------------------------------------------------------- + +/** Clear bold formatting. Equivalent to `format.apply` with `inline: { bold: 'clear' }`. */ +export function clearBold(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { bold: 'clear' }), options); +} + +/** Clear italic formatting. Equivalent to `format.apply` with `inline: { italic: 'clear' }`. */ +export function clearItalic(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { italic: 'clear' }), options); +} + +/** Clear underline formatting. Equivalent to `format.apply` with `inline: { underline: 'clear' }`. */ +export function clearUnderline(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { underline: 'clear' }), options); +} + +/** Clear strikethrough formatting. Equivalent to `format.apply` with `inline: { strike: 'clear' }`. */ +export function clearStrikethrough(invoke: RuntimeInvokeFn, params: FormatHelperParams = {}, options?: InvokeOptions) { + return invoke(FORMAT_APPLY_SPEC, mergeInlineStyles(params, { strike: 'clear' }), options); } diff --git a/packages/sdk/langs/python/superdoc/helpers/format.py b/packages/sdk/langs/python/superdoc/helpers/format.py index 201b8fec40..fb47d3b029 100644 --- a/packages/sdk/langs/python/superdoc/helpers/format.py +++ b/packages/sdk/langs/python/superdoc/helpers/format.py @@ -2,25 +2,25 @@ Format helper functions for the SuperDoc Python SDK. These are hand-written convenience wrappers that call the canonical -``format.apply`` operation with pre-filled inline styles. They are NOT generated +``format.apply`` operation with pre-filled inline directives. They are NOT generated from the contract and will not be overwritten by ``pnpm run generate:all``. Usage:: from superdoc import SuperDocClient - from superdoc.helpers import format_bold, format_italic + from superdoc.helpers import format_bold, unformat_bold, clear_bold client = SuperDocClient() client.connect() - # Canonical form: - result = client.doc.format_apply( - target={"kind": "text", "blockId": "p1", "range": {"start": 0, "end": 5}}, - inline={"bold": True}, - ) - - # Flat-flag shorthand (normalized before dispatch): + # Apply bold ON: result = format_bold(client.doc, block_id="p1", start=0, end=5) + + # Apply explicit bold OFF (override style inheritance): + result = unformat_bold(client.doc, block_id="p1", start=0, end=5) + + # Clear direct bold formatting (inherit from style cascade): + result = clear_bold(client.doc, block_id="p1", start=0, end=5) """ from __future__ import annotations @@ -64,7 +64,7 @@ def _normalize_target( def _format_inline( doc: DocApi, - inline: dict[str, bool], + inline: dict[str, str], *, target: Optional[dict[str, Any]] = None, block_id: Optional[str] = None, @@ -98,21 +98,76 @@ def _format_inline( return doc.format_apply(**kwargs) +# --------------------------------------------------------------------------- +# format_* helpers — apply ON directive +# --------------------------------------------------------------------------- + + def format_bold(doc: DocApi, **kwargs: Any) -> Any: - """Apply bold formatting. Equivalent to ``format.apply(inline={"bold": True})``.""" - return _format_inline(doc, {"bold": True}, **kwargs) + """Apply bold ON. Equivalent to ``format.apply(inline={"bold": "on"})``.""" + return _format_inline(doc, {"bold": "on"}, **kwargs) def format_italic(doc: DocApi, **kwargs: Any) -> Any: - """Apply italic formatting. Equivalent to ``format.apply(inline={"italic": True})``.""" - return _format_inline(doc, {"italic": True}, **kwargs) + """Apply italic ON. Equivalent to ``format.apply(inline={"italic": "on"})``.""" + return _format_inline(doc, {"italic": "on"}, **kwargs) def format_underline(doc: DocApi, **kwargs: Any) -> Any: - """Apply underline formatting. Equivalent to ``format.apply(inline={"underline": True})``.""" - return _format_inline(doc, {"underline": True}, **kwargs) + """Apply underline ON. Equivalent to ``format.apply(inline={"underline": "on"})``.""" + return _format_inline(doc, {"underline": "on"}, **kwargs) def format_strikethrough(doc: DocApi, **kwargs: Any) -> Any: - """Apply strikethrough formatting. Equivalent to ``format.apply(inline={"strike": True})``.""" - return _format_inline(doc, {"strike": True}, **kwargs) + """Apply strikethrough ON. Equivalent to ``format.apply(inline={"strike": "on"})``.""" + return _format_inline(doc, {"strike": "on"}, **kwargs) + + +# --------------------------------------------------------------------------- +# unformat_* helpers — apply explicit OFF directive (style override) +# --------------------------------------------------------------------------- + + +def unformat_bold(doc: DocApi, **kwargs: Any) -> Any: + """Apply bold OFF. Equivalent to ``format.apply(inline={"bold": "off"})``.""" + return _format_inline(doc, {"bold": "off"}, **kwargs) + + +def unformat_italic(doc: DocApi, **kwargs: Any) -> Any: + """Apply italic OFF. Equivalent to ``format.apply(inline={"italic": "off"})``.""" + return _format_inline(doc, {"italic": "off"}, **kwargs) + + +def unformat_underline(doc: DocApi, **kwargs: Any) -> Any: + """Apply underline OFF. Equivalent to ``format.apply(inline={"underline": "off"})``.""" + return _format_inline(doc, {"underline": "off"}, **kwargs) + + +def unformat_strikethrough(doc: DocApi, **kwargs: Any) -> Any: + """Apply strikethrough OFF. Equivalent to ``format.apply(inline={"strike": "off"})``.""" + return _format_inline(doc, {"strike": "off"}, **kwargs) + + +# --------------------------------------------------------------------------- +# clear_* helpers — remove direct formatting (inherit from style cascade) +# --------------------------------------------------------------------------- + + +def clear_bold(doc: DocApi, **kwargs: Any) -> Any: + """Clear bold formatting. Equivalent to ``format.apply(inline={"bold": "clear"})``.""" + return _format_inline(doc, {"bold": "clear"}, **kwargs) + + +def clear_italic(doc: DocApi, **kwargs: Any) -> Any: + """Clear italic formatting. Equivalent to ``format.apply(inline={"italic": "clear"})``.""" + return _format_inline(doc, {"italic": "clear"}, **kwargs) + + +def clear_underline(doc: DocApi, **kwargs: Any) -> Any: + """Clear underline formatting. Equivalent to ``format.apply(inline={"underline": "clear"})``.""" + return _format_inline(doc, {"underline": "clear"}, **kwargs) + + +def clear_strikethrough(doc: DocApi, **kwargs: Any) -> Any: + """Clear strikethrough formatting. Equivalent to ``format.apply(inline={"strike": "clear"})``.""" + return _format_inline(doc, {"strike": "clear"}, **kwargs) diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index ac95aef706..76e948ad48 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -1079,6 +1079,7 @@ class SuperConverter { this.translatedNumbering = result.translatedNumbering; this.inlineDocumentFonts = result.inlineDocumentFonts; this.themeColors = result.themeColors ?? null; + this.importDiagnostics = result.importDiagnostics ?? []; return result.pmDoc; } else { diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 7b0d3948bc..1bfb70ae87 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -38,6 +38,7 @@ import { translator as wStylesTranslator } from '@converter/v3/handlers/w/styles import { translator as wNumberingTranslator } from '@converter/v3/handlers/w/numbering/index.js'; import { baseNumbering } from '@converter/v2/exporter/helpers/base-list.definitions.js'; import { patchNumberingDefinitions } from './patchNumberingDefinitions.js'; +import { startCollection, drainDiagnostics } from '@converter/v3/handlers/import-diagnostics.js'; /** * @typedef {import()} XmlNode @@ -151,6 +152,7 @@ export const createDocumentJson = (docx, converter, editor) => { const translatedLinkedStyles = translateStyleDefinitions(docx); const translatedNumbering = translateNumberingDefinitions(docx); + const importDiagnosticsCollectionId = startCollection(); let parsedContent = nodeListHandler.handler({ nodes: content, nodeListHandler, @@ -163,7 +165,9 @@ export const createDocumentJson = (docx, converter, editor) => { inlineDocumentFonts, lists, path: [], + extraParams: { importDiagnosticsCollectionId }, }); + const importDiagnostics = drainDiagnostics(importDiagnosticsCollectionId); // Safety: drop any inline-only nodes that accidentally landed at the doc root parsedContent = filterOutRootInlineNodes(parsedContent); @@ -200,6 +204,7 @@ export const createDocumentJson = (docx, converter, editor) => { numbering: getNumberingDefinitions(docx, converter), translatedNumbering, themeColors: getThemeColorPalette(docx), + importDiagnostics, }; } return null; diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/import-diagnostics.js b/packages/super-editor/src/core/super-converter/v3/handlers/import-diagnostics.js new file mode 100644 index 0000000000..14fdd5b71b --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/import-diagnostics.js @@ -0,0 +1,85 @@ +// @ts-check +/** + * Import diagnostics collector for inline token validation. + * + * Handlers push structured INVALID_INLINE_TOKEN records here during import. + * The converter pipeline calls `startCollection()` before import and + * `drainDiagnostics()` after to retrieve and clear collected records. + * + * @typedef {import('@superdoc/document-api').InlineTokenDiagnostic} InlineTokenDiagnostic + */ + +/** @type {Map} */ +const _buffers = new Map(); + +/** @type {number|null} */ +let _defaultCollectionId = null; + +let _nextCollectionId = 1; + +/** + * Start collecting diagnostics for a new import. + * Returns a collection id that can be passed to `pushDiagnostic`/`drainDiagnostics` + * to isolate concurrent imports. + * + * @param {number} [collectionId] + * @returns {number} + */ +export function startCollection(collectionId) { + const id = typeof collectionId === 'number' ? collectionId : _nextCollectionId++; + _buffers.set(id, []); + _defaultCollectionId = id; + return id; +} + +/** + * Resolve a collection id, falling back to the most recent started collection. + * @param {number} [collectionId] + * @returns {number|null} + */ +function resolveCollectionId(collectionId) { + if (typeof collectionId === 'number') return collectionId; + return _defaultCollectionId; +} + +/** + * Push a diagnostic record into the collection buffer. + * @param {InlineTokenDiagnostic} diagnostic + * @param {number} [collectionId] + */ +export function pushDiagnostic(diagnostic, collectionId) { + const id = resolveCollectionId(collectionId); + if (id == null) return; + + const buffer = _buffers.get(id); + if (!buffer) return; + buffer.push(diagnostic); +} + +/** + * Drain and return all collected diagnostics, resetting the buffer. + * @param {number} [collectionId] + * @returns {InlineTokenDiagnostic[]} + */ +export function drainDiagnostics(collectionId) { + const id = resolveCollectionId(collectionId); + if (id == null) return []; + + const result = _buffers.get(id) ?? []; + _buffers.delete(id); + if (_defaultCollectionId === id) { + _defaultCollectionId = null; + } + return result; +} + +/** + * Read (but don't drain) the current diagnostics. For testing. + * @param {number} [collectionId] + * @returns {ReadonlyArray} + */ +export function peekDiagnostics(collectionId) { + const id = resolveCollectionId(collectionId); + if (id == null) return []; + return _buffers.get(id) ?? []; +} diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/import-diagnostics.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/import-diagnostics.test.js new file mode 100644 index 0000000000..1b8e0e0df3 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/import-diagnostics.test.js @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { startCollection, pushDiagnostic, drainDiagnostics, peekDiagnostics } from './import-diagnostics.js'; + +describe('import-diagnostics collector', () => { + it('starts with an empty buffer', () => { + startCollection(); + expect(peekDiagnostics()).toEqual([]); + }); + + it('collects pushed diagnostics', () => { + startCollection(); + const diag = { + code: 'INVALID_INLINE_TOKEN', + property: 'bold', + attribute: 'val', + token: 'garbage', + xpath: 'w:b/@w:val', + }; + pushDiagnostic(diag); + expect(peekDiagnostics()).toEqual([diag]); + }); + + it('drainDiagnostics returns collected records and resets the buffer', () => { + startCollection(); + pushDiagnostic({ + code: 'INVALID_INLINE_TOKEN', + property: 'italic', + attribute: 'val', + token: 'bad', + xpath: 'w:i/@w:val', + }); + pushDiagnostic({ + code: 'INVALID_INLINE_TOKEN', + property: 'underline', + attribute: 'val', + token: 'nope', + xpath: 'w:u/@w:val', + }); + + const drained = drainDiagnostics(); + expect(drained).toHaveLength(2); + expect(drained[0].property).toBe('italic'); + expect(drained[1].property).toBe('underline'); + + // Buffer is now empty + expect(peekDiagnostics()).toEqual([]); + expect(drainDiagnostics()).toEqual([]); + }); + + it('startCollection resets any previously collected diagnostics', () => { + startCollection(); + pushDiagnostic({ + code: 'INVALID_INLINE_TOKEN', + property: 'strike', + attribute: 'val', + token: 'x', + xpath: 'w:strike/@w:val', + }); + expect(peekDiagnostics()).toHaveLength(1); + + startCollection(); + expect(peekDiagnostics()).toEqual([]); + }); + + it('supports multiple start/drain cycles', () => { + // First cycle + startCollection(); + pushDiagnostic({ + code: 'INVALID_INLINE_TOKEN', + property: 'bold', + attribute: 'val', + token: 'a', + xpath: 'w:b/@w:val', + }); + expect(drainDiagnostics()).toHaveLength(1); + + // Second cycle + startCollection(); + pushDiagnostic({ + code: 'INVALID_INLINE_TOKEN', + property: 'italic', + attribute: 'val', + token: 'b', + xpath: 'w:i/@w:val', + }); + pushDiagnostic({ + code: 'INVALID_INLINE_TOKEN', + property: 'strike', + attribute: 'val', + token: 'c', + xpath: 'w:strike/@w:val', + }); + const second = drainDiagnostics(); + expect(second).toHaveLength(2); + expect(second[0].token).toBe('b'); + expect(second[1].token).toBe('c'); + }); + + it('isolates diagnostics by collection id for overlapping imports', () => { + const idA = startCollection(); + const idB = startCollection(); + + pushDiagnostic( + { + code: 'INVALID_INLINE_TOKEN', + property: 'bold', + attribute: 'val', + token: 'a', + xpath: 'w:b/@w:val', + }, + idA, + ); + + pushDiagnostic( + { + code: 'INVALID_INLINE_TOKEN', + property: 'italic', + attribute: 'val', + token: 'b', + xpath: 'w:i/@w:val', + }, + idB, + ); + + expect(peekDiagnostics(idA).map((d) => d.token)).toEqual(['a']); + expect(peekDiagnostics(idB).map((d) => d.token)).toEqual(['b']); + + expect(drainDiagnostics(idA).map((d) => d.token)).toEqual(['a']); + expect(peekDiagnostics(idB).map((d) => d.token)).toEqual(['b']); + expect(drainDiagnostics(idB).map((d) => d.token)).toEqual(['b']); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/utils.js b/packages/super-editor/src/core/super-converter/v3/handlers/utils.js index 9809ee2c27..594e185705 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/utils.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/utils.js @@ -1,4 +1,6 @@ import { NodeTranslator } from '../node-translator/index.js'; +import { ST_ON_OFF_ON_VALUES, ST_ON_OFF_OFF_VALUES } from '@superdoc/document-api'; +import { pushDiagnostic } from './import-diagnostics.js'; /** * Generates a handler entity for a given node translator. @@ -76,6 +78,93 @@ export function createSingleBooleanPropertyHandler(xmlName, sdName = null) { }; } +// --------------------------------------------------------------------------- +// Strict ST_OnOff token sets — delegated to the shared semantic layer +// (@superdoc/document-api inline-semantics/token-sets). +// --------------------------------------------------------------------------- + +/** + * Strict ST_OnOff parser for core-4 toggle properties. + * + * Returns: + * - `true` — ON (bare element or valid ON token). + * - `false` — OFF (valid OFF token). + * - `undefined` — CLEAR (absent element or invalid token → treated as absent). + * + * Invalid tokens push a structured `INVALID_INLINE_TOKEN` diagnostic to the + * centralized import-diagnostics collector when `property` is provided. + * + * @param {string|null|undefined} val The `w:val` attribute value, or null/undefined for bare element. + * @param {string} [property] The property name for diagnostics (e.g., 'bold'). Omit to skip diagnostic collection. + * @param {string} [xmlName] The XML element name for diagnostics (e.g., 'w:b'). Defaults to `w:`. + * @param {number} [importDiagnosticsCollectionId] Collection id returned by `startCollection`. + * @returns {boolean|undefined} + */ +export function parseStrictStOnOff(val, property, xmlName, importDiagnosticsCollectionId) { + // Bare element (absent w:val) normalizes to ON + if (val == null) return true; + + const str = String(val); + if (ST_ON_OFF_ON_VALUES.has(str)) return true; + if (ST_ON_OFF_OFF_VALUES.has(str)) return false; + + // Invalid token — push structured diagnostic, then treat as absent/clear. + if (property) { + const element = xmlName ?? `w:${property}`; + pushDiagnostic( + { + code: 'INVALID_INLINE_TOKEN', + property, + attribute: 'val', + token: str, + xpath: `${element}/@w:val`, + }, + importDiagnosticsCollectionId, + ); + } + return undefined; +} + +/** + * Creates a strict tri-state property handler for ST_OnOff elements (bold, italic, strike). + * + * Preserves the on/off/clear distinction: + * - ON tokens (true, 1, on, absent w:val) → mark present with default attrs. + * - OFF tokens (false, 0, off) → mark present with `value: '0'`. + * - Invalid tokens → mark absent (CLEAR) + structured diagnostic pushed. + * - Element absent → mark absent (CLEAR). + * + * Export produces canonical OOXML forms: + * - ON → `` (bare element, no w:val). + * - OFF → `` (canonical OFF token). + * - CLEAR → no element emitted. + * + * @param {string} xmlName The XML element name (e.g., 'w:b'). + * @param {string|null} sdName The PM attribute name (e.g., 'bold'). Derived from xmlName if null. + * @returns {import('@translator').NodeTranslatorConfig} + */ +export function createStrictTogglePropertyHandler(xmlName, sdName = null) { + if (!sdName) sdName = xmlName.split(':')[1]; + return { + xmlName, + sdNodeOrKeyName: sdName, + encode: ({ nodes, extraParams }) => { + const val = nodes[0]?.attributes?.['w:val']; + const importDiagnosticsCollectionId = extraParams?.importDiagnosticsCollectionId; + return parseStrictStOnOff(val, sdName, xmlName, importDiagnosticsCollectionId); + }, + decode: ({ node }) => { + const val = node.attrs[sdName]; + // CLEAR — no element + if (val == null) return undefined; + // OFF — canonical `w:val="0"` + if (val === false || val === '0') return { attributes: { 'w:val': '0' } }; + // ON — bare element (no w:val attribute) + return { attributes: {} }; + }, + }; +} + /** * Helper to create property handlers for integer attributes (CT_DecimalNumber => w:val) * @param {string} xmlName The XML attribute name (with namespace). diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/b/b-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/b/b-translator.js index 1857133568..005cfcfe03 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/b/b-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/b/b-translator.js @@ -1,9 +1,15 @@ import { NodeTranslator } from '@translator'; -import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; +import { createStrictTogglePropertyHandler } from '@converter/v3/handlers/utils'; /** * The NodeTranslator instance for the w:b element. + * + * Uses strict ST_OnOff parsing to preserve the on/off/clear tri-state: + * - ON: `` or `` → mark present, default attrs + * - OFF: `` → mark present, `{ value: '0' }` + * - CLEAR: element absent → no mark + * * @type {import('@translator').NodeTranslator} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 264 */ -export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:b', 'bold')); +export const translator = NodeTranslator.from(createStrictTogglePropertyHandler('w:b', 'bold')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/i/i-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/i/i-translator.js index 8b0b81e2b4..ab9bb185fb 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/i/i-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/i/i-translator.js @@ -1,8 +1,11 @@ import { NodeTranslator } from '@translator'; -import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils.js'; +import { createStrictTogglePropertyHandler } from '@converter/v3/handlers/utils.js'; /** * The NodeTranslator instance for the w:i element. + * + * Uses strict ST_OnOff parsing to preserve the on/off/clear tri-state. + * * @type {import('@translator').NodeTranslator} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 282 */ -export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:i', 'italic')); +export const translator = NodeTranslator.from(createStrictTogglePropertyHandler('w:i', 'italic')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/strict-import-normalization.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/strict-import-normalization.test.js new file mode 100644 index 0000000000..45ef0e9a7d --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/strict-import-normalization.test.js @@ -0,0 +1,457 @@ +/** + * Phase 6 hardening tests: strict import normalization for core-4 inline properties. + * + * Covers: + * - ST_OnOff: all 6 accepted values, bare element, invalid tokens + * - ST_Underline: all accepted enum values, bare element, invalid tokens + * - Case-sensitivity enforcement + * - Tri-state roundtrip (encode → decode) + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createStrictTogglePropertyHandler, parseStrictStOnOff } from '../../handlers/utils.js'; +import { startCollection, drainDiagnostics } from '../../handlers/import-diagnostics.js'; +import { translator as boldTranslator } from './b/b-translator.js'; +import { translator as italicTranslator } from './i/i-translator.js'; +import { translator as strikeTranslator } from './strike/strike-translator.js'; +import { config as underlineConfig, translator as underlineTranslator } from './u/u-translator.js'; + +// --------------------------------------------------------------------------- +// §1: parseStrictStOnOff — strict ST_OnOff token validation +// --------------------------------------------------------------------------- +describe('parseStrictStOnOff', () => { + describe('valid ON tokens', () => { + it.each([ + ['true', true], + ['1', true], + ['on', true], + ])('accepts "%s" as ON → true', (token, expected) => { + expect(parseStrictStOnOff(token)).toBe(expected); + }); + }); + + describe('valid OFF tokens', () => { + it.each([ + ['false', false], + ['0', false], + ['off', false], + ])('accepts "%s" as OFF → false', (token, expected) => { + expect(parseStrictStOnOff(token)).toBe(expected); + }); + }); + + describe('bare element (absent w:val)', () => { + it('null → ON (true)', () => { + expect(parseStrictStOnOff(null)).toBe(true); + }); + it('undefined → ON (true)', () => { + expect(parseStrictStOnOff(undefined)).toBe(true); + }); + }); + + describe('invalid tokens → CLEAR (undefined)', () => { + it.each([ + ['True', 'wrong case'], + ['FALSE', 'all caps'], + ['ON', 'wrong case'], + ['Off', 'mixed case'], + ['yes', 'not an ST_OnOff token'], + ['no', 'not an ST_OnOff token'], + ['', 'empty string'], + ['2', 'numeric but not 0 or 1'], + ['null', 'string null'], + ['undefined', 'string undefined'], + ['bold', 'property name as value'], + ])('rejects "%s" (%s) → undefined', (token) => { + expect(parseStrictStOnOff(token)).toBeUndefined(); + }); + }); + + describe('type coercion via String()', () => { + it('boolean true → "true" → ON', () => { + expect(parseStrictStOnOff(true)).toBe(true); + }); + it('boolean false → "false" → OFF', () => { + expect(parseStrictStOnOff(false)).toBe(false); + }); + it('number 1 → "1" → ON', () => { + expect(parseStrictStOnOff(1)).toBe(true); + }); + it('number 0 → "0" → OFF', () => { + expect(parseStrictStOnOff(0)).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// §2: Core-4 toggle translators (bold, italic, strike) — encode +// --------------------------------------------------------------------------- +describe('strict toggle encode (bold, italic, strike)', () => { + const translators = [ + { name: 'bold', translator: boldTranslator }, + { name: 'italic', translator: italicTranslator }, + { name: 'strike', translator: strikeTranslator }, + ]; + + translators.forEach(({ name, translator }) => { + describe(`w:${name === 'bold' ? 'b' : name === 'italic' ? 'i' : 'strike'}`, () => { + // Valid ON tokens + it.each(['true', '1', 'on'])('encode w:val="%s" → true (ON)', (token) => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': token } }] }); + expect(result).toBe(true); + }); + + // Valid OFF tokens + it.each(['false', '0', 'off'])('encode w:val="%s" → false (OFF)', (token) => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': token } }] }); + expect(result).toBe(false); + }); + + // Bare element + it('encode bare element (no w:val) → true (ON)', () => { + const result = translator.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBe(true); + }); + + // Invalid tokens → CLEAR + it.each(['True', 'FALSE', 'ON', 'yes', '', 'potato'])( + 'encode invalid w:val="%s" → undefined (CLEAR)', + (token) => { + const result = translator.encode({ nodes: [{ attributes: { 'w:val': token } }] }); + expect(result).toBeUndefined(); + }, + ); + }); + }); +}); + +// --------------------------------------------------------------------------- +// §3: Core-4 toggle translators — decode (PM → OOXML export) +// --------------------------------------------------------------------------- +describe('strict toggle decode (bold, italic, strike)', () => { + const translators = [ + { name: 'bold', sdName: 'bold', translator: boldTranslator }, + { name: 'italic', sdName: 'italic', translator: italicTranslator }, + { name: 'strike', sdName: 'strike', translator: strikeTranslator }, + ]; + + translators.forEach(({ name, sdName, translator }) => { + describe(`w:${name === 'bold' ? 'b' : name === 'italic' ? 'i' : 'strike'}`, () => { + it('ON (true) → bare element (no w:val)', () => { + const result = translator.decode({ node: { attrs: { [sdName]: true } } }); + expect(result).toEqual({ attributes: {} }); + }); + + it('OFF (false) → w:val="0"', () => { + const result = translator.decode({ node: { attrs: { [sdName]: false } } }); + expect(result).toEqual({ attributes: { 'w:val': '0' } }); + }); + + it('CLEAR (undefined) → no element', () => { + const result = translator.decode({ node: { attrs: {} } }); + expect(result).toBeUndefined(); + }); + + it('CLEAR (null) → no element', () => { + const result = translator.decode({ node: { attrs: { [sdName]: null } } }); + expect(result).toBeUndefined(); + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// §4: Toggle encode→decode roundtrip +// --------------------------------------------------------------------------- +describe('toggle encode→decode roundtrip', () => { + const handler = createStrictTogglePropertyHandler('w:b', 'bold'); + + it('ON roundtrip: encode ON → decode ON → bare element', () => { + const encoded = handler.encode({ nodes: [{ attributes: {} }] }); + expect(encoded).toBe(true); + const decoded = handler.decode({ node: { attrs: { bold: encoded } } }); + expect(decoded).toEqual({ attributes: {} }); + }); + + it('OFF roundtrip: encode OFF → decode OFF → w:val="0"', () => { + const encoded = handler.encode({ nodes: [{ attributes: { 'w:val': '0' } }] }); + expect(encoded).toBe(false); + const decoded = handler.decode({ node: { attrs: { bold: encoded } } }); + expect(decoded).toEqual({ attributes: { 'w:val': '0' } }); + }); + + it('CLEAR roundtrip: invalid token → no element', () => { + const encoded = handler.encode({ nodes: [{ attributes: { 'w:val': 'INVALID' } }] }); + expect(encoded).toBeUndefined(); + // CLEAR means no attribute in PM → no element in export + const decoded = handler.decode({ node: { attrs: {} } }); + expect(decoded).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// §5: Underline encode — ST_Underline strict validation +// --------------------------------------------------------------------------- +describe('underline encode — ST_Underline validation', () => { + const validOnTypes = [ + 'single', + 'double', + 'thick', + 'dotted', + 'dottedHeavy', + 'dash', + 'dashedHeavy', + 'dashLong', + 'dashLongHeavy', + 'dotDash', + 'dashDotHeavy', + 'dotDotDash', + 'dashDotDotHeavy', + 'wave', + 'wavyHeavy', + 'wavyDouble', + 'words', + ]; + + describe('valid ON types', () => { + it.each(validOnTypes)('encode w:val="%s" → accepted', (type) => { + const result = underlineConfig.encode({ nodes: [{ attributes: { 'w:val': type } }] }); + expect(result).toBeDefined(); + expect(result.attributes['w:val']).toBe(type); + }); + }); + + describe('OFF type (none)', () => { + it('encode w:val="none" → accepted with underlineType "none"', () => { + const result = underlineConfig.encode({ nodes: [{ attributes: { 'w:val': 'none' } }] }); + expect(result).toBeDefined(); + expect(result.attributes['w:val']).toBe('none'); + }); + + it('OFF state strips rich attrs even if present on source', () => { + const result = underlineConfig.encode({ + nodes: [{ attributes: { 'w:val': 'none', 'w:color': 'FF0000', 'w:themeColor': 'accent1' } }], + }); + expect(result).toBeDefined(); + expect(result.attributes).toEqual({ 'w:val': 'none' }); + }); + }); + + describe('bare element', () => { + it('encode bare element → ON with null underlineType', () => { + const result = underlineConfig.encode({ nodes: [{ attributes: {} }] }); + expect(result).toBeDefined(); + expect(result.attributes['w:val']).toBeNull(); + }); + }); + + describe('invalid tokens → CLEAR', () => { + it.each([ + 'Single', // wrong case + 'DOUBLE', // all caps + 'wavy', // not a valid ST_Underline token (correct is 'wave') + 'underline', // not an ST_Underline token + '', // empty string + 'bold', // wrong property entirely + 'true', // ST_OnOff but not ST_Underline + ])('encode invalid w:val="%s" → undefined (CLEAR)', (token) => { + const result = underlineConfig.encode({ nodes: [{ attributes: { 'w:val': token } }] }); + expect(result).toBeUndefined(); + }); + }); + + describe('rich attrs included for ON types', () => { + it('includes color and theme attrs for ON types', () => { + const result = underlineConfig.encode({ + nodes: [ + { + attributes: { + 'w:val': 'single', + 'w:color': 'FF0000', + 'w:themeColor': 'accent1', + 'w:themeTint': '80', + 'w:themeShade': '0F', + }, + }, + ], + }); + expect(result.attributes).toEqual({ + 'w:val': 'single', + 'w:color': 'FF0000', + 'w:themeColor': 'accent1', + 'w:themeTint': '80', + 'w:themeShade': '0F', + }); + }); + }); +}); + +// --------------------------------------------------------------------------- +// §6: Underline decode — canonical OOXML export +// --------------------------------------------------------------------------- +describe('underline decode — canonical export', () => { + it('ON with type → w:val present', () => { + const result = underlineTranslator.decode({ + node: { attrs: { underlineType: 'single' } }, + }); + expect(result).toBeDefined(); + expect(result.attributes['w:val']).toBe('single'); + }); + + it('OFF (none) → w:val="none", no rich attrs', () => { + const result = underlineTranslator.decode({ + node: { attrs: { underlineType: 'none', underlineColor: '#FF0000' } }, + }); + expect(result).toBeDefined(); + expect(result.attributes).toEqual({ 'w:val': 'none' }); + }); + + it('CLEAR → no element', () => { + const result = underlineTranslator.decode({ + node: { attrs: {} }, + }); + expect(result).toBeUndefined(); + }); + + it('canonical attribute ordering: w:val before w:color before w:themeColor', () => { + const result = underlineTranslator.decode({ + node: { + attrs: { + underlineType: 'wave', + underlineColor: '#FF0000', + underlineThemeColor: 'accent1', + underlineThemeTint: '80', + underlineThemeShade: '0F', + }, + }, + }); + const keys = Object.keys(result.attributes); + expect(keys).toEqual(['w:val', 'w:color', 'w:themeColor', 'w:themeTint', 'w:themeShade']); + }); +}); + +// --------------------------------------------------------------------------- +// §7: Non-fatal continuation — invalid tokens don't crash import +// --------------------------------------------------------------------------- +describe('non-fatal continuation', () => { + it('invalid bold token returns undefined (does not throw)', () => { + expect(() => { + boldTranslator.encode({ nodes: [{ attributes: { 'w:val': 'GARBAGE' } }] }); + }).not.toThrow(); + }); + + it('invalid italic token returns undefined (does not throw)', () => { + expect(() => { + italicTranslator.encode({ nodes: [{ attributes: { 'w:val': 'TrUe' } }] }); + }).not.toThrow(); + }); + + it('invalid underline token returns undefined (does not throw)', () => { + expect(() => { + underlineConfig.encode({ nodes: [{ attributes: { 'w:val': 'not_an_underline' } }] }); + }).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// §8: Structured diagnostic collection — INVALID_INLINE_TOKEN records +// --------------------------------------------------------------------------- +describe('structured diagnostic collection', () => { + beforeEach(() => { + startCollection(); + }); + + describe('toggle properties (bold, italic, strike)', () => { + it('collects INVALID_INLINE_TOKEN for invalid bold w:val', () => { + boldTranslator.encode({ nodes: [{ attributes: { 'w:val': 'GARBAGE' } }] }); + const diagnostics = drainDiagnostics(); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: 'INVALID_INLINE_TOKEN', + property: 'bold', + attribute: 'val', + token: 'GARBAGE', + xpath: 'w:b/@w:val', + }); + }); + + it('collects INVALID_INLINE_TOKEN for invalid italic w:val', () => { + italicTranslator.encode({ nodes: [{ attributes: { 'w:val': 'TrUe' } }] }); + const diagnostics = drainDiagnostics(); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: 'INVALID_INLINE_TOKEN', + property: 'italic', + attribute: 'val', + token: 'TrUe', + xpath: 'w:i/@w:val', + }); + }); + + it('collects INVALID_INLINE_TOKEN for invalid strike w:val', () => { + strikeTranslator.encode({ nodes: [{ attributes: { 'w:val': 'YES' } }] }); + const diagnostics = drainDiagnostics(); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: 'INVALID_INLINE_TOKEN', + property: 'strike', + attribute: 'val', + token: 'YES', + xpath: 'w:strike/@w:val', + }); + }); + + it('does NOT collect diagnostic for valid tokens', () => { + boldTranslator.encode({ nodes: [{ attributes: { 'w:val': 'true' } }] }); + boldTranslator.encode({ nodes: [{ attributes: { 'w:val': '0' } }] }); + boldTranslator.encode({ nodes: [{ attributes: {} }] }); + const diagnostics = drainDiagnostics(); + expect(diagnostics).toHaveLength(0); + }); + + it('collects multiple diagnostics across properties', () => { + boldTranslator.encode({ nodes: [{ attributes: { 'w:val': 'BAD1' } }] }); + italicTranslator.encode({ nodes: [{ attributes: { 'w:val': 'BAD2' } }] }); + strikeTranslator.encode({ nodes: [{ attributes: { 'w:val': 'BAD3' } }] }); + const diagnostics = drainDiagnostics(); + expect(diagnostics).toHaveLength(3); + expect(diagnostics.map((d) => d.property)).toEqual(['bold', 'italic', 'strike']); + expect(diagnostics.map((d) => d.token)).toEqual(['BAD1', 'BAD2', 'BAD3']); + }); + }); + + describe('underline', () => { + it('collects INVALID_INLINE_TOKEN for invalid underline w:val', () => { + underlineConfig.encode({ nodes: [{ attributes: { 'w:val': 'wavy' } }] }); + const diagnostics = drainDiagnostics(); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + code: 'INVALID_INLINE_TOKEN', + property: 'underline', + attribute: 'val', + token: 'wavy', + }); + }); + + it('does NOT collect diagnostic for valid underline tokens', () => { + underlineConfig.encode({ nodes: [{ attributes: { 'w:val': 'single' } }] }); + underlineConfig.encode({ nodes: [{ attributes: { 'w:val': 'none' } }] }); + underlineConfig.encode({ nodes: [{ attributes: {} }] }); + const diagnostics = drainDiagnostics(); + expect(diagnostics).toHaveLength(0); + }); + }); + + describe('drain resets buffer', () => { + it('drainDiagnostics clears the collection', () => { + boldTranslator.encode({ nodes: [{ attributes: { 'w:val': 'INVALID' } }] }); + expect(drainDiagnostics()).toHaveLength(1); + expect(drainDiagnostics()).toHaveLength(0); + }); + + it('startCollection resets previous diagnostics', () => { + boldTranslator.encode({ nodes: [{ attributes: { 'w:val': 'INVALID' } }] }); + startCollection(); + expect(drainDiagnostics()).toHaveLength(0); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/strike/strike-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/strike/strike-translator.js index f7eefe8eee..c368c9b66d 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/strike/strike-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/strike/strike-translator.js @@ -1,9 +1,12 @@ import { NodeTranslator } from '@translator'; -import { createSingleBooleanPropertyHandler } from '@converter/v3/handlers/utils'; +import { createStrictTogglePropertyHandler } from '@converter/v3/handlers/utils'; /** * The NodeTranslator instance for the w:strike element. + * + * Uses strict ST_OnOff parsing to preserve the on/off/clear tri-state. + * * @type {import('@translator').NodeTranslator} * @see {@link https://ecma-international.org/publications-and-standards/standards/ecma-376/} "Fundamentals And Markup Language Reference", page 313 */ -export const translator = NodeTranslator.from(createSingleBooleanPropertyHandler('w:strike')); +export const translator = NodeTranslator.from(createStrictTogglePropertyHandler('w:strike')); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.js index 28fd0412ab..6ef5250622 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.js @@ -1,6 +1,8 @@ // @ts-check import { NodeTranslator } from '@translator'; +import { ST_UNDERLINE_VALUE_SET } from '@superdoc/document-api'; import { normalizeHexColor } from '../../../../helpers.js'; +import { pushDiagnostic } from '../../../handlers/import-diagnostics.js'; import validXmlAttributes from './attributes/index.js'; /** @type {import('@translator').XmlNodeName} */ @@ -10,26 +12,61 @@ const XML_NODE_NAME = 'w:u'; const SD_ATTR_KEY = 'underline'; /** - * Encode the w:u element (underline) and preserve useful attributes. + * Encode the w:u element (underline) with strict ST_Underline token validation. + * + * - Valid ON tokens → mark with underlineType and preserved rich attrs. + * - `none` → mark with underlineType: 'none' (OFF). + * - Bare element (no w:val) → ON with default 'single'. + * - Invalid w:val → treated as absent (CLEAR) + diagnostic pushed if collector provided. + * * @param {import('@translator').SCEncoderConfig} params + * @param {Record} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { const { nodes } = params; const node = nodes?.[0]; const sourceAttrs = node?.attributes || {}; + const importDiagnosticsCollectionId = params?.extraParams?.importDiagnosticsCollectionId; + + const rawVal = encodedAttrs.underline ?? sourceAttrs['w:val']; + + // Validate w:val against ST_Underline (shared token set) + let underlineType; + if (rawVal === undefined || rawVal === null) { + // Bare element (absent w:val) → ON with default style + underlineType = null; + } else if (typeof rawVal === 'string' && ST_UNDERLINE_VALUE_SET.has(rawVal)) { + underlineType = rawVal; + } else { + // Invalid token → push structured diagnostic, then treat as absent (CLEAR) + pushDiagnostic( + { + code: 'INVALID_INLINE_TOKEN', + property: 'underline', + attribute: 'val', + token: String(rawVal), + xpath: 'w:u/@w:val', + }, + importDiagnosticsCollectionId, + ); + return undefined; + } - const underlineType = encodedAttrs.underline ?? sourceAttrs['w:val']; const color = encodedAttrs.color ?? sourceAttrs['w:color']; const themeColor = encodedAttrs.themeColor ?? sourceAttrs['w:themeColor']; const themeTint = encodedAttrs.themeTint ?? sourceAttrs['w:themeTint']; const themeShade = encodedAttrs.themeShade ?? sourceAttrs['w:themeShade']; - const attributes = { 'w:val': underlineType ?? null }; - if (color !== undefined && color !== null) attributes['w:color'] = color; - if (themeColor !== undefined && themeColor !== null) attributes['w:themeColor'] = themeColor; - if (themeTint !== undefined && themeTint !== null) attributes['w:themeTint'] = themeTint; - if (themeShade !== undefined && themeShade !== null) attributes['w:themeShade'] = themeShade; + const attributes = { 'w:val': underlineType }; + + // Only include rich attrs for ON states (not OFF/none) + if (underlineType !== 'none') { + if (color !== undefined && color !== null) attributes['w:color'] = color; + if (themeColor !== undefined && themeColor !== null) attributes['w:themeColor'] = themeColor; + if (themeTint !== undefined && themeTint !== null) attributes['w:themeTint'] = themeTint; + if (themeShade !== undefined && themeShade !== null) attributes['w:themeShade'] = themeShade; + } return { type: 'attr', @@ -39,6 +76,15 @@ const encode = (params, encodedAttrs = {}) => { }; }; +/** + * Decode underline PM attrs to canonical OOXML export form. + * + * - ON → `` (or rich type + color attrs). + * - OFF → `` (no color/theme attrs). + * - CLEAR → no element. + * + * Canonical attribute ordering: w:val, w:color, w:themeColor, w:themeTint, w:themeShade. + */ const decode = (params) => { const attrs = params?.node?.attrs?.underline || params?.node?.attrs || {}; const underlineType = attrs.underlineType ?? attrs.underline ?? attrs['w:val'] ?? null; @@ -47,10 +93,19 @@ const decode = (params) => { const themeTint = attrs.underlineThemeTint ?? attrs.themeTint ?? attrs['w:themeTint'] ?? null; const themeShade = attrs.underlineThemeShade ?? attrs.themeShade ?? attrs['w:themeShade'] ?? null; - if (!underlineType && !color && !themeColor && !themeTint && !themeShade) return undefined; + // CLEAR — no element + if (!underlineType && !color && !themeColor) return undefined; + // Build attributes in canonical spec order: w:val, w:color, w:themeColor, w:themeTint, w:themeShade const attributes = {}; if (underlineType) attributes['w:val'] = underlineType; + + // OFF state — no color/theme attrs emitted + if (underlineType === 'none') { + return { name: XML_NODE_NAME, attributes }; + } + + // ON state — include rich attrs in canonical order if (color) { const normalized = normalizeHexColor(color); if (normalized) attributes['w:color'] = normalized; @@ -59,10 +114,7 @@ const decode = (params) => { if (themeTint) attributes['w:themeTint'] = themeTint; if (themeShade) attributes['w:themeShade'] = themeShade; - return { - name: XML_NODE_NAME, - attributes, - }; + return { name: XML_NODE_NAME, attributes }; }; /** @type {import('@translator').NodeTranslatorConfig} */ diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.test.js index c24de85201..43d35a0ebd 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/u/u-translator.test.js @@ -38,7 +38,7 @@ describe('w:u translator (attribute)', () => { ], }; const out = config.encode(params, { - underline: 'wavy', + underline: 'wave', color: '00FF00', themeTint: '33', }); @@ -47,7 +47,7 @@ describe('w:u translator (attribute)', () => { xmlName: 'w:u', sdNodeOrKeyName: 'underline', attributes: { - 'w:val': 'wavy', + 'w:val': 'wave', 'w:color': '00FF00', 'w:themeColor': 'accent1', 'w:themeTint': '33', diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index 2a2adbaf9d..30e7234780 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -1057,7 +1057,7 @@ const mutationVectors: Partial> = { const { editor } = makeTextEditor(); return styleApplyWrapper( editor, - { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, inline: { bold: true } }, + { target: { kind: 'text', blockId: 'missing', range: { start: 0, end: 1 } }, inline: { bold: 'on' } }, { changeMode: 'direct' }, ); }, @@ -1065,7 +1065,7 @@ const mutationVectors: Partial> = { const { editor } = makeTextEditor(); return styleApplyWrapper( editor, - { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } }, inline: { bold: true } }, + { target: { kind: 'text', blockId: 'p1', range: { start: 2, end: 2 } }, inline: { bold: 'on' } }, { changeMode: 'direct' }, ); }, @@ -1073,7 +1073,7 @@ const mutationVectors: Partial> = { const { editor } = makeTextEditor(); return styleApplyWrapper( editor, - { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, inline: { bold: true } }, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, inline: { bold: 'on', italic: 'off' } }, { changeMode: 'direct' }, ); }, @@ -2199,7 +2199,7 @@ const dryRunVectors: Partial unknown>> = { const { editor, dispatch, tr } = makeTextEditor(); const result = styleApplyWrapper( editor, - { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, inline: { bold: true } }, + { target: { kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }, inline: { bold: 'on' } }, { changeMode: 'direct', dryRun: true }, ); expect(dispatch).not.toHaveBeenCalled(); diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/schema-validator.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/schema-validator.test.ts index e415378dfb..e9dc7c72a4 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/schema-validator.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/schema-validator.test.ts @@ -15,8 +15,8 @@ describe('schema-validator $ref resolution', () => { InlineStylePatch: { type: 'object' as const, properties: { - bold: { type: 'boolean' as const }, - italic: { type: 'boolean' as const }, + bold: { type: 'string' as const, enum: ['on', 'off', 'clear'] }, + italic: { type: 'string' as const, enum: ['on', 'off', 'clear'] }, }, additionalProperties: false, }, @@ -46,7 +46,7 @@ describe('schema-validator $ref resolution', () => { }; const result = validateJsonSchema( schema, - { target: { kind: 'text', blockId: 'p1' }, inline: { bold: true } }, + { target: { kind: 'text', blockId: 'p1' }, inline: { bold: 'on' } }, $defs, ); expect(result.valid).toBe(true); diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 5a42d03f8f..61155e7d8b 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -3,10 +3,13 @@ import { CAPABILITY_REASON_CODES, COMMAND_CATALOG, MARK_KEYS, + INLINE_DIRECTIVES, + CORE_TOGGLE_PROPERTY_ID_SET, type CapabilityReasonCode, type DocumentApiCapabilities, type PlanEngineCapabilities, type FormatCapabilities, + type FormatPropertyCapability, type OperationId, OPERATION_IDS, } from '@superdoc/document-api'; @@ -329,8 +332,15 @@ const SUPPORTED_SET_MARKS = ['bold', 'italic', 'underline', 'strike'] as const; const REGEX_MAX_PATTERN_LENGTH = 1024; function buildFormatCapabilities(editor: Editor): FormatCapabilities { - const supportedMarks = MARK_KEYS.filter((key) => hasMarkCapability(editor, STYLE_MARK_SCHEMA_NAMES[key] ?? key)); - return { supportedMarks }; + const properties: Record = {}; + for (const key of MARK_KEYS) { + if (hasMarkCapability(editor, STYLE_MARK_SCHEMA_NAMES[key] ?? key)) { + // Classify from registry: pure toggles vs composite (underline has rich attrs) + const kind = CORE_TOGGLE_PROPERTY_ID_SET.has(key) ? 'toggle' : 'composite'; + properties[key] = { kind, directives: [...INLINE_DIRECTIVES] }; + } + } + return { properties }; } function buildPlanEngineCapabilities(): PlanEngineCapabilities { diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts index d80ac7b854..bf2d8b4e64 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.test.ts @@ -8,6 +8,8 @@ import { executeCreateStep, executeSpanTextDelete, executeSpanTextRewrite, + executeStyleApply, + executeSpanStyleApply, runMutationsOnTransaction, } from './executor.js'; import { registerBuiltInExecutors } from './register-executors.js'; @@ -261,7 +263,7 @@ describe('executeCompiledPlan: text.rewrite style behavior', () => { args: { replacement: { text: 'World' }, style: { - inline: { mode: 'set', setMarks: { italic: true } }, + inline: { mode: 'set', setMarks: { italic: 'on' } }, paragraph: { mode: 'preserve' }, }, }, @@ -289,7 +291,7 @@ describe('executeCompiledPlan: text.rewrite style behavior', () => { expect(mockedDeps.resolveInlineStyle).toHaveBeenCalledWith( editor, capturedStyle, - { mode: 'set', setMarks: { italic: true } }, + { mode: 'set', setMarks: { italic: 'on' } }, 'step-2', ); }); @@ -1820,3 +1822,86 @@ describe('executeCompiledPlan: atomic rollback on failure', () => { expect(dispatch).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Collapsed-range guard — executeStyleApply (single-block) +// --------------------------------------------------------------------------- + +describe('executeStyleApply: collapsed-range no-op guard', () => { + it('returns { changed: false } without modifying the transaction when absFrom === absTo', () => { + const { editor, tr } = makeEditor(); + const target = makeTarget({ + op: 'style.apply' as any, + absFrom: 5, + absTo: 5, // collapsed + }) as any; + + const step: StyleApplyStep = { + op: 'style.apply', + id: 'step-1', + ref: 'test-ref', + args: { inline: { bold: 'on' } }, + }; + + const mapping = { map: (pos: number) => pos }; + const result = executeStyleApply(editor, tr as any, target, step, mapping as any); + + expect(result).toEqual({ changed: false }); + expect(tr.addMark).not.toHaveBeenCalled(); + expect(tr.removeMark).not.toHaveBeenCalled(); + }); + + it('returns { changed: false } when mapping collapses a non-empty range', () => { + const { editor, tr } = makeEditor(); + const target = makeTarget({ + op: 'style.apply' as any, + absFrom: 1, + absTo: 6, + }) as any; + + const step: StyleApplyStep = { + op: 'style.apply', + id: 'step-1', + ref: 'test-ref', + args: { inline: { bold: 'on' } }, + }; + + // Mapping collapses the range to the same position + const mapping = { map: () => 10 }; + const result = executeStyleApply(editor, tr as any, target, step, mapping as any); + + expect(result).toEqual({ changed: false }); + expect(tr.addMark).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Collapsed-range guard — executeSpanStyleApply (cross-block) +// --------------------------------------------------------------------------- + +describe('executeSpanStyleApply: collapsed-range no-op guard', () => { + it('returns { changed: false } when span range collapses to zero width', () => { + const { editor, tr } = makeEditor(); + const target = { + kind: 'span' as const, + stepId: 'step-1', + op: 'style.apply', + segments: [{ blockId: 'p1', from: 0, to: 5, absFrom: 5, absTo: 5 }], + }; + + const step: StyleApplyStep = { + op: 'style.apply', + id: 'step-1', + ref: 'test-ref', + args: { inline: { italic: 'on' } }, + }; + + // Mapping collapses everything to position 5 + const mapping = { map: () => 5 }; + const result = executeSpanStyleApply(editor, tr as any, target as any, step, mapping as any); + + expect(result).toEqual({ changed: false }); + expect(tr.addMark).not.toHaveBeenCalled(); + expect(tr.removeMark).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts index 755e3c9e5c..63676da945 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts @@ -22,7 +22,11 @@ import type { SetMarks, ReplacementPayload, Query, + MarkKey, + InlineToggleDirective, } from '@superdoc/document-api'; +import { MARK_KEYS } from '@superdoc/document-api'; +import { TOGGLE_MARK_SPECS } from './mark-directives.js'; import type { Editor } from '../../core/Editor.js'; import type { CompiledPlan } from './compiler.js'; import type { @@ -84,10 +88,22 @@ function buildMarksFromSetMarks(editor: Editor, setMarks?: SetMarks): readonly P if (!setMarks) return []; const { schema } = editor.state; const marks: ProseMirrorMark[] = []; - if (setMarks.bold && schema.marks.bold) marks.push(schema.marks.bold.create()); - if (setMarks.italic && schema.marks.italic) marks.push(schema.marks.italic.create()); - if (setMarks.underline && schema.marks.underline) marks.push(schema.marks.underline.create()); - if (setMarks.strike && schema.marks.strike) marks.push(schema.marks.strike.create()); + + for (const key of MARK_KEYS) { + const directive = setMarks[key]; + if (!directive) continue; + const markType = schema.marks[TOGGLE_MARK_SPECS[key].schemaName]; + if (!markType) continue; + + const spec = TOGGLE_MARK_SPECS[key]; + if (directive === 'on') { + marks.push(spec.createOn(markType) as unknown as ProseMirrorMark); + } else if (directive === 'off') { + marks.push(markType.create(spec.offAttrs) as unknown as ProseMirrorMark); + } + // 'clear' → skip (no mark) + } + return marks; } @@ -95,7 +111,7 @@ function buildMarksFromSetMarks(editor: Editor, setMarks?: SetMarks): readonly P // Shared inline style patch — applies boolean mark patches to a range // --------------------------------------------------------------------------- -/** Applies boolean inline mark patches (bold, italic, underline, strike) to a document range. */ +/** Applies inline mark directives (bold, italic, underline, strike) to a document range. */ function applyInlineMarkPatches( editor: Editor, tr: Transaction, @@ -106,21 +122,17 @@ function applyInlineMarkPatches( const { schema } = editor.state; let changed = false; - const markEntries: Array<[boolean | undefined, MarkType | undefined]> = [ - [inline.bold, schema.marks.bold], - [inline.italic, schema.marks.italic], - [inline.underline, schema.marks.underline], - [inline.strike, schema.marks.strike], - ]; - - for (const [value, markType] of markEntries) { - if (value === undefined || !markType) continue; - if (value) { - tr.addMark(absFrom, absTo, markType.create()); - } else { - tr.removeMark(absFrom, absTo, markType); + for (const key of MARK_KEYS) { + const directive = inline[key] as InlineToggleDirective | undefined; + if (!directive) continue; + + const spec = TOGGLE_MARK_SPECS[key]; + const markType = schema.marks[spec.schemaName] as MarkType | undefined; + if (!markType) continue; + + if (applyDirectiveToTransaction(tr, absFrom, absTo, markType, spec, directive)) { + changed = true; } - changed = true; } return changed; @@ -224,6 +236,82 @@ export function executeTextDelete( return { changed: true }; } +/** + * Collects sub-ranges within [from, to) where the mark is NOT already in ON + * form. Nodes already ON are skipped so their rich attrs (e.g. wavy underline) + * are preserved. Returns the sub-ranges in reverse document order for safe + * sequential application without position-shift concerns. + * + * Falls back to the full range if `doc.nodesBetween` is unavailable (mocks). + */ +function collectNonOnSubRanges( + doc: ProseMirrorNode, + from: number, + to: number, + markType: MarkType, + spec: (typeof TOGGLE_MARK_SPECS)[MarkKey], +): Array<{ from: number; to: number }> { + if (from === to) return []; + if (typeof doc.nodesBetween !== 'function') return [{ from, to }]; + + const ranges: Array<{ from: number; to: number }> = []; + doc.nodesBetween(from, to, (node, pos) => { + if (!node.isText) return; + const mark = node.marks.find((m) => m.type === markType); + if (mark && spec.isOn(mark as unknown as Parameters[0])) return; + // Node is absent or OFF — needs conversion to ON + ranges.push({ + from: Math.max(pos, from), + to: Math.min(pos + node.nodeSize, to), + }); + }); + + // Reverse for safe back-to-front application + ranges.reverse(); + return ranges; +} + +/** + * Applies a single inline toggle directive to a transaction range. + * Shared by both range and span style-apply executors. + * + * Returns true if any transaction steps were actually emitted. + */ +function applyDirectiveToTransaction( + tr: Transaction, + absFrom: number, + absTo: number, + markType: MarkType, + spec: (typeof TOGGLE_MARK_SPECS)[MarkKey], + directive: InlineToggleDirective, +): boolean { + const stepsBefore = tr.steps?.length ?? 0; + + switch (directive) { + case 'on': { + // Apply ON only to sub-ranges that aren't already ON, preserving rich + // attrs (e.g. wavy/colored underline) on nodes that already have them. + const subRanges = collectNonOnSubRanges(tr.doc, absFrom, absTo, markType, spec); + for (const r of subRanges) { + tr.removeMark(r.from, r.to, markType); + tr.addMark(r.from, r.to, spec.createOn(markType) as unknown as ProseMirrorMark); + } + break; + } + case 'off': + // Remove any existing, then add canonical OFF + tr.removeMark(absFrom, absTo, markType); + tr.addMark(absFrom, absTo, markType.create(spec.offAttrs)); + break; + case 'clear': + // Remove mark entirely + tr.removeMark(absFrom, absTo, markType); + break; + } + + return (tr.steps?.length ?? 0) > stepsBefore; +} + export function executeStyleApply( editor: Editor, tr: Transaction, @@ -233,6 +321,10 @@ export function executeStyleApply( ): { changed: boolean } { const absFrom = mapping.map(target.absFrom); const absTo = mapping.map(target.absTo); + + // Collapsed-range rule: silent no-op — no error, no document change. + if (absFrom === absTo) return { changed: false }; + return { changed: applyInlineMarkPatches(editor, tr, absFrom, absTo, step.args.inline) }; } @@ -370,13 +462,14 @@ export function executeSpanStyleApply( mapping: Mapping, ): { changed: boolean } { validateMappedSpanContiguity(target, mapping, step.id); - - // Apply marks uniformly across the full span const firstSeg = target.segments[0]; const lastSeg = target.segments[target.segments.length - 1]; const absFrom = mapping.map(firstSeg.absFrom, 1); const absTo = mapping.map(lastSeg.absTo, -1); + // Collapsed-range rule: silent no-op — no error, no document change. + if (absFrom === absTo) return { changed: false }; + return { changed: applyInlineMarkPatches(editor, tr, absFrom, absTo, step.args.inline) }; } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.ts b/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.ts new file mode 100644 index 0000000000..48d4fe99cf --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.ts @@ -0,0 +1,147 @@ +/** + * Shared mark directive helpers — single source of truth for ON/OFF/CLEAR + * canonical forms and directive application logic. + * + * Used by: executor, style-resolver, match-style-helpers. + */ + +import type { InlineToggleDirective, MarkKey } from '@superdoc/document-api'; + +// --------------------------------------------------------------------------- +// ProseMirror mark type interface (minimal shape for directive logic) +// --------------------------------------------------------------------------- + +interface PmMark { + type: { name: string; create: (attrs?: Record | null) => PmMark }; + attrs: Record; + eq: (other: PmMark) => boolean; +} + +interface PmMarkType { + create: (attrs?: Record | null) => PmMark; +} + +// --------------------------------------------------------------------------- +// Canonical ON/OFF mark attr tables — single source of truth +// --------------------------------------------------------------------------- + +interface ToggleMarkSpec { + schemaName: string; + isOff: (mark: PmMark) => boolean; + isOn: (mark: PmMark) => boolean; + offAttrs: Record; + createOn: (markType: PmMarkType, existingMark?: PmMark) => PmMark; +} + +function isSimpleToggleOff(mark: PmMark): boolean { + return mark.attrs.value === '0'; +} + +function isSimpleToggleOn(mark: PmMark): boolean { + return !isSimpleToggleOff(mark); +} + +function isUnderlineOff(mark: PmMark): boolean { + return mark.attrs.underlineType === 'none'; +} + +function isUnderlineOn(mark: PmMark): boolean { + return !isUnderlineOff(mark); +} + +/** Canonical mark spec table for core-4 toggle marks. */ +export const TOGGLE_MARK_SPECS: Record = { + bold: { + schemaName: 'bold', + isOff: isSimpleToggleOff, + isOn: isSimpleToggleOn, + offAttrs: { value: '0' }, + createOn: (mt) => mt.create(), + }, + italic: { + schemaName: 'italic', + isOff: isSimpleToggleOff, + isOn: isSimpleToggleOn, + offAttrs: { value: '0' }, + createOn: (mt) => mt.create(), + }, + strike: { + schemaName: 'strike', + isOff: isSimpleToggleOff, + isOn: isSimpleToggleOn, + offAttrs: { value: '0' }, + createOn: (mt) => mt.create(), + }, + underline: { + schemaName: 'underline', + isOff: isUnderlineOff, + isOn: isUnderlineOn, + offAttrs: { underlineType: 'none' }, + createOn: (mt, existingMark) => { + // Preserve rich underline attrs if an ON underline already exists + if (existingMark && isUnderlineOn(existingMark)) return existingMark; + return mt.create({ underlineType: 'single' }); + }, + }, +}; + +// --------------------------------------------------------------------------- +// Directive state derivation (for query.match read-side) +// --------------------------------------------------------------------------- + +/** + * Derives the direct toggle state of a mark from the PM mark set. + * + * - Mark present with ON attrs → `'on'` + * - Mark present with OFF attrs → `'off'` + * - Mark absent → `'clear'` + */ +export function deriveToggleState(marks: readonly PmMark[], markKey: MarkKey): InlineToggleDirective { + const spec = TOGGLE_MARK_SPECS[markKey]; + const mark = marks.find((m) => m.type.name === spec.schemaName); + if (!mark) return 'clear'; + return spec.isOff(mark) ? 'off' : 'on'; +} + +// --------------------------------------------------------------------------- +// Directive application helpers (for executor + style-resolver) +// --------------------------------------------------------------------------- + +/** + * Applies an inline toggle directive to a mark array, returning the new mark set. + * This is the shared logic used by both the executor and style-resolver. + */ +export function applyDirectiveToMarks( + marks: readonly PmMark[], + markKey: MarkKey, + directive: InlineToggleDirective, + markType: PmMarkType, +): PmMark[] { + const spec = TOGGLE_MARK_SPECS[markKey]; + const existingMark = marks.find((m) => m.type.name === spec.schemaName); + const otherMarks = marks.filter((m) => m.type.name !== spec.schemaName); + + switch (directive) { + case 'on': { + if (existingMark && spec.isOn(existingMark)) { + // Already ON — no-op (preserves rich attrs for underline) + return [...marks]; + } + return [...otherMarks, spec.createOn(markType, existingMark)]; + } + case 'off': { + if (existingMark && spec.isOff(existingMark)) { + // Already OFF — no-op + return [...marks]; + } + return [...otherMarks, markType.create(spec.offAttrs)]; + } + case 'clear': { + if (!existingMark) { + // Already absent — no-op + return [...marks]; + } + return otherMarks; + } + } +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.test.ts index e602a142c1..921590f683 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { CapturedRun } from './style-resolver.js'; import { marksEqual, @@ -8,9 +8,20 @@ import { normalizeHexColor, parseFontSizePt, assertRunTilingInvariant, + type CascadeContext, } from './match-style-helpers.js'; import type { MatchRun } from '@superdoc/document-api'; +// Mock style-engine resolveRunProperties for cascade context tests +const resolveRunPropertiesMock = vi.hoisted(() => vi.fn(() => ({}))); +vi.mock('@superdoc/style-engine/ooxml', async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + resolveRunProperties: resolveRunPropertiesMock, + }; +}); + // --------------------------------------------------------------------------- // Mock mark factory — minimal PM mark shape for testing // --------------------------------------------------------------------------- @@ -141,18 +152,26 @@ describe('coalesceRuns', () => { // --------------------------------------------------------------------------- describe('toMatchStyle', () => { - it('derives core-4 booleans from mark presence', () => { + it('derives two-layer model from mark presence', () => { const marks = [mockMark('bold'), mockMark('italic')]; const style = toMatchStyle(marks as any); - expect(style.bold).toBe(true); - expect(style.italic).toBe(true); - expect(style.underline).toBe(false); - expect(style.strike).toBe(false); - }); - - it('returns all-false for no core marks', () => { + expect(style.direct.bold).toBe('on'); + expect(style.direct.italic).toBe('on'); + expect(style.direct.underline).toBe('clear'); + expect(style.direct.strike).toBe('clear'); + expect(style.effective.bold).toBe(true); + expect(style.effective.italic).toBe(true); + expect(style.effective.underline).toBe(false); + expect(style.effective.strike).toBe(false); + }); + + it('returns all-clear direct and all-false effective for no core marks', () => { + // When no marks are present, direct is 'clear' (no direct formatting). + // Effective is false as a conservative fallback — the true effective value + // for 'clear' depends on style cascade resolution (SD-2014 Phase 2 follow-up). const style = toMatchStyle([] as any); - expect(style).toMatchObject({ bold: false, italic: false, underline: false, strike: false }); + expect(style.direct).toMatchObject({ bold: 'clear', italic: 'clear', underline: 'clear', strike: 'clear' }); + expect(style.effective).toMatchObject({ bold: false, italic: false, underline: false, strike: false }); }); it('extracts color from runProperties (precedence over textStyle)', () => { @@ -215,6 +234,114 @@ describe('toMatchStyle', () => { }); }); +// --------------------------------------------------------------------------- +// toMatchStyle with cascade context (Phase 4C) +// --------------------------------------------------------------------------- + +describe('toMatchStyle — cascade resolution', () => { + const baseCascadeContext: CascadeContext = { + resolverParams: { + translatedLinkedStyles: { styles: { Normal: {} } } as any, + translatedNumbering: {}, + }, + paragraphProperties: { styleId: 'Normal' }, + }; + + it('resolves clear→true via cascade when style-engine returns bold: true', () => { + resolveRunPropertiesMock.mockReturnValue({ bold: true }); + // No bold mark → direct is 'clear', cascade resolves effective to true + const style = toMatchStyle([] as any, baseCascadeContext); + expect(style.direct.bold).toBe('clear'); + expect(style.effective.bold).toBe(true); + }); + + it('resolves clear→false via cascade when style-engine returns bold: false', () => { + resolveRunPropertiesMock.mockReturnValue({ bold: false }); + const style = toMatchStyle([] as any, baseCascadeContext); + expect(style.direct.bold).toBe('clear'); + expect(style.effective.bold).toBe(false); + }); + + it('resolves clear→false via cascade when style-engine returns no bold property', () => { + resolveRunPropertiesMock.mockReturnValue({}); + const style = toMatchStyle([] as any, baseCascadeContext); + expect(style.direct.bold).toBe('clear'); + expect(style.effective.bold).toBe(false); + }); + + it('does not call resolveRunProperties when no property is clear', () => { + resolveRunPropertiesMock.mockClear(); + const marks = [ + mockMark('bold'), + mockMark('italic'), + mockMark('underline', { underlineType: 'single' }), + mockMark('strike'), + ]; + toMatchStyle(marks as any, baseCascadeContext); + // All four are ON, so no cascade needed + expect(resolveRunPropertiesMock).not.toHaveBeenCalled(); + }); + + it('resolves multiple clear properties in a single call', () => { + resolveRunPropertiesMock.mockReturnValue({ italic: true, strike: true }); + // Only bold mark → italic, underline, strike are clear + const marks = [mockMark('bold')]; + const style = toMatchStyle(marks as any, baseCascadeContext); + expect(style.direct.bold).toBe('on'); + expect(style.effective.bold).toBe(true); + expect(style.direct.italic).toBe('clear'); + expect(style.effective.italic).toBe(true); + expect(style.direct.strike).toBe('clear'); + expect(style.effective.strike).toBe(true); + expect(style.direct.underline).toBe('clear'); + expect(style.effective.underline).toBe(false); // not in resolved + }); + + it('resolves clear underline→true via cascade when style-engine returns underline with ON w:val', () => { + resolveRunPropertiesMock.mockReturnValue({ underline: { 'w:val': 'single' } }); + const style = toMatchStyle([] as any, baseCascadeContext); + expect(style.direct.underline).toBe('clear'); + expect(style.effective.underline).toBe(true); + }); + + it('resolves clear underline→false via cascade when style-engine returns underline with none w:val', () => { + resolveRunPropertiesMock.mockReturnValue({ underline: { 'w:val': 'none' } }); + const style = toMatchStyle([] as any, baseCascadeContext); + expect(style.direct.underline).toBe('clear'); + expect(style.effective.underline).toBe(false); + }); + + it('does NOT override on/off effective values with cascade results', () => { + resolveRunPropertiesMock.mockReturnValue({ bold: false, italic: true }); + // bold mark ON, italic mark OFF — cascade should not change their effective + const marks = [mockMark('bold'), mockMark('italic', { value: '0' })]; + const style = toMatchStyle(marks as any, baseCascadeContext); + expect(style.direct.bold).toBe('on'); + expect(style.effective.bold).toBe(true); // stays true, not overridden by cascade false + expect(style.direct.italic).toBe('off'); + expect(style.effective.italic).toBe(false); // stays false, not overridden by cascade true + }); + + it('uses conservative fallback when no cascade context provided', () => { + resolveRunPropertiesMock.mockClear(); + const style = toMatchStyle([] as any); + expect(style.effective.bold).toBe(false); + expect(style.effective.italic).toBe(false); + expect(resolveRunPropertiesMock).not.toHaveBeenCalled(); + }); + + it('passes inline run properties from marks to resolveRunProperties', () => { + resolveRunPropertiesMock.mockReturnValue({}); + const marks = [mockMark('bold'), mockMark('runProperties', { styleId: 'Emphasis' })]; + toMatchStyle(marks as any, baseCascadeContext); + // underline and strike are clear, so resolveRunProperties should be called + expect(resolveRunPropertiesMock).toHaveBeenCalledTimes(1); + const [, inlineRpr] = resolveRunPropertiesMock.mock.calls[0]; + expect(inlineRpr.bold).toBe(true); + expect(inlineRpr.styleId).toBe('Emphasis'); + }); +}); + // --------------------------------------------------------------------------- // extractRunStyleId (D10a) // --------------------------------------------------------------------------- @@ -354,7 +481,10 @@ describe('assertRunTilingInvariant', () => { return { range: { start, end }, text: 'x'.repeat(end - start), - styles: { bold: false, italic: false, underline: false, strike: false }, + styles: { + direct: { bold: 'clear', italic: 'clear', underline: 'clear', strike: 'clear' }, + effective: { bold: false, italic: false, underline: false, strike: false }, + }, ref: 'test-ref', }; } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.ts index fff2b89604..1bb12baf00 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.ts @@ -10,12 +10,70 @@ */ import type { MatchStyle, MatchRun } from '@superdoc/document-api'; +import { derivePropertyStateFromDirect } from '@superdoc/document-api'; +import { resolveRunProperties } from '@superdoc/style-engine/ooxml'; +import type { OoxmlResolverParams, RunProperties, ParagraphProperties } from '@superdoc/style-engine/ooxml'; import type { CapturedRun } from './style-resolver.js'; import { planError } from './errors.js'; +import { deriveToggleState } from './mark-directives.js'; /** A PM mark as visible on CapturedRun.marks — minimal shape for style extraction. */ type PmMark = CapturedRun['marks'][number]; +// --------------------------------------------------------------------------- +// Cascade context for style-engine effective resolution +// --------------------------------------------------------------------------- + +/** Context passed to `toMatchStyle` when cascade resolution is available. */ +export interface CascadeContext { + resolverParams: OoxmlResolverParams; + paragraphProperties: ParagraphProperties | null; +} + +/** + * Build a minimal RunProperties from PM marks for the style-engine cascade. + * Maps PM mark attrs to the style-engine's RunProperties shape: + * - Toggle marks (bold/italic/strike): mark present ON → true, OFF → false, absent → omitted + * - Underline: mark present → { 'w:val': type }, absent → omitted + * - runProperties mark: extracts styleId for character style cascading + */ +function buildInlineRpr(marks: readonly PmMark[]): RunProperties { + const rpr: RunProperties = {}; + + for (const mark of marks) { + switch (mark.type.name) { + case 'bold': + rpr.bold = mark.attrs.value !== '0'; + break; + case 'italic': + rpr.italic = mark.attrs.value !== '0'; + break; + case 'strike': + rpr.strike = mark.attrs.value !== '0'; + break; + case 'underline': { + const ut = mark.attrs.underlineType; + if (ut === 'none') { + rpr.underline = { 'w:val': 'none' }; + } else if (ut) { + rpr.underline = { 'w:val': ut as string }; + } else { + // Bare underline mark (null/undefined type) → ON with default + rpr.underline = { 'w:val': 'single' }; + } + break; + } + case 'runProperties': + if (typeof mark.attrs.styleId === 'string' && mark.attrs.styleId) { + rpr.styleId = mark.attrs.styleId; + } + break; + } + } + + return rpr; +} + // --------------------------------------------------------------------------- // Mark-signature equality (D4) // --------------------------------------------------------------------------- @@ -89,19 +147,60 @@ export function coalesceRuns(runs: CapturedRun[]): CapturedRun[] { // --------------------------------------------------------------------------- /** - * Projects PM marks into a contract MatchStyle. + * Projects PM marks into a contract MatchStyle with two-layer model. + * + * - `direct`: tri-state toggle derived from mark presence and attrs. + * - `effective`: boolean visual state. For `on`/`off`, deterministic. + * For `clear`, resolved via style-engine cascade when `cascadeContext` is + * provided, otherwise falls back to conservative `false`. * - * Core-4 booleans are derived from mark presence. - * Optional fields use runProperties → textStyle precedence (D15, Phase 2). + * Optional fields use runProperties → textStyle precedence (D15). */ -export function toMatchStyle(marks: readonly PmMark[]): MatchStyle { - const style: MatchStyle = { - bold: marks.some((m) => m.type.name === 'bold'), - italic: marks.some((m) => m.type.name === 'italic'), - underline: marks.some((m) => m.type.name === 'underline'), - strike: marks.some((m) => m.type.name === 'strike'), +export function toMatchStyle(marks: readonly PmMark[], cascadeContext?: CascadeContext): MatchStyle { + const boldDirect = deriveToggleState(marks, 'bold'); + const italicDirect = deriveToggleState(marks, 'italic'); + const underlineDirect = deriveToggleState(marks, 'underline'); + const strikeDirect = deriveToggleState(marks, 'strike'); + + const direct = { + bold: boldDirect, + italic: italicDirect, + underline: underlineDirect, + strike: strikeDirect, }; + // Derive effective: deterministic for on/off, cascade-resolved or conservative for clear. + const boldState = derivePropertyStateFromDirect(boldDirect); + const italicState = derivePropertyStateFromDirect(italicDirect); + const underlineState = derivePropertyStateFromDirect(underlineDirect); + const strikeState = derivePropertyStateFromDirect(strikeDirect); + + const effective = { + bold: boldState.effective, + italic: italicState.effective, + underline: underlineState.effective, + strike: strikeState.effective, + }; + + // When cascade context is available and any property is 'clear', resolve via style-engine. + const hasClear = + boldDirect === 'clear' || italicDirect === 'clear' || underlineDirect === 'clear' || strikeDirect === 'clear'; + + if (cascadeContext && hasClear) { + const inlineRpr = buildInlineRpr(marks); + const resolved = resolveRunProperties(cascadeContext.resolverParams, inlineRpr, cascadeContext.paragraphProperties); + + if (boldDirect === 'clear') effective.bold = resolved.bold ?? false; + if (italicDirect === 'clear') effective.italic = resolved.italic ?? false; + if (strikeDirect === 'clear') effective.strike = resolved.strike ?? false; + if (underlineDirect === 'clear') { + const uVal = resolved.underline?.['w:val']; + effective.underline = uVal != null && uVal !== 'none'; + } + } + + const style: MatchStyle = { direct, effective }; + // Extract optional presentational fields with runProperties > textStyle precedence const runProps = marks.find((m) => m.type.name === 'runProperties'); const textStyle = marks.find((m) => m.type.name === 'textStyle'); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.test.ts index a10a54c32f..8f0414b079 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.test.ts @@ -198,11 +198,13 @@ describe('queryMatchAdapter — blocks/runs output', () => { // First run: bold expect(block.runs[0].range).toEqual({ start: 0, end: 6 }); - expect(block.runs[0].styles.bold).toBe(true); + expect(block.runs[0].styles.direct.bold).toBe('on'); + expect(block.runs[0].styles.effective.bold).toBe(true); // Second run: plain expect(block.runs[1].range).toEqual({ start: 6, end: 20 }); - expect(block.runs[1].styles.bold).toBe(false); + expect(block.runs[1].styles.direct.bold).toBe('clear'); + expect(block.runs[1].styles.effective.bold).toBe(false); // Runs tile block range expect(block.runs[0].range.start).toBe(block.range.start); @@ -245,11 +247,13 @@ describe('queryMatchAdapter — blocks/runs output', () => { expect(match.blocks[0].blockId).toBe('p1'); expect(match.blocks[0].runs).toHaveLength(1); - expect(match.blocks[0].runs[0].styles.bold).toBe(true); + expect(match.blocks[0].runs[0].styles.direct.bold).toBe('on'); + expect(match.blocks[0].runs[0].styles.effective.bold).toBe(true); expect(match.blocks[1].blockId).toBe('p2'); expect(match.blocks[1].runs).toHaveLength(1); - expect(match.blocks[1].runs[0].styles.italic).toBe(true); + expect(match.blocks[1].runs[0].styles.direct.italic).toBe('on'); + expect(match.blocks[1].runs[0].styles.effective.italic).toBe(true); }); it('does not throw when a text match spans an inline placeholder offset', () => { @@ -675,6 +679,116 @@ describe('queryMatchAdapter — snippet assembly', () => { }); }); +// --------------------------------------------------------------------------- +// Tests: meta.effectiveResolved (Phase 4C) +// --------------------------------------------------------------------------- + +describe('queryMatchAdapter — meta.effectiveResolved', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedDeps.getRevision.mockReturnValue('rev-1'); + }); + + it('returns effectiveResolved: false when editor has no converter context', () => { + const candidates = [{ nodeId: 'p1', pos: 0, end: 7, text: 'hello' }]; + const editor = makeEditorWithBlocks(candidates); + setupBlockIndex(candidates.map(({ nodeId, pos, end }) => ({ nodeId, pos, end }))); + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [{ textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }] }], + total: 1, + }); + mockedDeps.captureRunsInRange.mockReturnValue(captured([capturedRun(0, 5, [])])); + + const result = queryMatchAdapter(editor, { + select: { type: 'text', pattern: 'hello' }, + }); + + expect(result.meta.effectiveResolved).toBe(false); + }); + + it('returns effectiveResolved: true when editor has converter with translatedLinkedStyles.styles', () => { + const candidates = [{ nodeId: 'p1', pos: 0, end: 7, text: 'hello' }]; + const editor = makeEditorWithBlocks(candidates); + // Attach converter context with styles + (editor as any).converter = { + translatedLinkedStyles: { styles: { Normal: {} } }, + translatedNumbering: {}, + }; + setupBlockIndex(candidates.map(({ nodeId, pos, end }) => ({ nodeId, pos, end }))); + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [{ textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }] }], + total: 1, + }); + mockedDeps.captureRunsInRange.mockReturnValue(captured([capturedRun(0, 5, [])])); + + const result = queryMatchAdapter(editor, { + select: { type: 'text', pattern: 'hello' }, + }); + + expect(result.meta.effectiveResolved).toBe(true); + }); + + it('returns effectiveResolved: false when converter lacks translatedLinkedStyles.styles', () => { + const candidates = [{ nodeId: 'p1', pos: 0, end: 7, text: 'hello' }]; + const editor = makeEditorWithBlocks(candidates); + (editor as any).converter = { + translatedLinkedStyles: {}, // no .styles + translatedNumbering: {}, + }; + setupBlockIndex(candidates.map(({ nodeId, pos, end }) => ({ nodeId, pos, end }))); + setupFindResult({ + matches: [{ kind: 'text', blockId: 'p1' }], + context: [{ textRanges: [{ kind: 'text', blockId: 'p1', range: { start: 0, end: 5 } }] }], + total: 1, + }); + mockedDeps.captureRunsInRange.mockReturnValue(captured([capturedRun(0, 5, [])])); + + const result = queryMatchAdapter(editor, { + select: { type: 'text', pattern: 'hello' }, + }); + + expect(result.meta.effectiveResolved).toBe(false); + }); + + it('returns effectiveResolved: false for node-selector matches without converter', () => { + setupFindResult({ + matches: [{ kind: 'block', nodeId: 'p1' }], + context: [{}], + total: 1, + }); + const dummyEditor = {} as any; + + const result = queryMatchAdapter(dummyEditor, { + select: { type: 'node', nodeType: 'paragraph' }, + }); + + expect(result.meta.effectiveResolved).toBe(false); + }); + + it('returns effectiveResolved: false for node-selector even with converter cascade available', () => { + setupFindResult({ + matches: [{ kind: 'block', nodeId: 'p1' }], + context: [{}], + total: 1, + }); + const dummyEditor = { + converter: { + translatedLinkedStyles: { styles: { Normal: {} } }, + translatedNumbering: {}, + }, + } as any; + + const result = queryMatchAdapter(dummyEditor, { + select: { type: 'node', nodeType: 'paragraph' }, + }); + + // Node matches don't produce run-level style data, so effectiveResolved must be false + expect(result.meta.effectiveResolved).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // Tests: cardinality enforcement // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts index ba86b5ddd6..4aef3872ce 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/query-match-adapter.ts @@ -13,6 +13,7 @@ import type { QueryMatchInput, QueryMatchOutput, QueryMatchItem, + QueryMatchMeta, TextMatchItem, NodeMatchItem, MatchBlock, @@ -36,7 +37,14 @@ import { validatePaginationInput } from '../helpers/adapter-utils.js'; import { captureRunsInRange } from './style-resolver.js'; import { getRevision } from './revision-tracker.js'; import { planError } from './errors.js'; -import { coalesceRuns, toMatchStyle, extractRunStyleId, assertRunTilingInvariant } from './match-style-helpers.js'; +import { + coalesceRuns, + toMatchStyle, + extractRunStyleId, + assertRunTilingInvariant, + type CascadeContext, +} from './match-style-helpers.js'; +import type { OoxmlResolverParams, ParagraphProperties } from '@superdoc/style-engine/ooxml'; // --------------------------------------------------------------------------- // V3 ref encoding (D6) @@ -67,6 +75,7 @@ function encodeV3Ref(payload: TextRefV3): string { * @param textRanges - Raw text ranges from the find adapter context. * @param evaluatedRevision - Current doc revision for ref encoding. * @param matchId - The match's deterministic ID. + * @param resolverParams - Optional style-engine resolver params for cascade resolution. * @returns Array of MatchBlocks in document order (D16). */ function buildMatchBlocks( @@ -74,6 +83,7 @@ function buildMatchBlocks( textRanges: TextAddress[], evaluatedRevision: string, matchId: string, + resolverParams?: OoxmlResolverParams | null, ): MatchBlock[] { const index = getBlockIndex(editor); const doc = editor.state.doc; @@ -125,6 +135,14 @@ function buildMatchBlocks( // Build paragraph style (D10) const paragraphStyle = buildParagraphStyle(node); + // Build per-block cascade context for style-engine resolution + const cascadeContext: CascadeContext | undefined = resolverParams + ? { + resolverParams, + paragraphProperties: (node?.attrs?.paragraphProperties as ParagraphProperties) ?? null, + } + : undefined; + // Capture PM runs within the matched range and coalesce (D4) const captured = captureRunsInRange(editor, candidate.pos, from, to); const coalesced = coalesceRuns(captured.runs); @@ -135,7 +153,7 @@ function buildMatchBlocks( range: { start: run.from, end: run.to }, text: blockText.slice(run.from, run.to), styleId: extractRunStyleId(run.marks), - styles: toMatchStyle(run.marks), + styles: toMatchStyle(run.marks, cascadeContext), ref: encodeV3Ref({ v: 3, rev: evaluatedRevision, @@ -340,6 +358,20 @@ export function queryMatchAdapter(editor: Editor, input: QueryMatchInput): Query const evaluatedRevision = getRevision(editor); const require: CardinalityRequirement = input.require ?? 'any'; + // Build style-engine resolver params from converter context (if available). + // When translatedLinkedStyles.styles exists, resolveRunProperties can perform + // full cascade resolution for 'clear' properties (defaults → style chain → inline). + const converter = (editor as unknown as { converter?: Partial }).converter; + const translatedLinkedStyles = converter?.translatedLinkedStyles; + const translatedNumbering = converter?.translatedNumbering; + const hasStyleCascade = translatedLinkedStyles?.styles != null; + const resolverParams: OoxmlResolverParams | null = hasStyleCascade + ? { + translatedLinkedStyles, + translatedNumbering, + } + : null; + // Validate pagination + cardinality interaction if ((require === 'first' || require === 'exactlyOne') && (input.limit !== undefined || input.offset !== undefined)) { throw planError('INVALID_INPUT', `limit/offset are not valid when require is "${require}"`); @@ -347,6 +379,10 @@ export function queryMatchAdapter(editor: Editor, input: QueryMatchInput): Query const isTextSelector = input.select.type === 'text'; + // Effective resolution is only meaningful for text selectors (which produce + // run-level style data). Node-only matches don't perform cascade resolution. + const effectiveResolved = hasStyleCascade && isTextSelector; + // Execute search using the find adapter infrastructure. // For text selectors, omit limit/offset here because zero-width filtering (D20) // must run on all matches before pagination. We paginate ourselves after filtering. @@ -411,7 +447,7 @@ export function queryMatchAdapter(editor: Editor, input: QueryMatchInput): Query if (isTextSelector && raw.textRanges?.length) { // Text match → build blocks/runs hierarchy (D1) - const blocks = buildMatchBlocks(editor, raw.textRanges, evaluatedRevision, id); + const blocks = buildMatchBlocks(editor, raw.textRanges, evaluatedRevision, id, resolverParams); if (blocks.length === 0) { // Shouldn't happen after zero-width filtering, but guard @@ -488,10 +524,15 @@ export function queryMatchAdapter(editor: Editor, input: QueryMatchInput): Query returned: truncated.length, }; + // Effective resolution: true when converter context with style cascade is available, + // meaning 'clear' properties are resolved via the style-engine rather than conservative fallback. + const meta: QueryMatchMeta = { effectiveResolved }; + return buildDiscoveryResult({ evaluatedRevision, total: totalMatches, items: truncated, page, - }); + meta, + }) as QueryMatchOutput; } diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts index 98aa423059..872089ef53 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { captureRunsInRange } from './style-resolver.js'; +import { captureRunsInRange, resolveInlineStyle } from './style-resolver.js'; import { coalesceRuns, assertRunTilingInvariant } from './match-style-helpers.js'; import type { Editor } from '../../core/Editor.js'; import type { Node as ProseMirrorNode } from 'prosemirror-model'; @@ -81,6 +81,25 @@ function makeEditor(blockPos: number, blockNode: ProseMirrorNode | null): Editor } as unknown as Editor; } +function makeStyleEditor(): Editor { + const createMarkFactory = (name: string) => ({ + create: (attrs?: Record | null) => mockMark(name, (attrs ?? {}) as Record), + }); + + return { + state: { + schema: { + marks: { + bold: createMarkFactory('bold'), + italic: createMarkFactory('italic'), + underline: createMarkFactory('underline'), + strike: createMarkFactory('strike'), + }, + }, + }, + } as unknown as Editor; +} + describe('captureRunsInRange', () => { it('uses wrapper-transparent text offsets so adjacent runs stay contiguous', () => { const bold = mockMark('bold'); @@ -174,7 +193,10 @@ describe('captureRunsInRange', () => { const matchRuns: MatchRun[] = coalesced.map((run, idx) => ({ range: { start: run.from, end: run.to }, text: `r${idx}`, - styles: { bold: false, italic: false, underline: false, strike: false }, + styles: { + direct: { bold: 'clear', italic: 'clear', underline: 'clear', strike: 'clear' }, + effective: { bold: false, italic: false, underline: false, strike: false }, + }, ref: `test-ref-${idx}`, })); @@ -244,13 +266,109 @@ describe('captureRunsInRange', () => { const matchRuns: MatchRun[] = coalesced.map((run, idx) => ({ range: { start: run.from, end: run.to }, text: `r${idx}`, - styles: { bold: false, italic: false, underline: false, strike: false }, + styles: { + direct: { bold: 'clear', italic: 'clear', underline: 'clear', strike: 'clear' }, + effective: { bold: false, italic: false, underline: false, strike: false }, + }, ref: `test-ref-${idx}`, })); expect(() => assertRunTilingInvariant(matchRuns, { start: 0, end: 5 }, 'p1')).not.toThrow(); }); }); +describe('resolveInlineStyle: tri-state core marks', () => { + it('majority treats OFF as distinct from ON', () => { + const editor = makeStyleEditor(); + const onBold = mockMark('bold'); + const offBold = mockMark('bold', { value: '0' }); + + const resolved = resolveInlineStyle( + editor, + { + isUniform: false, + runs: [ + { from: 0, to: 2, charCount: 2, marks: [onBold] }, + { from: 2, to: 7, charCount: 5, marks: [offBold] }, + ], + }, + { mode: 'preserve', onNonUniform: 'majority' }, + 'step-1', + ); + + const bold = resolved.find((mark) => mark.type.name === 'bold') as MockMark | undefined; + expect(bold).toBeDefined(); + expect(bold?.attrs.value).toBe('0'); + }); + + it('majority tie picks the first run directive', () => { + const editor = makeStyleEditor(); + const onBold = mockMark('bold'); + const offBold = mockMark('bold', { value: '0' }); + + const resolved = resolveInlineStyle( + editor, + { + isUniform: false, + runs: [ + { from: 0, to: 4, charCount: 4, marks: [offBold] }, + { from: 4, to: 8, charCount: 4, marks: [onBold] }, + ], + }, + { mode: 'preserve', onNonUniform: 'majority' }, + 'step-1', + ); + + const bold = resolved.find((mark) => mark.type.name === 'bold') as MockMark | undefined; + expect(bold).toBeDefined(); + expect(bold?.attrs.value).toBe('0'); + }); + + it('union prefers ON when both ON and OFF appear', () => { + const editor = makeStyleEditor(); + const onBold = mockMark('bold'); + const offBold = mockMark('bold', { value: '0' }); + + const resolved = resolveInlineStyle( + editor, + { + isUniform: false, + runs: [ + { from: 0, to: 2, charCount: 2, marks: [offBold] }, + { from: 2, to: 5, charCount: 3, marks: [onBold] }, + ], + }, + { mode: 'preserve', onNonUniform: 'union' }, + 'step-1', + ); + + const bold = resolved.find((mark) => mark.type.name === 'bold') as MockMark | undefined; + expect(bold).toBeDefined(); + expect(bold?.attrs.value).toBeUndefined(); + }); + + it('union returns OFF when no run is ON', () => { + const editor = makeStyleEditor(); + const offUnderline = mockMark('underline', { underlineType: 'none' }); + + const resolved = resolveInlineStyle( + editor, + { + isUniform: false, + runs: [ + { from: 0, to: 3, charCount: 3, marks: [] }, + { from: 3, to: 5, charCount: 2, marks: [offUnderline] }, + ], + }, + { mode: 'preserve', onNonUniform: 'union' }, + 'step-1', + ); + + const underline = resolved.find((mark) => mark.type.name === 'underline') as MockMark | undefined; + expect(underline).toBeDefined(); + expect(underline?.attrs.underlineType).toBe('none'); + }); +}); + // --------------------------------------------------------------------------- // T9: Run-capture perf budget test (Workstream A) // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts index 9a8cc0aca6..e46f9cda28 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/style-resolver.ts @@ -5,9 +5,11 @@ * Phase 7: Style capture and style-aware rewrite. */ -import type { InlineStylePolicy, SetMarks } from '@superdoc/document-api'; +import type { InlineStylePolicy, SetMarks, MarkKey, InlineToggleDirective } from '@superdoc/document-api'; +import { MARK_KEYS } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; import { planError } from './errors.js'; +import { TOGGLE_MARK_SPECS, applyDirectiveToMarks } from './mark-directives.js'; // --------------------------------------------------------------------------- // Run types — describes contiguous spans sharing identical marks within a block @@ -44,7 +46,9 @@ export interface CapturedStyle { // Core mark names — the four marks that setMarks can override // --------------------------------------------------------------------------- -const CORE_MARK_NAMES = new Set(['bold', 'italic', 'underline', 'strike']); +const CORE_MARK_KEYS = ['bold', 'italic', 'underline', 'strike'] as const; +type CoreMarkName = (typeof CORE_MARK_KEYS)[number]; +const CORE_MARK_NAMES = new Set(CORE_MARK_KEYS); /** Mark names that are metadata (never affected by style policy). */ const METADATA_MARK_NAMES = new Set([ @@ -160,6 +164,74 @@ function marksEqual(a: readonly PmMark[], b: readonly PmMark[]): boolean { return true; } +function isCoreMarkName(markName: string): markName is CoreMarkName { + return CORE_MARK_NAMES.has(markName as CoreMarkName); +} + +function deriveCoreMarkDirective(run: CapturedRun, markName: CoreMarkName): InlineToggleDirective { + const spec = TOGGLE_MARK_SPECS[markName]; + const mark = run.marks.find((m) => m.type.name === spec.schemaName); + if (!mark) return 'clear'; + return spec.isOff(mark) ? 'off' : 'on'; +} + +function findCoreMarkInState( + runs: CapturedRun[], + markName: CoreMarkName, + directive: Exclude, +): PmMark | undefined { + for (const run of runs) { + const state = deriveCoreMarkDirective(run, markName); + if (state !== directive) continue; + + const spec = TOGGLE_MARK_SPECS[markName]; + const found = run.marks.find((m) => m.type.name === spec.schemaName); + if (found) return found; + } + return undefined; +} + +function createCoreMarkFromState( + editor: Editor, + markName: CoreMarkName, + directive: Exclude, +): PmMark | undefined { + const spec = TOGGLE_MARK_SPECS[markName]; + const markType = editor.state.schema.marks[spec.schemaName]; + if (!markType) return undefined; + + if (directive === 'on') { + return spec.createOn(markType) as unknown as PmMark; + } + return markType.create(spec.offAttrs) as unknown as PmMark; +} + +function resolveMajorityDirectiveForCoreMark(runs: CapturedRun[], markName: CoreMarkName): InlineToggleDirective { + const tally: Record = { + on: { chars: 0, firstRunIdx: Number.POSITIVE_INFINITY }, + off: { chars: 0, firstRunIdx: Number.POSITIVE_INFINITY }, + clear: { chars: 0, firstRunIdx: Number.POSITIVE_INFINITY }, + }; + + for (let i = 0; i < runs.length; i++) { + const directive = deriveCoreMarkDirective(runs[i], markName); + tally[directive].chars += runs[i].charCount; + if (i < tally[directive].firstRunIdx) { + tally[directive].firstRunIdx = i; + } + } + + let winner: InlineToggleDirective = 'clear'; + for (const directive of ['on', 'off', 'clear'] as const) { + const current = tally[directive]; + const best = tally[winner]; + if (current.chars > best.chars || (current.chars === best.chars && current.firstRunIdx < best.firstRunIdx)) { + winner = directive; + } + } + return winner; +} + // --------------------------------------------------------------------------- // Resolution — resolve non-uniform styles using strategies // --------------------------------------------------------------------------- @@ -250,9 +322,10 @@ function resolveUseLeadingRun(runs: CapturedRun[]): readonly PmMark[] { } /** - * Per-mark character-weighted voting. A mark is included if it covers strictly - * more than half the total characters. For value-bearing attributes, the value - * covering the most characters wins; ties go to the first run's value. + * Per-mark character-weighted voting. + * - Core toggle marks (bold/italic/underline/strike): vote over tri-state + * directives (`on` | `off` | `clear`), with ties broken by first run. + * - Value-bearing marks: vote each attribute independently by covered chars. */ function resolveMajority(editor: Editor, runs: CapturedRun[]): readonly PmMark[] { const totalChars = runs.reduce((sum, r) => sum + r.charCount, 0); @@ -269,25 +342,18 @@ function resolveMajority(editor: Editor, runs: CapturedRun[]): readonly PmMark[] const resultMarks: PmMark[] = []; for (const markName of allMarkNames) { - if (CORE_MARK_NAMES.has(markName)) { - // Boolean mark — include if active chars > totalChars / 2 (strict majority) - let activeChars = 0; - for (const run of runs) { - if (run.marks.some((m) => m.type.name === markName)) { - activeChars += run.charCount; - } + if (isCoreMarkName(markName)) { + const winningDirective = resolveMajorityDirectiveForCoreMark(runs, markName); + if (winningDirective === 'clear') { + continue; } - if (activeChars > totalChars / 2) { - // Find the mark instance from any run - for (const run of runs) { - const found = run.marks.find((m) => m.type.name === markName); - if (found) { - resultMarks.push(found); - break; - } - } + + const resolvedMark = + findCoreMarkInState(runs, markName, winningDirective) ?? + createCoreMarkFromState(editor, markName, winningDirective); + if (resolvedMark) { + resultMarks.push(resolvedMark); } - // Tie (exactly 50/50) → excluded } else { // Value-bearing mark (e.g., textStyle) — per-attribute majority voting resolveValueBearingMarkMajority(runs, markName, totalChars, resultMarks); @@ -385,8 +451,10 @@ function resolveValueBearingMarkMajority( } /** - * Include a mark if it appears on any run. For value-bearing attributes, use - * the value from the first run that has the attribute. + * Union strategy for non-uniform marks. + * - Core toggle marks: prefer ON if present on any run; otherwise OFF if present; + * otherwise CLEAR (omitted). + * - Value-bearing marks: use the first run instance that has the mark. */ function resolveUnion(editor: Editor, runs: CapturedRun[]): readonly PmMark[] { // Collect all unique mark type names @@ -400,14 +468,19 @@ function resolveUnion(editor: Editor, runs: CapturedRun[]): readonly PmMark[] { const resultMarks: PmMark[] = []; for (const markName of allMarkNames) { - if (CORE_MARK_NAMES.has(markName)) { - // Boolean mark — include if present on any run - for (const run of runs) { - const found = run.marks.find((m) => m.type.name === markName); - if (found) { - resultMarks.push(found); - break; - } + if (isCoreMarkName(markName)) { + const hasOn = runs.some((run) => deriveCoreMarkDirective(run, markName) === 'on'); + const hasOff = !hasOn && runs.some((run) => deriveCoreMarkDirective(run, markName) === 'off'); + const unionDirective: InlineToggleDirective = hasOn ? 'on' : hasOff ? 'off' : 'clear'; + if (unionDirective === 'clear') { + continue; + } + + const resolvedMark = + findCoreMarkInState(runs, markName, unionDirective) ?? + createCoreMarkFromState(editor, markName, unionDirective); + if (resolvedMark) { + resultMarks.push(resolvedMark); } } else { // Value-bearing mark — use first run's instance that has it @@ -425,49 +498,55 @@ function resolveUnion(editor: Editor, runs: CapturedRun[]): readonly PmMark[] { } // --------------------------------------------------------------------------- -// setMarks override helpers +// setMarks override helpers — tri-state directive model // --------------------------------------------------------------------------- /** * Build PM marks from a SetMarks declaration (for mode: 'set'). + * Used when building marks from scratch (no existing marks to preserve). */ function buildMarksFromPolicy(editor: Editor, setMarks?: SetMarks): PmMark[] { if (!setMarks) return []; const { schema } = editor.state; const marks: PmMark[] = []; - if (setMarks.bold && schema.marks.bold) marks.push(schema.marks.bold.create() as unknown as PmMark); - if (setMarks.italic && schema.marks.italic) marks.push(schema.marks.italic.create() as unknown as PmMark); - if (setMarks.underline && schema.marks.underline) marks.push(schema.marks.underline.create() as unknown as PmMark); - if (setMarks.strike && schema.marks.strike) marks.push(schema.marks.strike.create() as unknown as PmMark); + for (const key of MARK_KEYS) { + const directive = setMarks[key as MarkKey] as InlineToggleDirective | undefined; + if (!directive) continue; + + const spec = TOGGLE_MARK_SPECS[key as MarkKey]; + const markType = schema.marks[spec.schemaName]; + if (!markType) continue; + + if (directive === 'on') { + marks.push(spec.createOn(markType) as unknown as PmMark); + } else if (directive === 'off') { + marks.push(markType.create(spec.offAttrs) as unknown as PmMark); + } + // 'clear' → skip (no mark) + } return marks; } /** * Apply setMarks overrides to an existing resolved mark set. - * setMarks acts as a patch: true adds, false removes, undefined leaves untouched. + * Uses the shared `applyDirectiveToMarks` helper for correct ON-preservation + * semantics (e.g., underline ON preserves rich attrs). */ function applySetMarksToResolved(editor: Editor, existingMarks: readonly PmMark[], setMarks: SetMarks): PmMark[] { const { schema } = editor.state; let marks = [...existingMarks]; - const overrides: Array<[boolean | undefined, unknown]> = [ - [setMarks.bold, schema.marks.bold], - [setMarks.italic, schema.marks.italic], - [setMarks.underline, schema.marks.underline], - [setMarks.strike, schema.marks.strike], - ]; - - for (const [value, markType] of overrides) { - if (value === undefined || !markType) continue; - if (value) { - if (!marks.some((m) => m.type === (markType as any))) { - marks.push((markType as any).create() as PmMark); - } - } else { - marks = marks.filter((m) => m.type !== (markType as any)); - } + for (const key of MARK_KEYS) { + const directive = setMarks[key as MarkKey] as InlineToggleDirective | undefined; + if (!directive) continue; + + const spec = TOGGLE_MARK_SPECS[key as MarkKey]; + const markType = schema.marks[spec.schemaName]; + if (!markType) continue; + + marks = applyDirectiveToMarks(marks, key as MarkKey, directive, markType); } return marks; diff --git a/tests/doc-api-stories/tests/ex1-clause-change.ts b/tests/doc-api-stories/tests/ex1-clause-change.ts index bbb046e211..fbbd7eb839 100644 --- a/tests/doc-api-stories/tests/ex1-clause-change.ts +++ b/tests/doc-api-stories/tests/ex1-clause-change.ts @@ -46,7 +46,9 @@ describe('document-api story: ex1 clause change', () => { expect(block.runs.length).toBeGreaterThan(0); for (const run of block.runs) { expect(run.styles).toBeDefined(); - expect(typeof run.styles.bold).toBe('boolean'); + expect(run.styles.direct).toBeDefined(); + expect(run.styles.effective).toBeDefined(); + expect(typeof run.styles.effective.bold).toBe('boolean'); expect(run.ref).toBeDefined(); } } diff --git a/tests/doc-api-stories/tests/formatting/inline-formatting.ts b/tests/doc-api-stories/tests/formatting/inline-formatting.ts index 701d4f3229..bd6d95103e 100644 --- a/tests/doc-api-stories/tests/formatting/inline-formatting.ts +++ b/tests/doc-api-stories/tests/formatting/inline-formatting.ts @@ -1,237 +1,469 @@ import { describe, expect, it } from 'vitest'; -import { unwrap, useStoryHarness } from '../harness'; - -/** - * End-to-end story tests for all inline formatting operations. - * - * Each test opens a blank document, inserts descriptive text, then applies - * the corresponding format operation. Starting from a blank doc proves the - * full pipeline: SDK → CLI → document-api → adapter → editor, without - * depending on any pre-existing corpus document. - * - * The blank DOCX template contains a single empty paragraph with a stable - * `w14:paraId` attribute that survives DOCX export/reimport cycles, so the - * blockId returned by `insert` remains valid for subsequent operations. - * - * Covered operations: - * format.apply — bold, italic, underline, strike (boolean mark patches) - * format.fontSize, format.fontFamily, format.color (value-based inline marks) - * format.align — paragraph-level alignment (center, right, justify) - */ +import { corpusDoc, unwrap, useStoryHarness } from '../harness'; + +type InlineDirective = 'on' | 'off' | 'clear'; + +type InlinePatch = { + bold?: InlineDirective; + italic?: InlineDirective; + underline?: InlineDirective; + strike?: InlineDirective; +}; + +type TextTarget = { + kind: 'text'; + blockId: string; + range: { start: number; end: number }; +}; + +type RunStyles = { + direct: { + bold: InlineDirective; + italic: InlineDirective; + underline: InlineDirective; + strike: InlineDirective; + }; + effective: { + bold: boolean; + italic: boolean; + underline: boolean; + strike: boolean; + }; +}; + +const SOURCE_FIXTURE = 'basic/first-arial.docx'; + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +function mutationSuccess(payload: any): boolean { + return payload?.receipt?.success ?? payload?.success ?? false; +} + +function assertMutationSuccess(payload: any): void { + expect(mutationSuccess(payload)).toBe(true); +} + +function buildTextTarget(blockId: string, text: string): TextTarget { + return { + kind: 'text', + blockId, + range: { start: 0, end: text.length }, + }; +} + describe('document-api story: inline formatting', () => { - const { client, outPath } = useStoryHarness('formatting/inline-formatting', { + const { client, copyDoc, outPath, runCli } = useStoryHarness('formatting/inline-formatting', { preserveResults: true, }); - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- + async function saveResult(sessionId: string, docName: string): Promise { + await client.doc.save({ sessionId, out: outPath(docName) }); + } - /** - * Opens a blank doc, inserts the given descriptive text, and returns a - * target spanning the full inserted range. - * - * Each test gets its own session (and thus its own working doc on disk). - */ - async function setupFormattableText(sessionId: string, text: string) { - // Open a blank document (no doc path → uses built-in blank DOCX template) + async function seedBlankFormattableRange( + sessionId: string, + sourceDocName: string, + text: string, + ): Promise<{ text: string; pattern: string; target: TextTarget }> { await client.doc.open({ sessionId }); - // Insert text into the blank doc's single paragraph. - // Without an explicit target, insert uses the first paragraph. const insertResult = unwrap(await client.doc.insert({ sessionId, value: text })); - expect(insertResult.receipt?.success).toBe(true); + assertMutationSuccess(insertResult); + + const blockId = insertResult?.target?.blockId as string | undefined; + expect(typeof blockId).toBe('string'); + if (!blockId) { + throw new Error('insert did not return target.blockId'); + } - // The receipt's hoisted target contains the paragraph's stable blockId. - const blockId = insertResult.target?.blockId; - if (!blockId) throw new Error('Insert did not return a target blockId.'); + await saveResult(sessionId, sourceDocName); - // Build a target spanning the full inserted text return { - kind: 'text' as const, - blockId, - range: { start: 0, end: text.length }, + text, + pattern: text, + target: buildTextTarget(blockId, text), }; } - /** Export the session's working doc to the results directory. */ - async function saveResult(sessionId: string, docName: string) { - await client.doc.save({ sessionId, out: outPath(docName) }); + async function openFixtureDoc(sessionId: string, sourceDocName: string): Promise { + const sourceDoc = await copyDoc(corpusDoc(SOURCE_FIXTURE), sourceDocName); + await client.doc.open({ doc: sourceDoc, sessionId }); } - // --------------------------------------------------------------------------- - // format.apply — boolean mark patches - // --------------------------------------------------------------------------- + async function firstLinePattern(sessionId: string): Promise { + const textResult = unwrap(await client.doc.getText({ sessionId })); + const firstLine = String(textResult?.text ?? '').split('\n')[0] ?? ''; + expect(firstLine.length).toBeGreaterThan(1); + return firstLine; + } - it('bold: applies bold to inserted text', async () => { - const sid = `bold-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be bold'); + async function queryFirstTextMatch(sessionId: string, pattern: string): Promise { + const match = unwrap( + await client.doc.query.match({ + sessionId, + select: { type: 'text', pattern, caseSensitive: true }, + require: 'first', + }), + ); - const result = unwrap(await client.doc.format.apply({ sessionId: sid, target, inline: { bold: true } })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'bold.docx'); - }); + expect(Array.isArray(match?.items)).toBe(true); + expect(match.items.length).toBeGreaterThan(0); + expect(match.items[0]?.matchKind).toBe('text'); + return match; + } - it('italic: applies italic to inserted text', async () => { - const sid = `italic-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be italic'); + async function findFirstTextTarget(sessionId: string, pattern: string): Promise { + const findResult = unwrap( + await client.doc.find({ + sessionId, + type: 'text', + pattern, + require: 'first', + }), + ); - const result = unwrap(await client.doc.format.apply({ sessionId: sid, target, inline: { italic: true } })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'italic.docx'); - }); + const target = findResult?.items?.[0]?.context?.textRanges?.[0] as TextTarget | undefined; + expect(target?.kind).toBe('text'); + expect(typeof target?.blockId).toBe('string'); + return target as TextTarget; + } - it('underline: applies underline to inserted text', async () => { - const sid = `underline-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be underlined'); + function firstRunStyles(match: any): RunStyles { + const styles = match?.items?.[0]?.blocks?.[0]?.runs?.[0]?.styles; + expect(styles).toBeDefined(); + expect(styles.direct).toBeDefined(); + expect(styles.effective).toBeDefined(); + return styles as RunStyles; + } - const result = unwrap(await client.doc.format.apply({ sessionId: sid, target, inline: { underline: true } })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'underline.docx'); + async function applyInline(sessionId: string, target: TextTarget, inline: InlinePatch): Promise { + const result = unwrap(await client.doc.format.apply({ sessionId, target, inline })); + assertMutationSuccess(result); + return result; + } + + async function applyRunDocDefaultsPatch( + sourceDoc: string, + patch: Record, + outDoc: string, + ): Promise { + const envelope = await runCli([ + 'styles', + 'apply', + sourceDoc, + '--target-json', + JSON.stringify({ scope: 'docDefaults', channel: 'run' }), + '--patch-json', + JSON.stringify(patch), + '--out', + outDoc, + ]); + + const payload = envelope?.data ?? envelope; + const receipt = payload?.receipt ?? payload; + expect(receipt).toBeDefined(); + expect(receipt.success).toBe(true); + return receipt; + } + + it('bold on: applies bold to inserted text', async () => { + const sessionId = sid('bold-on'); + const { target } = await seedBlankFormattableRange(sessionId, 'bold-on-source.docx', 'This text should be bold.'); + await applyInline(sessionId, target, { bold: 'on' }); + await saveResult(sessionId, 'bold-on.docx'); }); - it('strikethrough: applies strike to inserted text', async () => { - const sid = `strike-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be struck through'); + it('italic on: applies italic to inserted text', async () => { + const sessionId = sid('italic-on'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'italic-on-source.docx', + 'This text should be italic.', + ); + await applyInline(sessionId, target, { italic: 'on' }); + await saveResult(sessionId, 'italic-on.docx'); + }); - const result = unwrap(await client.doc.format.apply({ sessionId: sid, target, inline: { strike: true } })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'strike.docx'); + it('underline on: applies underline to inserted text', async () => { + const sessionId = sid('underline-on'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'underline-on-source.docx', + 'This text should be underlined.', + ); + await applyInline(sessionId, target, { underline: 'on' }); + await saveResult(sessionId, 'underline-on.docx'); }); - it('multi-mark: applies bold + italic in a single call', async () => { - const sid = `multi-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be bold and italic'); + it('strike on: applies strike to inserted text', async () => { + const sessionId = sid('strike-on'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'strike-on-source.docx', + 'This text should be struck through.', + ); + await applyInline(sessionId, target, { strike: 'on' }); + await saveResult(sessionId, 'strike-on.docx'); + }); - const result = unwrap( - await client.doc.format.apply({ - sessionId: sid, - target, - inline: { bold: true, italic: true }, - }), + it('multi-mark on: applies bold + italic in one call', async () => { + const sessionId = sid('multi-mark-on'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'multi-mark-on-source.docx', + 'This text should be bold and italic.', ); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'multi-mark.docx'); + await applyInline(sessionId, target, { bold: 'on', italic: 'on' }); + await saveResult(sessionId, 'multi-mark-on.docx'); }); - // --------------------------------------------------------------------------- - // format.fontSize - // --------------------------------------------------------------------------- + it('fontSize numeric: sets point size', async () => { + const sessionId = sid('font-size-num'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'font-size-num-source.docx', + 'This text should be 24pt.', + ); - it('fontSize: sets a numeric point size', async () => { - const sid = `fontSize-num-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be 24pt'); + const result = unwrap(await client.doc.format.fontSize({ sessionId, target, value: 24 })); + assertMutationSuccess(result); - const result = unwrap(await client.doc.format.fontSize({ sessionId: sid, target, value: 24 })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'fontSize-num.docx'); + await saveResult(sessionId, 'font-size-num.docx'); }); - it('fontSize: sets a string size value', async () => { - const sid = `fontSize-str-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be 14pt'); + it('fontSize string: sets point size from string', async () => { + const sessionId = sid('font-size-str'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'font-size-str-source.docx', + 'This text should be 14pt.', + ); + + const result = unwrap(await client.doc.format.fontSize({ sessionId, target, value: '14pt' })); + assertMutationSuccess(result); - const result = unwrap(await client.doc.format.fontSize({ sessionId: sid, target, value: '14pt' })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'fontSize-str.docx'); + await saveResult(sessionId, 'font-size-str.docx'); }); - // --------------------------------------------------------------------------- - // format.fontFamily - // --------------------------------------------------------------------------- + it('fontFamily: sets font family', async () => { + const sessionId = sid('font-family'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'font-family-source.docx', + 'This text should be Courier New.', + ); - it('fontFamily: sets a font family', async () => { - const sid = `fontFamily-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be Courier New'); + const result = unwrap(await client.doc.format.fontFamily({ sessionId, target, value: 'Courier New' })); + assertMutationSuccess(result); - const result = unwrap(await client.doc.format.fontFamily({ sessionId: sid, target, value: 'Courier New' })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'fontFamily.docx'); + await saveResult(sessionId, 'font-family.docx'); }); - // --------------------------------------------------------------------------- - // format.color - // --------------------------------------------------------------------------- + it('color: sets text color', async () => { + const sessionId = sid('color'); + const { target } = await seedBlankFormattableRange(sessionId, 'color-source.docx', 'This text should be red.'); - it('color: sets a hex color', async () => { - const sid = `color-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be red'); + const result = unwrap(await client.doc.format.color({ sessionId, target, value: '#FF0000' })); + assertMutationSuccess(result); - const result = unwrap(await client.doc.format.color({ sessionId: sid, target, value: '#FF0000' })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'color.docx'); + await saveResult(sessionId, 'color.docx'); }); - // --------------------------------------------------------------------------- - // format.align (paragraph-level) - // --------------------------------------------------------------------------- + it('align center: centers paragraph', async () => { + const sessionId = sid('align-center'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'align-center-source.docx', + 'This paragraph should be centered.', + ); - it('align center: centers the paragraph', async () => { - const sid = `align-center-${Date.now()}`; - const target = await setupFormattableText(sid, 'This paragraph should be centered'); + const result = unwrap(await client.doc.format.align({ sessionId, target, alignment: 'center' })); + assertMutationSuccess(result); - const result = unwrap(await client.doc.format.align({ sessionId: sid, target, alignment: 'center' })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'align-center.docx'); + await saveResult(sessionId, 'align-center.docx'); }); - it('align right: right-aligns the paragraph', async () => { - const sid = `align-right-${Date.now()}`; - const target = await setupFormattableText(sid, 'This paragraph should be right-aligned'); + it('align right: right-aligns paragraph', async () => { + const sessionId = sid('align-right'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'align-right-source.docx', + 'This paragraph should be right aligned.', + ); + + const result = unwrap(await client.doc.format.align({ sessionId, target, alignment: 'right' })); + assertMutationSuccess(result); - const result = unwrap(await client.doc.format.align({ sessionId: sid, target, alignment: 'right' })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'align-right.docx'); + await saveResult(sessionId, 'align-right.docx'); }); - it('align justify: justifies the paragraph', async () => { - const sid = `align-justify-${Date.now()}`; - const target = await setupFormattableText( - sid, - 'This paragraph should be fully justified so that both the left and right edges align neatly. When the text is long enough to wrap across several lines, justified alignment becomes visually obvious because each line stretches to fill the full width of the page, distributing extra space evenly between words.', + it('align justify: justifies paragraph', async () => { + const sessionId = sid('align-justify'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'align-justify-source.docx', + 'This paragraph should be fully justified across multiple wrapped lines so the alignment difference is visually obvious in exported output.', ); - const result = unwrap(await client.doc.format.align({ sessionId: sid, target, alignment: 'justify' })); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'align-justify.docx'); + const result = unwrap(await client.doc.format.align({ sessionId, target, alignment: 'justify' })); + assertMutationSuccess(result); + + await saveResult(sessionId, 'align-justify.docx'); }); - // --------------------------------------------------------------------------- - // Combined: multiple value formats on the same range - // --------------------------------------------------------------------------- + it('combined value formats: fontSize + fontFamily + color on same range', async () => { + const sessionId = sid('combined-values'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'combined-values-source.docx', + 'This text should be 18pt Georgia in blue.', + ); - it('combined: fontSize + fontFamily + color on the same text', async () => { - const sid = `combined-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should be 18pt Georgia in blue'); + const sizeResult = unwrap(await client.doc.format.fontSize({ sessionId, target, value: 18 })); + assertMutationSuccess(sizeResult); - const sizeResult = unwrap(await client.doc.format.fontSize({ sessionId: sid, target, value: 18 })); - expect(sizeResult.receipt?.success).toBe(true); + const familyResult = unwrap(await client.doc.format.fontFamily({ sessionId, target, value: 'Georgia' })); + assertMutationSuccess(familyResult); - const familyResult = unwrap(await client.doc.format.fontFamily({ sessionId: sid, target, value: 'Georgia' })); - expect(familyResult.receipt?.success).toBe(true); + const colorResult = unwrap(await client.doc.format.color({ sessionId, target, value: '#0000FF' })); + assertMutationSuccess(colorResult); - const colorResult = unwrap(await client.doc.format.color({ sessionId: sid, target, value: '#0000FF' })); - expect(colorResult.receipt?.success).toBe(true); - await saveResult(sid, 'combined.docx'); + await saveResult(sessionId, 'combined-values.docx'); }); - // --------------------------------------------------------------------------- - // dryRun: verify no mutation occurs - // --------------------------------------------------------------------------- - - it('dryRun: format.apply returns success without mutating', async () => { - const sid = `dryRun-${Date.now()}`; - const target = await setupFormattableText(sid, 'This text should not actually change'); + it('dryRun format.apply: reports success without mutating', async () => { + const sessionId = sid('dry-run'); + const { target } = await seedBlankFormattableRange( + sessionId, + 'dry-run-source.docx', + 'This text should remain unchanged.', + ); const result = unwrap( await client.doc.format.apply({ - sessionId: sid, + sessionId, target, - inline: { bold: true }, + inline: { bold: 'on' }, dryRun: true, }), ); - expect(result.receipt?.success).toBe(true); - await saveResult(sid, 'dryRun.docx'); + assertMutationSuccess(result); + await saveResult(sessionId, 'dry-run.docx'); + }); + + it('directive cycle: source inherits bold ON, then off -> clear -> on', async () => { + const seedSessionId = sid('directive-cycle-seed'); + const probe = 'Directive cycle probe text.'; + const { pattern } = await seedBlankFormattableRange(seedSessionId, '.directive-cycle-base.docx', probe); + + const sourceDoc = outPath('directive-cycle-source.docx'); + const stylesReceipt = await applyRunDocDefaultsPatch( + outPath('.directive-cycle-base.docx'), + { bold: true }, + sourceDoc, + ); + expect(stylesReceipt.after?.bold).toBe('on'); + + const sessionId = sid('directive-cycle'); + await client.doc.open({ doc: sourceDoc, sessionId }); + + const sourceMatch = await queryFirstTextMatch(sessionId, pattern); + const sourceStyles = firstRunStyles(sourceMatch); + expect(['on', 'clear']).toContain(sourceStyles.direct.bold); + expect(sourceStyles.effective.bold).toBe(true); + const target = await findFirstTextTarget(sessionId, pattern); + + await applyInline(sessionId, target, { bold: 'off' }); + const offMatch = await queryFirstTextMatch(sessionId, pattern); + const offStyles = firstRunStyles(offMatch); + expect(typeof offStyles.effective.bold).toBe('boolean'); + await saveResult(sessionId, 'directive-cycle-off.docx'); + + await applyInline(sessionId, target, { bold: 'clear' }); + const clearMatch = await queryFirstTextMatch(sessionId, pattern); + const clearStyles = firstRunStyles(clearMatch); + expect(typeof clearStyles.effective.bold).toBe('boolean'); + await saveResult(sessionId, 'directive-cycle-clear.docx'); + + await applyInline(sessionId, target, { bold: 'on' }); + const onMatch = await queryFirstTextMatch(sessionId, pattern); + const onStyles = firstRunStyles(onMatch); + expect(onStyles.direct.bold).toBe('on'); + expect(onStyles.effective.bold).toBe(true); + await saveResult(sessionId, 'directive-cycle-on.docx'); + }); + + it('query.match meta: text selector sets effectiveResolved=true', async () => { + const sessionId = sid('meta-text'); + await openFixtureDoc(sessionId, 'meta-text-source.docx'); + + const pattern = await firstLinePattern(sessionId); + const match = await queryFirstTextMatch(sessionId, pattern); + expect(match.meta?.effectiveResolved).toBe(true); + }); + + it('query.match meta: node selector sets effectiveResolved=false', async () => { + const sessionId = sid('meta-node'); + await openFixtureDoc(sessionId, 'meta-node-source.docx'); + + const nodeMatch = unwrap( + await client.doc.query.match({ + sessionId, + select: { type: 'node', nodeType: 'paragraph' }, + require: 'first', + }), + ); + + expect(Array.isArray(nodeMatch?.items)).toBe(true); + expect(nodeMatch.items.length).toBeGreaterThan(0); + expect(nodeMatch.items[0]?.matchKind).toBe('node'); + expect(nodeMatch.meta?.effectiveResolved).toBe(false); + }); + + it('node-ref mutation: mutations.apply format.apply bold on', async () => { + const sessionId = sid('node-ref-bold-on'); + await openFixtureDoc(sessionId, 'node-ref-source.docx'); + + const nodeMatch = unwrap( + await client.doc.query.match({ + sessionId, + select: { type: 'node', nodeType: 'paragraph' }, + require: 'first', + }), + ); + + const paragraphRef = nodeMatch?.items?.[0]?.handle?.ref as string | undefined; + expect(typeof paragraphRef).toBe('string'); + if (!paragraphRef) { + throw new Error('Could not resolve paragraph ref from node selector.'); + } + + const applyResult = unwrap( + await client.doc.mutations.apply({ + sessionId, + expectedRevision: nodeMatch.evaluatedRevision, + atomic: true, + changeMode: 'direct', + steps: [ + { + id: `node-ref-bold-on-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`, + op: 'format.apply', + where: { by: 'ref', ref: paragraphRef }, + args: { inline: { bold: 'on' } }, + }, + ], + }), + ); + expect(applyResult?.success).toBe(true); + + const pattern = await firstLinePattern(sessionId); + const updatedMatch = await queryFirstTextMatch(sessionId, pattern); + const updatedStyles = firstRunStyles(updatedMatch); + expect(updatedStyles.direct.bold).toBe('on'); + expect(updatedStyles.effective.bold).toBe(true); + + await saveResult(sessionId, 'node-ref-bold-on.docx'); }); }); From abf545af94b4c93b4523c080ea213791527cee77 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 26 Feb 2026 19:39:38 -0800 Subject: [PATCH 2/2] fix(document-api): normalize OFF toggles and re-export format helpers --- .../langs/python/superdoc/helpers/__init__.py | 23 ++++++- .../python/tests/test_helpers_reexports.py | 57 ++++++++++++++++ .../plan-engine/mark-directives.test.ts | 66 +++++++++++++++++++ .../plan-engine/mark-directives.ts | 12 +++- .../plan-engine/match-style-helpers.test.ts | 26 ++++++++ .../plan-engine/match-style-helpers.ts | 8 +-- .../plan-engine/remap-length.test.ts | 2 +- 7 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 packages/sdk/langs/python/tests/test_helpers_reexports.py create mode 100644 packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.test.ts diff --git a/packages/sdk/langs/python/superdoc/helpers/__init__.py b/packages/sdk/langs/python/superdoc/helpers/__init__.py index f77299524b..473657d524 100644 --- a/packages/sdk/langs/python/superdoc/helpers/__init__.py +++ b/packages/sdk/langs/python/superdoc/helpers/__init__.py @@ -3,11 +3,32 @@ These files are NOT generated and will not be overwritten by codegen. """ -from .format import format_bold, format_italic, format_underline, format_strikethrough +from .format import ( + clear_bold, + clear_italic, + clear_strikethrough, + clear_underline, + format_bold, + format_italic, + format_strikethrough, + format_underline, + unformat_bold, + unformat_italic, + unformat_strikethrough, + unformat_underline, +) __all__ = [ + "clear_bold", + "clear_italic", + "clear_underline", + "clear_strikethrough", "format_bold", "format_italic", "format_underline", "format_strikethrough", + "unformat_bold", + "unformat_italic", + "unformat_underline", + "unformat_strikethrough", ] diff --git a/packages/sdk/langs/python/tests/test_helpers_reexports.py b/packages/sdk/langs/python/tests/test_helpers_reexports.py new file mode 100644 index 0000000000..36904ca382 --- /dev/null +++ b/packages/sdk/langs/python/tests/test_helpers_reexports.py @@ -0,0 +1,57 @@ +""" +Failing tests that expose missing re-exports in ``superdoc.helpers``. + +The helper module defines ``unformat_*`` and ``clear_*`` functions in +``superdoc.helpers.format``, but package-level imports from +``superdoc.helpers`` currently fail. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +def test_superdoc_helpers_reexports_unformat_helpers(): + from superdoc.helpers import ( + unformat_bold, + unformat_italic, + unformat_underline, + unformat_strikethrough, + ) + + assert callable(unformat_bold) + assert callable(unformat_italic) + assert callable(unformat_underline) + assert callable(unformat_strikethrough) + + +def test_superdoc_helpers_reexports_clear_helpers(): + from superdoc.helpers import ( + clear_bold, + clear_italic, + clear_underline, + clear_strikethrough, + ) + + assert callable(clear_bold) + assert callable(clear_italic) + assert callable(clear_underline) + assert callable(clear_strikethrough) + + +def test_superdoc_helpers_all_includes_new_helpers(): + from superdoc import helpers + + expected = { + "unformat_bold", + "unformat_italic", + "unformat_underline", + "unformat_strikethrough", + "clear_bold", + "clear_italic", + "clear_underline", + "clear_strikethrough", + } + + assert expected.issubset(set(helpers.__all__)) diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.test.ts new file mode 100644 index 0000000000..7d063b8b01 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { applyDirectiveToMarks, deriveToggleState } from './mark-directives.js'; + +function mockMark(name: string, attrs: Record = {}) { + return { + type: { + name, + create(nextAttrs?: Record | null) { + return mockMark(name, nextAttrs ?? {}); + }, + }, + attrs, + eq(other: any) { + if (!other || other.type?.name !== name) return false; + const keys = new Set([...Object.keys(attrs), ...Object.keys(other.attrs || {})]); + for (const key of keys) { + if (attrs[key] !== other.attrs?.[key]) return false; + } + return true; + }, + }; +} + +function mockMarkType(name: string) { + return { + create(attrs?: Record | null) { + return mockMark(name, attrs ?? {}); + }, + }; +} + +describe('deriveToggleState', () => { + it.each(['bold', 'italic', 'strike'] as const)('treats boolean false as OFF for %s', (markKey) => { + const state = deriveToggleState([mockMark(markKey, { value: false })] as any, markKey); + expect(state).toBe('off'); + }); + + it.each(['bold', 'italic', 'strike'] as const)('treats numeric 0 as OFF for %s', (markKey) => { + const state = deriveToggleState([mockMark(markKey, { value: 0 })] as any, markKey); + expect(state).toBe('off'); + }); +}); + +describe('applyDirectiveToMarks', () => { + it('does not no-op when existing mark stores OFF as boolean false', () => { + const boldOffBoolean = mockMark('bold', { value: false }); + const markType = mockMarkType('bold'); + + const result = applyDirectiveToMarks([boldOffBoolean] as any, 'bold', 'on', markType as any); + + expect(result).toHaveLength(1); + expect(result[0].type.name).toBe('bold'); + expect(result[0].attrs.value).toBeUndefined(); + }); + + it('does not no-op when existing mark stores OFF as numeric 0', () => { + const boldOffNumeric = mockMark('bold', { value: 0 }); + const markType = mockMarkType('bold'); + + const result = applyDirectiveToMarks([boldOffNumeric] as any, 'bold', 'on', markType as any); + + expect(result).toHaveLength(1); + expect(result[0].type.name).toBe('bold'); + expect(result[0].attrs.value).toBeUndefined(); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.ts b/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.ts index 48d4fe99cf..f094f18fd2 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/mark-directives.ts @@ -33,8 +33,18 @@ interface ToggleMarkSpec { createOn: (markType: PmMarkType, existingMark?: PmMark) => PmMark; } +/** + * Core-4 toggle OFF values seen across import paths: + * - canonical string token: `'0'` + * - legacy/strict parser boolean token: `false` + * - numeric token from permissive decoders: `0` + */ +export function isSimpleToggleOffValue(value: unknown): boolean { + return value === '0' || value === false || value === 0; +} + function isSimpleToggleOff(mark: PmMark): boolean { - return mark.attrs.value === '0'; + return isSimpleToggleOffValue(mark.attrs.value); } function isSimpleToggleOn(mark: PmMark): boolean { diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.test.ts index 921590f683..8f1d38c7fa 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.test.ts @@ -340,6 +340,32 @@ describe('toMatchStyle — cascade resolution', () => { expect(inlineRpr.bold).toBe(true); expect(inlineRpr.styleId).toBe('Emphasis'); }); + + it('passes boolean false toggle marks as false in inline run properties', () => { + resolveRunPropertiesMock.mockClear(); + resolveRunPropertiesMock.mockReturnValue({}); + + // bold OFF is explicit, while others are clear (which triggers cascade resolution) + const marks = [mockMark('bold', { value: false })]; + toMatchStyle(marks as any, baseCascadeContext); + + expect(resolveRunPropertiesMock).toHaveBeenCalledTimes(1); + const [, inlineRpr] = resolveRunPropertiesMock.mock.calls[0]; + expect(inlineRpr.bold).toBe(false); + }); + + it('passes numeric 0 toggle marks as false in inline run properties', () => { + resolveRunPropertiesMock.mockClear(); + resolveRunPropertiesMock.mockReturnValue({}); + + // bold OFF is explicit, while others are clear (which triggers cascade resolution) + const marks = [mockMark('bold', { value: 0 })]; + toMatchStyle(marks as any, baseCascadeContext); + + expect(resolveRunPropertiesMock).toHaveBeenCalledTimes(1); + const [, inlineRpr] = resolveRunPropertiesMock.mock.calls[0]; + expect(inlineRpr.bold).toBe(false); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.ts index 1bb12baf00..651e25e510 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/match-style-helpers.ts @@ -15,7 +15,7 @@ import { resolveRunProperties } from '@superdoc/style-engine/ooxml'; import type { OoxmlResolverParams, RunProperties, ParagraphProperties } from '@superdoc/style-engine/ooxml'; import type { CapturedRun } from './style-resolver.js'; import { planError } from './errors.js'; -import { deriveToggleState } from './mark-directives.js'; +import { deriveToggleState, isSimpleToggleOffValue } from './mark-directives.js'; /** A PM mark as visible on CapturedRun.marks — minimal shape for style extraction. */ type PmMark = CapturedRun['marks'][number]; @@ -43,13 +43,13 @@ function buildInlineRpr(marks: readonly PmMark[]): RunProperties { for (const mark of marks) { switch (mark.type.name) { case 'bold': - rpr.bold = mark.attrs.value !== '0'; + rpr.bold = !isSimpleToggleOffValue(mark.attrs.value); break; case 'italic': - rpr.italic = mark.attrs.value !== '0'; + rpr.italic = !isSimpleToggleOffValue(mark.attrs.value); break; case 'strike': - rpr.strike = mark.attrs.value !== '0'; + rpr.strike = !isSimpleToggleOffValue(mark.attrs.value); break; case 'underline': { const ut = mark.attrs.underlineType; diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/remap-length.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/remap-length.test.ts index 1e0c00c1e3..50b5b87dba 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/remap-length.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/remap-length.test.ts @@ -264,7 +264,7 @@ describe('remap correctness: format.apply uses mapping', () => { id: 'remap-format', op: 'format.apply', where: { by: 'select', select: { type: 'text', pattern: 'some text' }, require: 'exactlyOne' }, - args: { inline: { bold: true } }, + args: { inline: { bold: 'on' } }, }; // Simulate +10 offset from prior steps