diff --git a/apps/mcp/src/generated/catalog.ts b/apps/mcp/src/generated/catalog.ts index b40215a3fe..7b994255c4 100644 --- a/apps/mcp/src/generated/catalog.ts +++ b/apps/mcp/src/generated/catalog.ts @@ -1,6021 +1,5192 @@ // Auto-generated from packages/sdk/tools/catalog.json // Do not edit manually — re-run generate:all to update. export const MCP_TOOL_CATALOG = { - "contractVersion": "0.1.0", - "generatedAt": null, - "toolCount": 10, - "tools": [ + contractVersion: '0.1.0', + generatedAt: null, + toolCount: 10, + tools: [ { - "toolName": "superdoc_get_content", - "description": "Read document content in various formats. Call this first in any workflow to understand document structure before making edits. Action \"blocks\" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. When you need to evaluate or rewrite existing paragraphs or clauses, prefer action \"blocks\" with includeText:true so you can identify the correct block and then target it by nodeId. Action \"text\" and \"markdown\" return the full document as plain text or Markdown. Action \"html\" returns HTML. Action \"info\" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. The \"blocks\" action supports pagination via \"offset\" and \"limit\", and filtering via \"nodeTypes\". Other actions ignore these parameters. This tool never modifies the document. Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.\n\nEXAMPLES:\n 1. {\"action\":\"blocks\"}\n 2. {\"action\":\"blocks\",\"includeText\":true,\"offset\":0,\"limit\":20}\n 3. {\"action\":\"blocks\",\"offset\":0,\"limit\":20,\"nodeTypes\":[\"heading\",\"paragraph\"]}\n 4. {\"action\":\"text\"}\n 5. {\"action\":\"info\"}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "blocks", - "extract", - "html", - "info", - "markdown", - "text" - ], - "description": "The action to perform. One of: blocks, extract, html, info, markdown, text." - }, - "unflattenLists": { - "type": "boolean", - "description": "When true, flattens nested list structures in output. Default: false. Only for action 'html'. Omit for other actions." - }, - "offset": { - "type": "number", - "minimum": 0, - "description": "Number of blocks to skip. Default: 0. Only for action 'blocks'. Omit for other actions." - }, - "limit": { - "type": "number", - "minimum": 1, - "description": "Maximum blocks to return. Omit for all blocks. Only for action 'blocks'. Omit for other actions." - }, - "nodeTypes": { - "type": "array", - "items": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt" - ] + toolName: 'superdoc_get_content', + description: + 'Read document content in various formats. Call this first in any workflow to understand document structure before making edits. Action "blocks" returns structured block data with nodeId, nodeType, textPreview, optional full text when includeText:true, formatting properties (fontFamily, fontSize, color, bold, underline, alignment), and ref handles for immediate use with superdoc_edit or superdoc_format. When you need to evaluate or rewrite existing paragraphs or clauses, prefer action "blocks" with includeText:true so you can identify the correct block and then target it by nodeId. Action "text" and "markdown" return the full document as plain text or Markdown. Action "html" returns HTML. Action "info" returns document metadata: word count, paragraph count, page count, outline, available styles, and capability flags. The "blocks" action supports pagination via "offset" and "limit", and filtering via "nodeTypes". Other actions ignore these parameters. This tool never modifies the document. Do NOT call superdoc_edit or superdoc_format without first reading blocks to get valid refs and formatting reference values.\n\nEXAMPLES:\n 1. {"action":"blocks"}\n 2. {"action":"blocks","includeText":true,"offset":0,"limit":20}\n 3. {"action":"blocks","offset":0,"limit":20,"nodeTypes":["heading","paragraph"]}\n 4. {"action":"text"}\n 5. {"action":"info"}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['blocks', 'extract', 'html', 'info', 'markdown', 'text'], + description: 'The action to perform. One of: blocks, extract, html, info, markdown, text.', + }, + unflattenLists: { + type: 'boolean', + description: + "When true, flattens nested list structures in output. Default: false. Only for action 'html'. Omit for other actions.", + }, + offset: { + type: 'number', + minimum: 0, + description: "Number of blocks to skip. Default: 0. Only for action 'blocks'. Omit for other actions.", + }, + limit: { + type: 'number', + minimum: 1, + description: + "Maximum blocks to return. Omit for all blocks. Only for action 'blocks'. Omit for other actions.", + }, + nodeTypes: { + type: 'array', + items: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + ], }, - "description": "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types. Only for action 'blocks'. Omit for other actions." + description: + "Filter by block types (e.g. ['paragraph', 'heading']). Omit for all types. Only for action 'blocks'. Omit for other actions.", + }, + includeText: { + type: 'boolean', + description: + "When true, includes the full flattened block text in each block entry. Only for action 'blocks'. Omit for other actions.", }, - "includeText": { - "type": "boolean", - "description": "When true, includes the full flattened block text in each block entry. Only for action 'blocks'. Omit for other actions." - } }, - "required": [ - "action" - ], - "additionalProperties": false + required: ['action'], + additionalProperties: false, }, - "mutates": false, - "operations": [ + mutates: false, + operations: [ { - "operationId": "doc.getText", - "intentAction": "text" + operationId: 'doc.getText', + intentAction: 'text', }, { - "operationId": "doc.getMarkdown", - "intentAction": "markdown" + operationId: 'doc.getMarkdown', + intentAction: 'markdown', }, { - "operationId": "doc.getHtml", - "intentAction": "html" + operationId: 'doc.getHtml', + intentAction: 'html', }, { - "operationId": "doc.info", - "intentAction": "info" + operationId: 'doc.info', + intentAction: 'info', }, { - "operationId": "doc.extract", - "intentAction": "extract" + operationId: 'doc.extract', + intentAction: 'extract', }, { - "operationId": "doc.blocks.list", - "intentAction": "blocks" - } - ] + operationId: 'doc.blocks.list', + intentAction: 'blocks', + }, + ], }, { - "toolName": "superdoc_edit", - "description": "The primary tool for inserting content into documents. ALWAYS use action \"insert\" with type \"markdown\" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with \"target\" (a BlockNodeAddress like {kind:\"block\", nodeType, nodeId}) and \"placement\" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts \"inline\" (fontFamily, fontSize, bold, underline, color), \"alignment\", and \"scope\" in the same step. Use scope: \"block\" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a \"ref\" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:\"block\", nodeType, nodeId} from superdoc_get_content action \"blocks\" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports \"dryRun\" to preview changes and \"changeMode: tracked\" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build \"target\" objects manually when a ref is available; prefer \"ref\" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {\"action\":\"insert\",\"type\":\"markdown\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"placement\":\"before\",\"value\":\"# Executive Summary\\n\\nThis agreement sets forth the principal terms...\"}\n 2. {\"action\":\"insert\",\"type\":\"markdown\",\"value\":\"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*.\"}\n 3. {\"action\":\"replace\",\"ref\":\"\",\"text\":\"new text here\"}\n 4. {\"action\":\"delete\",\"ref\":\"\"}\n 5. {\"action\":\"undo\"}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "delete", - "insert", - "redo", - "replace", - "undo" - ], - "description": "The action to perform. One of: delete, insert, redo, replace, undo." - }, - "force": { - "type": "boolean", - "description": "Bypass confirmation checks." - }, - "changeMode": { - "type": "string", - "enum": [ - "direct", - "tracked" - ], - "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." - }, - "dryRun": { - "type": "boolean", - "description": "Preview the result without applying changes." - }, - "target": { - "oneOf": [ + toolName: 'superdoc_edit', + description: + 'The primary tool for inserting content into documents. ALWAYS use action "insert" with type "markdown" to create headings, paragraphs, or any block content: this is faster and creates proper document structure in one call. Do NOT use superdoc_create for headings or paragraphs. The markdown parser creates headings from # markers (# = Heading1, ## = Heading2), bold from **text**, italic from *text*, and numbered/bullet lists. Position markdown inserts with "target" (a BlockNodeAddress like {kind:"block", nodeType, nodeId}) and "placement" (before, after, insideStart, insideEnd). Without a target, content appends at the end of the document. IMPORTANT: After a markdown insert, analyze the document context (what kind of document, how titles and body text are styled) and follow up with ONE superdoc_mutations call to format inserted blocks so they look like they belong. Each format.apply step accepts "inline" (fontFamily, fontSize, bold, underline, color), "alignment", and "scope" in the same step. Use scope: "block" so formatting covers the entire paragraph. Copy the exact property values from the existing get_content blocks (fontFamily, fontSize, color, alignment, bold, underline). Do NOT invent values: use what the blocks show. Also supports replace, delete, and undo/redo. For replace and delete, pass a "ref" from superdoc_search or superdoc_get_content blocks. A search ref covers only the matched substring; a block ref covers the entire block text, so use block refs when rewriting or shortening whole paragraphs. For multi-step redlines or whole-clause rewrites, prefer superdoc_mutations with where:{by:"block", nodeType, nodeId} from superdoc_get_content action "blocks" includeText:true rather than relying on text selectors. Refs expire after any mutation; always re-search before the next edit. For 2+ edits that must succeed or fail atomically, use superdoc_mutations instead. Supports "dryRun" to preview changes and "changeMode: tracked" to record edits as tracked changes (not supported for markdown/html inserts). Do NOT build "target" objects manually when a ref is available; prefer "ref" for simpler, more reliable targeting.\n\nEXAMPLES:\n 1. {"action":"insert","type":"markdown","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"placement":"before","value":"# Executive Summary\\n\\nThis agreement sets forth the principal terms..."}\n 2. {"action":"insert","type":"markdown","value":"# Section Title\\n\\nParagraph content here.\\n\\n# Another Section\\n\\nMore content with **bold** and *italic*."}\n 3. {"action":"replace","ref":"","text":"new text here"}\n 4. {"action":"delete","ref":""}\n 5. {"action":"undo"}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['delete', 'insert', 'redo', 'replace', 'undo'], + description: 'The action to perform. One of: delete, insert, redo, replace, undo.', + }, + force: { + type: 'boolean', + description: 'Bypass confirmation checks.', + }, + changeMode: { + type: 'string', + enum: ['direct', 'tracked'], + description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + }, + dryRun: { + type: 'boolean', + description: 'Preview the result without applying changes.', + }, + target: { + oneOf: [ { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/BlockNodeAddress", - "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." + $ref: '#/$defs/BlockNodeAddress', + description: + "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", }, { - "oneOf": [ + oneOf: [ { - "type": "object", - "properties": { - "kind": { - "const": "selection", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'selection', + type: 'string', }, - "start": { - "oneOf": [ + start: { + oneOf: [ { - "type": "object", - "properties": { - "kind": { - "const": "text", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'text', + type: 'string', }, - "blockId": { - "type": "string" + blockId: { + type: 'string', + }, + offset: { + type: 'number', }, - "offset": { - "type": "number" - } }, - "required": [ - "kind", - "blockId", - "offset" - ] + required: ['kind', 'blockId', 'offset'], }, { - "type": "object", - "properties": { - "kind": { - "const": "nodeEdge", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'nodeEdge', + type: 'string', }, - "node": { - "type": "object", - "properties": { - "kind": { - "const": "block", - "type": "string" + node: { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "table", - "tableOfContents", - "sdt", - "image" - ] + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "required": [ - "kind", - "nodeType", - "nodeId" - ] + required: ['kind', 'nodeType', 'nodeId'], + }, + edge: { + enum: ['before', 'after'], }, - "edge": { - "enum": [ - "before", - "after" - ] - } }, - "required": [ - "kind", - "node", - "edge" - ] - } + required: ['kind', 'node', 'edge'], + }, ], - "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." + description: + "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", }, - "end": { - "oneOf": [ + end: { + oneOf: [ { - "type": "object", - "properties": { - "kind": { - "const": "text", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'text', + type: 'string', + }, + blockId: { + type: 'string', }, - "blockId": { - "type": "string" + offset: { + type: 'number', }, - "offset": { - "type": "number" - } }, - "required": [ - "kind", - "blockId", - "offset" - ] + required: ['kind', 'blockId', 'offset'], }, { - "type": "object", - "properties": { - "kind": { - "const": "nodeEdge", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'nodeEdge', + type: 'string', }, - "node": { - "type": "object", - "properties": { - "kind": { - "const": "block", - "type": "string" + node: { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "table", - "tableOfContents", - "sdt", - "image" - ] + nodeType: { + enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + }, + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "required": [ - "kind", - "nodeType", - "nodeId" - ] + required: ['kind', 'nodeType', 'nodeId'], + }, + edge: { + enum: ['before', 'after'], }, - "edge": { - "enum": [ - "before", - "after" - ] - } }, - "required": [ - "kind", - "node", - "edge" - ] - } + required: ['kind', 'node', 'edge'], + }, ], - "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." - } + description: + "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + }, }, - "required": [ - "kind", - "start", - "end" - ] + required: ['kind', 'start', 'end'], }, { - "type": "object", - "properties": { - "kind": { - "const": "block", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt" - ] + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + ], + }, + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "required": [ - "kind", - "nodeType", - "nodeId" - ] + required: ['kind', 'nodeType', 'nodeId'], }, { - "type": "object", - "properties": { - "kind": { - "const": "selection", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'selection', + type: 'string', }, - "start": { - "oneOf": [ + start: { + oneOf: [ { - "type": "object", - "properties": { - "kind": { - "const": "text", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'text', + type: 'string', + }, + blockId: { + type: 'string', }, - "blockId": { - "type": "string" + offset: { + type: 'number', }, - "offset": { - "type": "number" - } }, - "required": [ - "kind", - "blockId", - "offset" - ] + required: ['kind', 'blockId', 'offset'], }, { - "type": "object", - "properties": { - "kind": { - "const": "nodeEdge", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'nodeEdge', + type: 'string', }, - "node": { - "type": "object", - "properties": { - "kind": { - "const": "block", - "type": "string" + node: { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "table", - "tableOfContents", - "sdt", - "image" - ] + nodeType: { + enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + }, + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "required": [ - "kind", - "nodeType", - "nodeId" - ] + required: ['kind', 'nodeType', 'nodeId'], + }, + edge: { + enum: ['before', 'after'], }, - "edge": { - "enum": [ - "before", - "after" - ] - } }, - "required": [ - "kind", - "node", - "edge" - ] - } + required: ['kind', 'node', 'edge'], + }, ], - "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." + description: + "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", }, - "end": { - "oneOf": [ + end: { + oneOf: [ { - "type": "object", - "properties": { - "kind": { - "const": "text", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'text', + type: 'string', + }, + blockId: { + type: 'string', }, - "blockId": { - "type": "string" + offset: { + type: 'number', }, - "offset": { - "type": "number" - } }, - "required": [ - "kind", - "blockId", - "offset" - ] + required: ['kind', 'blockId', 'offset'], }, { - "type": "object", - "properties": { - "kind": { - "const": "nodeEdge", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'nodeEdge', + type: 'string', }, - "node": { - "type": "object", - "properties": { - "kind": { - "const": "block", - "type": "string" + node: { + type: 'object', + properties: { + kind: { + const: 'block', + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "table", - "tableOfContents", - "sdt", - "image" - ] + nodeType: { + enum: ['paragraph', 'heading', 'table', 'tableOfContents', 'sdt', 'image'], + }, + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "required": [ - "kind", - "nodeType", - "nodeId" - ] + required: ['kind', 'nodeType', 'nodeId'], + }, + edge: { + enum: ['before', 'after'], }, - "edge": { - "enum": [ - "before", - "after" - ] - } }, - "required": [ - "kind", - "node", - "edge" - ] - } + required: ['kind', 'node', 'edge'], + }, ], - "description": "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries." - } + description: + "A point in the document. Use {kind:'text', blockId, offset} for character positions or {kind:'nodeEdge', node:{kind:'block', nodeType, nodeId}, edge:'before'|'after'} for block boundaries.", + }, }, - "required": [ - "kind", - "start", - "end" - ] - } - ] - } + required: ['kind', 'start', 'end'], + }, + ], + }, ], - "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." + description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", }, { - "$ref": "#/$defs/SelectionTarget", - "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." - } + $ref: '#/$defs/SelectionTarget', + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", + }, ], - "description": "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}." - }, - "value": { - "type": "string", - "description": "Text content to insert. Only for action 'insert'. Omit for other actions." - }, - "type": { - "type": "string", - "description": "Content format: 'text' (default), 'markdown', or 'html'. Only for action 'insert'. Omit for other actions.", - "enum": [ - "text", - "markdown", - "html" - ] - }, - "ref": { - "oneOf": [ + description: "Block address for structural insertion: {kind:'block', nodeType:'...', nodeId:'...'}.", + }, + value: { + type: 'string', + description: "Text content to insert. Only for action 'insert'. Omit for other actions.", + }, + type: { + type: 'string', + description: + "Content format: 'text' (default), 'markdown', or 'html'. Only for action 'insert'. Omit for other actions.", + enum: ['text', 'markdown', 'html'], + }, + ref: { + oneOf: [ { - "oneOf": [ + oneOf: [ { - "type": "string", - "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." + type: 'string', + description: + 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', }, { - "type": "string", - "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting." - } + type: 'string', + description: + "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting.", + }, ], - "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." + description: + 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', }, { - "type": "string", - "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting." - } + type: 'string', + description: + "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting.", + }, ], - "description": "Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object." + description: + 'Handle ref from superdoc_search result (pass handle.ref value directly). Preferred over building a target object.', }, - "content": { - "oneOf": [ + content: { + oneOf: [ { - "oneOf": [ + oneOf: [ { - "type": "object" + type: 'object', }, { - "type": "array", - "items": { - "type": "object" - } - } + type: 'array', + items: { + type: 'object', + }, + }, ], - "description": "Document fragment to insert (structured content)." + description: 'Document fragment to insert (structured content).', }, { - "oneOf": [ + oneOf: [ { - "type": "object", - "properties": {} + type: 'object', + properties: {}, }, { - "type": "array", - "items": { - "type": "object", - "properties": {} - } - } + type: 'array', + items: { + type: 'object', + properties: {}, + }, + }, ], - "description": "Document fragment to replace with (structured content)." - } - ], - "description": "Document fragment to insert (structured content). Only for actions 'insert', 'replace'. Omit for other actions." - }, - "placement": { - "enum": [ - "before", - "after", - "insideStart", - "insideEnd" + description: 'Document fragment to replace with (structured content).', + }, ], - "description": "Where to place content relative to target: 'before', 'after', 'insideStart', or 'insideEnd'. Only for action 'insert'. Omit for other actions." + description: + "Document fragment to insert (structured content). Only for actions 'insert', 'replace'. Omit for other actions.", }, - "nestingPolicy": { - "oneOf": [ + placement: { + enum: ['before', 'after', 'insideStart', 'insideEnd'], + description: + "Where to place content relative to target: 'before', 'after', 'insideStart', or 'insideEnd'. Only for action 'insert'. Omit for other actions.", + }, + nestingPolicy: { + oneOf: [ { - "type": "object", - "properties": { - "tables": { - "enum": [ - "forbid", - "allow" - ] - } + type: 'object', + properties: { + tables: { + enum: ['forbid', 'allow'], + }, }, - "additionalProperties": false, - "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables." + additionalProperties: false, + description: "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables.", }, { - "type": "object", - "properties": { - "tables": { - "enum": [ - "forbid", - "allow" - ] - } + type: 'object', + properties: { + tables: { + enum: ['forbid', 'allow'], + }, }, - "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables." - } + description: "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables.", + }, ], - "description": "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables. Only for actions 'insert', 'replace'. Omit for other actions." + description: + "Controls nesting behavior. tables: 'allow' permits inserting tables inside other tables. Only for actions 'insert', 'replace'. Omit for other actions.", + }, + text: { + type: 'string', + description: "Replacement text content. Only for action 'replace'. Omit for other actions.", }, - "text": { - "type": "string", - "description": "Replacement text content. Only for action 'replace'. Omit for other actions." + behavior: { + $ref: '#/$defs/DeleteBehavior', + description: + "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions.", }, - "behavior": { - "$ref": "#/$defs/DeleteBehavior", - "description": "Delete behavior: 'selection' (default) or 'exact'. Only for action 'delete'. Omit for other actions." - } }, - "required": [ - "action" - ], - "additionalProperties": false + required: ['action'], + additionalProperties: false, }, - "mutates": true, - "operations": [ + mutates: true, + operations: [ { - "operationId": "doc.insert", - "intentAction": "insert", - "requiredOneOf": [ - [ - "target", - "value" - ], - [ - "ref", - "value" - ], - [ - "value" - ], - [ - "content" - ] - ] + operationId: 'doc.insert', + intentAction: 'insert', + requiredOneOf: [['target', 'value'], ['ref', 'value'], ['value'], ['content']], }, { - "operationId": "doc.replace", - "intentAction": "replace", - "requiredOneOf": [ - [ - "target", - "text" - ], - [ - "ref", - "text" - ], - [ - "target", - "content" - ], - [ - "ref", - "content" - ] - ] + operationId: 'doc.replace', + intentAction: 'replace', + requiredOneOf: [ + ['target', 'text'], + ['ref', 'text'], + ['target', 'content'], + ['ref', 'content'], + ], }, { - "operationId": "doc.delete", - "intentAction": "delete", - "requiredOneOf": [ - [ - "target" - ], - [ - "ref" - ] - ] + operationId: 'doc.delete', + intentAction: 'delete', + requiredOneOf: [['target'], ['ref']], }, { - "operationId": "doc.history.undo", - "intentAction": "undo" + operationId: 'doc.history.undo', + intentAction: 'undo', }, { - "operationId": "doc.history.redo", - "intentAction": "redo" - } - ] + operationId: 'doc.history.redo', + intentAction: 'redo', + }, + ], }, { - "toolName": "superdoc_format", - "description": "Change text and paragraph formatting. To format multiple items at once, use superdoc_mutations with format.apply steps instead of calling this tool repeatedly. Use require \"all\" with a node selector to format every heading or paragraph in one batch. Use this tool for single-item formatting when you have a valid ref or nodeId. Action \"inline\" applies character formatting (bold, italic, underline, color, fontSize, fontFamily, highlight, strike, vertAlign) to a text range via \"ref\". Action \"set_style\" applies a named paragraph style by styleId (get available styles from superdoc_get_content info). Actions \"set_alignment\", \"set_indentation\", \"set_spacing\", \"set_direction\", and \"set_flow_options\" change paragraph-level properties and require a block target: {kind:\"block\", nodeType:\"paragraph\", nodeId:\"\"}, NOT a ref. Use \"set_flow_options\" with pageBreakBefore:true to start a paragraph on a new page. Supports \"dryRun\" and \"changeMode: tracked\" for inline formatting. Paragraph-level actions do NOT support tracked changes. Do NOT use a search ref for paragraph-level actions; they require a block target with nodeId. Do NOT use {kind:\"block\", start:{kind:\"nodeEdge\",...}} or selection-like structures for paragraph actions. ONLY {kind:\"block\", nodeType, nodeId} is accepted. Do NOT issue multiple superdoc_format calls in parallel; each call invalidates refs for subsequent calls.\n\nEXAMPLES:\n 1. {\"action\":\"inline\",\"ref\":\"\",\"inline\":{\"bold\":true}}\n 2. {\"action\":\"inline\",\"ref\":\"\",\"inline\":{\"fontFamily\":\"Calibri\",\"fontSize\":11,\"color\":\"#000000\",\"bold\":false}}\n 3. {\"action\":\"set_alignment\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"alignment\":\"center\"}\n 4. {\"action\":\"set_flow_options\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"pageBreakBefore\":true}\n 5. {\"action\":\"set_spacing\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"lineSpacing\":{\"rule\":\"auto\",\"value\":1.5}}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "inline", - "set_alignment", - "set_direction", - "set_flow_options", - "set_indentation", - "set_spacing", - "set_style" + toolName: 'superdoc_format', + description: + 'Change text and paragraph formatting. To format multiple items at once, use superdoc_mutations with format.apply steps instead of calling this tool repeatedly. Use require "all" with a node selector to format every heading or paragraph in one batch. Use this tool for single-item formatting when you have a valid ref or nodeId. Action "inline" applies character formatting (bold, italic, underline, color, fontSize, fontFamily, highlight, strike, vertAlign) to a text range via "ref". Action "set_style" applies a named paragraph style by styleId (get available styles from superdoc_get_content info). Actions "set_alignment", "set_indentation", "set_spacing", "set_direction", and "set_flow_options" change paragraph-level properties and require a block target: {kind:"block", nodeType:"paragraph", nodeId:""}, NOT a ref. Use "set_flow_options" with pageBreakBefore:true to start a paragraph on a new page. Supports "dryRun" and "changeMode: tracked" for inline formatting. Paragraph-level actions do NOT support tracked changes. Do NOT use a search ref for paragraph-level actions; they require a block target with nodeId. Do NOT use {kind:"block", start:{kind:"nodeEdge",...}} or selection-like structures for paragraph actions. ONLY {kind:"block", nodeType, nodeId} is accepted. Do NOT issue multiple superdoc_format calls in parallel; each call invalidates refs for subsequent calls.\n\nEXAMPLES:\n 1. {"action":"inline","ref":"","inline":{"bold":true}}\n 2. {"action":"inline","ref":"","inline":{"fontFamily":"Calibri","fontSize":11,"color":"#000000","bold":false}}\n 3. {"action":"set_alignment","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"alignment":"center"}\n 4. {"action":"set_flow_options","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"pageBreakBefore":true}\n 5. {"action":"set_spacing","target":{"kind":"block","nodeType":"paragraph","nodeId":""},"lineSpacing":{"rule":"auto","value":1.5}}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: [ + 'inline', + 'set_alignment', + 'set_direction', + 'set_flow_options', + 'set_indentation', + 'set_spacing', + 'set_style', ], - "description": "The action to perform. One of: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style." + description: + 'The action to perform. One of: inline, set_alignment, set_direction, set_flow_options, set_indentation, set_spacing, set_style.', }, - "force": { - "type": "boolean", - "description": "Bypass confirmation checks." + force: { + type: 'boolean', + description: 'Bypass confirmation checks.', }, - "changeMode": { - "type": "string", - "enum": [ - "direct", - "tracked" - ], - "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + changeMode: { + type: 'string', + enum: ['direct', 'tracked'], + description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', }, - "dryRun": { - "type": "boolean", - "description": "Preview the result without applying changes." + dryRun: { + type: 'boolean', + description: 'Preview the result without applying changes.', }, - "target": { - "oneOf": [ + target: { + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/SelectionTarget", - "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + $ref: '#/$defs/SelectionTarget', + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/ParagraphAddress" + $ref: '#/$defs/ParagraphAddress', }, { - "$ref": "#/$defs/HeadingAddress" + $ref: '#/$defs/HeadingAddress', }, { - "$ref": "#/$defs/ListItemAddress" - } - ] - } + $ref: '#/$defs/ListItemAddress', + }, + ], + }, ], - "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/ParagraphAddress" + $ref: '#/$defs/ParagraphAddress', }, { - "$ref": "#/$defs/HeadingAddress" + $ref: '#/$defs/HeadingAddress', }, { - "$ref": "#/$defs/ListItemAddress" - } - ] - } + $ref: '#/$defs/ListItemAddress', + }, + ], + }, ], - "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/ParagraphAddress" + $ref: '#/$defs/ParagraphAddress', }, { - "$ref": "#/$defs/HeadingAddress" + $ref: '#/$defs/HeadingAddress', }, { - "$ref": "#/$defs/ListItemAddress" - } - ] - } + $ref: '#/$defs/ListItemAddress', + }, + ], + }, ], - "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/ParagraphAddress" + $ref: '#/$defs/ParagraphAddress', }, { - "$ref": "#/$defs/HeadingAddress" + $ref: '#/$defs/HeadingAddress', }, { - "$ref": "#/$defs/ListItemAddress" - } - ] - } + $ref: '#/$defs/ListItemAddress', + }, + ], + }, ], - "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/ParagraphAddress" + $ref: '#/$defs/ParagraphAddress', }, { - "$ref": "#/$defs/HeadingAddress" + $ref: '#/$defs/HeadingAddress', }, { - "$ref": "#/$defs/ListItemAddress" - } - ] - } + $ref: '#/$defs/ListItemAddress', + }, + ], + }, ], - "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle." + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle.", }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/ParagraphAddress" + $ref: '#/$defs/ParagraphAddress', }, { - "$ref": "#/$defs/HeadingAddress" + $ref: '#/$defs/HeadingAddress', }, { - "$ref": "#/$defs/ListItemAddress" - } - ] - } + $ref: '#/$defs/ListItemAddress', + }, + ], + }, ], - "description": "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle. Required for actions 'set_style', 'set_alignment', 'set_indentation', 'set_spacing', 'set_flow_options', 'set_direction'." + description: + "Selection target: {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. Use 'ref' instead when you have a search result handle. Required for actions 'set_style', 'set_alignment', 'set_indentation', 'set_spacing', 'set_flow_options', 'set_direction'.", }, - "inline": { - "type": "object", - "properties": { - "bold": { - "oneOf": [ + inline: { + type: 'object', + properties: { + bold: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "italic": { - "oneOf": [ + italic: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "strike": { - "oneOf": [ + strike: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "underline": { - "oneOf": [ + underline: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" + type: 'null', }, { - "type": "object", - "properties": { - "style": { - "oneOf": [ + type: 'object', + properties: { + style: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "themeColor": { - "oneOf": [ + themeColor: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 - } - ] + additionalProperties: false, + minProperties: 1, + }, + ], }, - "highlight": { - "oneOf": [ + highlight: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "fontSize": { - "oneOf": [ + fontSize: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "fontFamily": { - "oneOf": [ + fontFamily: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "letterSpacing": { - "oneOf": [ + letterSpacing: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "vertAlign": { - "oneOf": [ + vertAlign: { + oneOf: [ { - "enum": [ - "superscript", - "subscript", - "baseline" - ] + enum: ['superscript', 'subscript', 'baseline'], }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "position": { - "oneOf": [ + position: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "dstrike": { - "oneOf": [ + dstrike: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "smallCaps": { - "oneOf": [ + smallCaps: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "caps": { - "oneOf": [ + caps: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "shading": { - "oneOf": [ + shading: { + oneOf: [ { - "type": "object", - "properties": { - "fill": { - "oneOf": [ + type: 'object', + properties: { + fill: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "val": { - "oneOf": [ + val: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "border": { - "oneOf": [ + border: { + oneOf: [ { - "type": "object", - "properties": { - "val": { - "oneOf": [ + type: 'object', + properties: { + val: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "sz": { - "oneOf": [ + sz: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "space": { - "oneOf": [ + space: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "outline": { - "oneOf": [ + outline: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "shadow": { - "oneOf": [ + shadow: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "emboss": { - "oneOf": [ + emboss: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "imprint": { - "oneOf": [ + imprint: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "charScale": { - "oneOf": [ + charScale: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "kerning": { - "oneOf": [ + kerning: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "vanish": { - "oneOf": [ + vanish: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "webHidden": { - "oneOf": [ + webHidden: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "specVanish": { - "oneOf": [ + specVanish: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "rtl": { - "oneOf": [ + rtl: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "cs": { - "oneOf": [ + cs: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "bCs": { - "oneOf": [ + bCs: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "iCs": { - "oneOf": [ + iCs: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "eastAsianLayout": { - "oneOf": [ + eastAsianLayout: { + oneOf: [ { - "type": "object", - "properties": { - "id": { - "oneOf": [ + type: 'object', + properties: { + id: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "combine": { - "oneOf": [ + combine: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "combineBrackets": { - "oneOf": [ + combineBrackets: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "vert": { - "oneOf": [ + vert: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "vertCompress": { - "oneOf": [ + vertCompress: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "em": { - "oneOf": [ + em: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "fitText": { - "oneOf": [ + fitText: { + oneOf: [ { - "type": "object", - "properties": { - "val": { - "oneOf": [ + type: 'object', + properties: { + val: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "id": { - "oneOf": [ + id: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "snapToGrid": { - "oneOf": [ + snapToGrid: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "lang": { - "oneOf": [ + lang: { + oneOf: [ { - "type": "object", - "properties": { - "val": { - "oneOf": [ + type: 'object', + properties: { + val: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "eastAsia": { - "oneOf": [ + eastAsia: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "bidi": { - "oneOf": [ + bidi: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "oMath": { - "oneOf": [ + oMath: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "rStyle": { - "oneOf": [ + rStyle: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "rFonts": { - "oneOf": [ + rFonts: { + oneOf: [ { - "type": "object", - "properties": { - "ascii": { - "oneOf": [ + type: 'object', + properties: { + ascii: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "hAnsi": { - "oneOf": [ + hAnsi: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "eastAsia": { - "oneOf": [ + eastAsia: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "cs": { - "oneOf": [ + cs: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "asciiTheme": { - "oneOf": [ + asciiTheme: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "hAnsiTheme": { - "oneOf": [ + hAnsiTheme: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "eastAsiaTheme": { - "oneOf": [ + eastAsiaTheme: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "csTheme": { - "oneOf": [ + csTheme: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "hint": { - "oneOf": [ + hint: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "fontSizeCs": { - "oneOf": [ + fontSizeCs: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "ligatures": { - "oneOf": [ + ligatures: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "numForm": { - "oneOf": [ + numForm: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "numSpacing": { - "oneOf": [ + numSpacing: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "stylisticSets": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "number" + stylisticSets: { + oneOf: [ + { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'number', + }, + val: { + type: 'boolean', }, - "val": { - "type": "boolean" - } }, - "required": [ - "id" - ], - "additionalProperties": false + required: ['id'], + additionalProperties: false, }, - "minItems": 1 + minItems: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "contextualAlternates": { - "oneOf": [ + contextualAlternates: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1, - "description": "Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold. Only for action 'inline'. Omit for other actions." - }, - "ref": { - "type": "string", - "description": "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting. Only for action 'inline'. Omit for other actions." - }, - "styleId": { - "type": "string", - "minLength": 1, - "description": "Named paragraph style ID (e.g. 'Normal', 'Heading1', 'BodyText'). Use superdoc_search to find a nearby paragraph, then inspect its style to determine the correct styleId. Required for action 'set_style'." - }, - "alignment": { - "enum": [ - "left", - "center", - "right", - "justify" - ], - "description": "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'." - }, - "left": { - "type": "integer", - "minimum": 0, - "description": "Left indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions." - }, - "right": { - "type": "integer", - "minimum": 0, - "description": "Right indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions." - }, - "firstLine": { - "type": "integer", - "minimum": 0, - "description": "First line indent in twips. Cannot be combined with hanging. Only for action 'set_indentation'. Omit for other actions." - }, - "hanging": { - "type": "integer", - "minimum": 0, - "description": "Hanging indent in twips. Cannot be combined with firstLine. Only for action 'set_indentation'. Omit for other actions." - }, - "before": { - "type": "integer", - "minimum": 0, - "description": "Space before paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions." - }, - "after": { - "type": "integer", - "minimum": 0, - "description": "Space after paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions." - }, - "line": { - "type": "integer", - "minimum": 1, - "description": "Line spacing value. Meaning depends on lineRule. Must be provided together with lineRule. Only for action 'set_spacing'. Omit for other actions." - }, - "lineRule": { - "enum": [ - "auto", - "exact", - "atLeast" - ], - "description": "Line spacing rule. Required when 'line' is set. Only for action 'set_spacing'. Omit for other actions." - }, - "contextualSpacing": { - "type": "boolean", - "description": "Only for action 'set_flow_options'. Omit for other actions." - }, - "pageBreakBefore": { - "type": "boolean", - "description": "Only for action 'set_flow_options'. Omit for other actions." + additionalProperties: false, + minProperties: 1, + description: + "Inline formatting properties to apply. Set a property to apply it, use null to clear it. Example: {bold: true, italic: true} or {bold: null} to remove bold. Only for action 'inline'. Omit for other actions.", + }, + ref: { + type: 'string', + description: + "Handle ref string from a superdoc_search result. Pass the handle.ref value directly (e.g. 'text:eyJ...'). Preferred over 'target' for inline formatting. Only for action 'inline'. Omit for other actions.", + }, + styleId: { + type: 'string', + minLength: 1, + description: + "Named paragraph style ID (e.g. 'Normal', 'Heading1', 'BodyText'). Use superdoc_search to find a nearby paragraph, then inspect its style to determine the correct styleId. Required for action 'set_style'.", + }, + alignment: { + enum: ['left', 'center', 'right', 'justify'], + description: + "Visual paragraph alignment. In RTL paragraphs, 'left' stores w:jc='right' and 'right' stores w:jc='left' so Word displays the requested side. Required for action 'set_alignment'.", + }, + left: { + type: 'integer', + minimum: 0, + description: + "Left indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions.", + }, + right: { + type: 'integer', + minimum: 0, + description: + "Right indentation in twips (1440 = 1 inch). Only for action 'set_indentation'. Omit for other actions.", + }, + firstLine: { + type: 'integer', + minimum: 0, + description: + "First line indent in twips. Cannot be combined with hanging. Only for action 'set_indentation'. Omit for other actions.", + }, + hanging: { + type: 'integer', + minimum: 0, + description: + "Hanging indent in twips. Cannot be combined with firstLine. Only for action 'set_indentation'. Omit for other actions.", + }, + before: { + type: 'integer', + minimum: 0, + description: + "Space before paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions.", + }, + after: { + type: 'integer', + minimum: 0, + description: + "Space after paragraph in twips (20 twips = 1pt). Only for action 'set_spacing'. Omit for other actions.", + }, + line: { + type: 'integer', + minimum: 1, + description: + "Line spacing value. Meaning depends on lineRule. Must be provided together with lineRule. Only for action 'set_spacing'. Omit for other actions.", + }, + lineRule: { + enum: ['auto', 'exact', 'atLeast'], + description: + "Line spacing rule. Required when 'line' is set. Only for action 'set_spacing'. Omit for other actions.", + }, + contextualSpacing: { + type: 'boolean', + description: "Only for action 'set_flow_options'. Omit for other actions.", + }, + pageBreakBefore: { + type: 'boolean', + description: "Only for action 'set_flow_options'. Omit for other actions.", + }, + suppressAutoHyphens: { + type: 'boolean', + description: "Only for action 'set_flow_options'. Omit for other actions.", + }, + direction: { + type: 'string', + enum: ['ltr', 'rtl'], + description: "Required for action 'set_direction'.", + }, + alignmentPolicy: { + type: 'string', + enum: ['preserve', 'matchDirection'], + description: "Only for action 'set_direction'. Omit for other actions.", }, - "suppressAutoHyphens": { - "type": "boolean", - "description": "Only for action 'set_flow_options'. Omit for other actions." - }, - "direction": { - "type": "string", - "enum": [ - "ltr", - "rtl" - ], - "description": "Required for action 'set_direction'." - }, - "alignmentPolicy": { - "type": "string", - "enum": [ - "preserve", - "matchDirection" - ], - "description": "Only for action 'set_direction'. Omit for other actions." - } }, - "required": [ - "action" - ], - "additionalProperties": false + required: ['action'], + additionalProperties: false, }, - "mutates": true, - "operations": [ + mutates: true, + operations: [ { - "operationId": "doc.format.apply", - "intentAction": "inline", - "requiredOneOf": [ - [ - "target", - "inline" - ], - [ - "ref", - "inline" - ] - ] + operationId: 'doc.format.apply', + intentAction: 'inline', + requiredOneOf: [ + ['target', 'inline'], + ['ref', 'inline'], + ], }, { - "operationId": "doc.styles.paragraph.setStyle", - "intentAction": "set_style", - "required": [ - "target", - "styleId" - ] + operationId: 'doc.styles.paragraph.setStyle', + intentAction: 'set_style', + required: ['target', 'styleId'], }, { - "operationId": "doc.format.paragraph.setAlignment", - "intentAction": "set_alignment", - "required": [ - "target", - "alignment" - ] + operationId: 'doc.format.paragraph.setAlignment', + intentAction: 'set_alignment', + required: ['target', 'alignment'], }, { - "operationId": "doc.format.paragraph.setIndentation", - "intentAction": "set_indentation", - "required": [ - "target" - ] + operationId: 'doc.format.paragraph.setIndentation', + intentAction: 'set_indentation', + required: ['target'], }, { - "operationId": "doc.format.paragraph.setSpacing", - "intentAction": "set_spacing", - "required": [ - "target" - ] + operationId: 'doc.format.paragraph.setSpacing', + intentAction: 'set_spacing', + required: ['target'], }, { - "operationId": "doc.format.paragraph.setFlowOptions", - "intentAction": "set_flow_options", - "requiredOneOf": [ - [ - "target", - "contextualSpacing" - ], - [ - "target", - "pageBreakBefore" - ], - [ - "target", - "suppressAutoHyphens" - ] - ] + operationId: 'doc.format.paragraph.setFlowOptions', + intentAction: 'set_flow_options', + requiredOneOf: [ + ['target', 'contextualSpacing'], + ['target', 'pageBreakBefore'], + ['target', 'suppressAutoHyphens'], + ], }, { - "operationId": "doc.format.paragraph.setDirection", - "intentAction": "set_direction", - "required": [ - "target", - "direction" - ] - } - ] + operationId: 'doc.format.paragraph.setDirection', + intentAction: 'set_direction', + required: ['target', 'direction'], + }, + ], }, { - "toolName": "superdoc_create", - "description": "IMPORTANT: For headings and paragraphs, use superdoc_edit with type \"markdown\" instead: it is faster, creates proper styles, and handles positioning via target + placement. Only use superdoc_create for tables or when markdown cannot express the content. Creates a single paragraph, heading, or table. Returns nodeId and ref for the created block. After creating, the returned ref is valid for ONE immediate superdoc_format call. For subsequent operations, re-fetch blocks with superdoc_get_content to get fresh refs (refs expire after any mutation). When the user asks for a \"heading\", use action \"heading\" with a level (default 1). Use action \"paragraph\" for regular body text. Position with \"at\": {kind:\"documentEnd\"} (default), {kind:\"documentStart\"}, or {kind:\"after\"/\"before\", target:{kind:\"block\", nodeType, nodeId}} for relative placement. When creating multiple items in sequence, use the previous response nodeId as the next \"at\" target to maintain correct ordering. Do NOT use newlines in \"text\" to create multiple paragraphs; call this tool separately for each one.\n\nEXAMPLES:\n 1. {\"action\":\"paragraph\",\"text\":\"New paragraph content.\",\"at\":{\"kind\":\"documentEnd\"}}\n 2. {\"action\":\"heading\",\"text\":\"Section Title\",\"level\":2,\"at\":{\"kind\":\"after\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 3. {\"action\":\"paragraph\",\"text\":\"Chained item.\",\"at\":{\"kind\":\"after\",\"target\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 4. {\"action\":\"table\",\"rows\":3,\"columns\":4,\"at\":{\"kind\":\"documentEnd\"}}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "heading", - "paragraph", - "table" - ], - "description": "The action to perform. One of: heading, paragraph, table." - }, - "force": { - "type": "boolean", - "description": "Bypass confirmation checks." - }, - "changeMode": { - "type": "string", - "enum": [ - "direct", - "tracked" - ], - "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." - }, - "dryRun": { - "type": "boolean", - "description": "Preview the result without applying changes." - }, - "at": { - "oneOf": [ + toolName: 'superdoc_create', + description: + 'IMPORTANT: For headings and paragraphs, use superdoc_edit with type "markdown" instead: it is faster, creates proper styles, and handles positioning via target + placement. Only use superdoc_create for tables or when markdown cannot express the content. Creates a single paragraph, heading, or table. Returns nodeId and ref for the created block. After creating, the returned ref is valid for ONE immediate superdoc_format call. For subsequent operations, re-fetch blocks with superdoc_get_content to get fresh refs (refs expire after any mutation). When the user asks for a "heading", use action "heading" with a level (default 1). Use action "paragraph" for regular body text. Position with "at": {kind:"documentEnd"} (default), {kind:"documentStart"}, or {kind:"after"/"before", target:{kind:"block", nodeType, nodeId}} for relative placement. When creating multiple items in sequence, use the previous response nodeId as the next "at" target to maintain correct ordering. Do NOT use newlines in "text" to create multiple paragraphs; call this tool separately for each one.\n\nEXAMPLES:\n 1. {"action":"paragraph","text":"New paragraph content.","at":{"kind":"documentEnd"}}\n 2. {"action":"heading","text":"Section Title","level":2,"at":{"kind":"after","target":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 3. {"action":"paragraph","text":"Chained item.","at":{"kind":"after","target":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 4. {"action":"table","rows":3,"columns":4,"at":{"kind":"documentEnd"}}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['heading', 'paragraph', 'table'], + description: 'The action to perform. One of: heading, paragraph, table.', + }, + force: { + type: 'boolean', + description: 'Bypass confirmation checks.', + }, + changeMode: { + type: 'string', + enum: ['direct', 'tracked'], + description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + }, + dryRun: { + type: 'boolean', + description: 'Preview the result without applying changes.', + }, + at: { + oneOf: [ { - "description": "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", - "oneOf": [ - { - "type": "object", - "properties": { - "kind": { - "const": "documentStart", - "type": "string" - } + description: + "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", + oneOf: [ + { + type: 'object', + properties: { + kind: { + const: 'documentStart', + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "kind" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "documentEnd", - "type": "string" - } + additionalProperties: false, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { + const: 'documentEnd', + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "kind" - ] + additionalProperties: false, + required: ['kind'], }, { - "type": "object", - "properties": { - "kind": { - "const": "before", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'before', + type: 'string', + }, + target: { + $ref: '#/$defs/BlockNodeAddress', }, - "target": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "kind", - "target" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "after", - "type": "string" + additionalProperties: false, + required: ['kind', 'target'], + }, + { + type: 'object', + properties: { + kind: { + const: 'after', + type: 'string', + }, + target: { + $ref: '#/$defs/BlockNodeAddress', }, - "target": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "kind", - "target" - ] - } - ] + additionalProperties: false, + required: ['kind', 'target'], + }, + ], }, { - "oneOf": [ - { - "type": "object", - "properties": { - "kind": { - "const": "documentStart", - "type": "string" - } + oneOf: [ + { + type: 'object', + properties: { + kind: { + const: 'documentStart', + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "kind" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "documentEnd", - "type": "string" - } + additionalProperties: false, + required: ['kind'], + }, + { + type: 'object', + properties: { + kind: { + const: 'documentEnd', + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "kind" - ] + additionalProperties: false, + required: ['kind'], }, { - "type": "object", - "properties": { - "kind": { - "const": "before", - "type": "string" + type: 'object', + properties: { + kind: { + const: 'before', + type: 'string', + }, + target: { + $ref: '#/$defs/BlockNodeAddress', }, - "target": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "kind", - "target" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "after", - "type": "string" + additionalProperties: false, + required: ['kind', 'target'], + }, + { + type: 'object', + properties: { + kind: { + const: 'after', + type: 'string', + }, + target: { + $ref: '#/$defs/BlockNodeAddress', }, - "target": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "kind", - "target" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "before", - "type": "string" + additionalProperties: false, + required: ['kind', 'target'], + }, + { + type: 'object', + properties: { + kind: { + const: 'before', + type: 'string', + }, + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "additionalProperties": false, - "required": [ - "kind", - "nodeId" - ] - }, - { - "type": "object", - "properties": { - "kind": { - "const": "after", - "type": "string" + additionalProperties: false, + required: ['kind', 'nodeId'], + }, + { + type: 'object', + properties: { + kind: { + const: 'after', + type: 'string', + }, + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "additionalProperties": false, - "required": [ - "kind", - "nodeId" - ] - } - ] - } + additionalProperties: false, + required: ['kind', 'nodeId'], + }, + ], + }, ], - "description": "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement." + description: + "Position: {kind:'documentEnd'} to append, {kind:'documentStart'} to prepend, or {kind:'before'|'after', target:{kind:'block', nodeType:'...', nodeId:'...'}} for relative placement.", }, - "text": { - "oneOf": [ + text: { + oneOf: [ { - "type": "string", - "description": "Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph." + type: 'string', + description: + 'Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph.', }, { - "type": "string", - "description": "Heading text content." - } + type: 'string', + description: 'Heading text content.', + }, ], - "description": "Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph." + description: + 'Paragraph text content. Each call creates ONE paragraph. For multiple items (e.g. list items), call superdoc_create separately for each item: do NOT use newlines to put multiple items in one paragraph.', }, - "input": { - "oneOf": [ + input: { + oneOf: [ { - "type": "object", - "description": "Full paragraph input as JSON (alternative to individual text/at params)." + type: 'object', + description: 'Full paragraph input as JSON (alternative to individual text/at params).', }, { - "type": "object", - "description": "Full heading input as JSON (alternative to individual text/level/at params)." - } + type: 'object', + description: 'Full heading input as JSON (alternative to individual text/level/at params).', + }, ], - "description": "Full paragraph input as JSON (alternative to individual text/at params)." - }, - "level": { - "type": "integer", - "minimum": 1, - "maximum": 6, - "description": "Heading level (1-6). Required for action 'heading'." - }, - "rows": { - "type": "integer", - "minimum": 1, - "description": "Required for action 'table'." - }, - "columns": { - "type": "integer", - "minimum": 1, - "description": "Required for action 'table'." - } + description: 'Full paragraph input as JSON (alternative to individual text/at params).', + }, + level: { + type: 'integer', + minimum: 1, + maximum: 6, + description: "Heading level (1-6). Required for action 'heading'.", + }, + rows: { + type: 'integer', + minimum: 1, + description: "Required for action 'table'.", + }, + columns: { + type: 'integer', + minimum: 1, + description: "Required for action 'table'.", + }, }, - "required": [ - "action" - ], - "additionalProperties": false + required: ['action'], + additionalProperties: false, }, - "mutates": true, - "operations": [ + mutates: true, + operations: [ { - "operationId": "doc.create.paragraph", - "intentAction": "paragraph" + operationId: 'doc.create.paragraph', + intentAction: 'paragraph', }, { - "operationId": "doc.create.heading", - "intentAction": "heading", - "required": [ - "level" - ] + operationId: 'doc.create.heading', + intentAction: 'heading', + required: ['level'], }, { - "operationId": "doc.create.table", - "intentAction": "table", - "required": [ - "rows", - "columns" - ] - } - ] + operationId: 'doc.create.table', + intentAction: 'table', + required: ['rows', 'columns'], + }, + ], }, { - "toolName": "superdoc_list", - "description": "Create and manipulate bullet and numbered lists. Most actions require a list-item target: {kind:\"block\", nodeType:\"listItem\", nodeId:\"\"}. Exceptions: \"create\" and \"attach\" operate on paragraph targets (they turn paragraphs into list items). Find nodeIds via superdoc_get_content({action:\"blocks\"}): pick listItem blocks for most actions, paragraph blocks for create/attach.\n\nCREATE & CONVERT:\n• \"create\": make a NEW list from paragraphs. Two modes: mode:\"empty\" with at:{kind:\"block\", nodeType:\"paragraph\", nodeId} converts a single paragraph; mode:\"fromParagraphs\" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range: ALL paragraphs between from and to become items, so make sure no other content sits between them. Pass a preset (\"disc\"|\"circle\"|\"square\"|\"dash\" for bullets; \"decimal\"|\"decimalParenthesis\"|\"lowerLetter\"|\"upperLetter\"|\"lowerRoman\"|\"upperRoman\" for ordered) or a custom style. Use \"create\" to start a fresh list: NOT to extend an existing one (use \"attach\" for that).\n• \"attach\": add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:\"block\", nodeType:\"listItem\", nodeId:\"\"} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see \"join\" below).\n• \"set_type\": convert an existing list between ordered and bullet. Pass target:{listItem} + kind:\"ordered\" or \"bullet\". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n• \"detach\": convert a list item back to a plain paragraph. Pass target:{listItem}.\n\nITEMS & NESTING:\n• \"insert\": add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:\"before\"|\"after\" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n• \"indent\" / \"outdent\": bump the target item's nesting level by one (0-8 range). Pass target:{listItem}.\n• \"set_level\": jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n\nNUMBERING (ordered lists):\n• \"set_value\": restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n• \"continue_previous\": make the target's sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n\nSEQUENCE SHAPE (merge / split):\n• \"merge\": merge the target's sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:\"withPrevious\" or \"withNext\". Absorbed items adopt the absorbing sequence's numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n• \"split\": split the target's sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.\n\nEXAMPLES:\n 1. {\"action\":\"create\",\"mode\":\"fromParagraphs\",\"preset\":\"disc\",\"target\":{\"from\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"to\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"}}}\n 2. {\"action\":\"set_type\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"kind\":\"ordered\"}\n 3. {\"action\":\"insert\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"position\":\"after\",\"text\":\"New list item\"}\n 4. {\"action\":\"indent\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}\n 5. {\"action\":\"merge\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"direction\":\"withPrevious\"}\n 6. {\"action\":\"split\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}\n 7. {\"action\":\"set_value\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"},\"value\":1}\n 8. {\"action\":\"continue_previous\",\"target\":{\"kind\":\"block\",\"nodeType\":\"listItem\",\"nodeId\":\"\"}}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "attach", - "continue_previous", - "create", - "delete", - "detach", - "indent", - "insert", - "merge", - "outdent", - "set_level", - "set_type", - "set_value", - "split" + toolName: 'superdoc_list', + description: + 'Create and manipulate bullet and numbered lists. Most actions require a list-item target: {kind:"block", nodeType:"listItem", nodeId:""}. Exceptions: "create" and "attach" operate on paragraph targets (they turn paragraphs into list items). Find nodeIds via superdoc_get_content({action:"blocks"}): pick listItem blocks for most actions, paragraph blocks for create/attach.\n\nCREATE & CONVERT:\n• "create": make a NEW list from paragraphs. Two modes: mode:"empty" with at:{kind:"block", nodeType:"paragraph", nodeId} converts a single paragraph; mode:"fromParagraphs" with target:{from:{...paragraph block address}, to:{...paragraph block address}} converts a range: ALL paragraphs between from and to become items, so make sure no other content sits between them. Pass a preset ("disc"|"circle"|"square"|"dash" for bullets; "decimal"|"decimalParenthesis"|"lowerLetter"|"upperLetter"|"lowerRoman"|"upperRoman" for ordered) or a custom style. Use "create" to start a fresh list: NOT to extend an existing one (use "attach" for that).\n• "attach": add paragraphs to an EXISTING list, inheriting its numbering definition. Pass target:{paragraph block address} (or {from, to} range of paragraphs) + attachTo:{kind:"block", nodeType:"listItem", nodeId:""} + optional level:0..8. Use this to extend a list or as the second half of a merge workflow (see "join" below).\n• "set_type": convert an existing list between ordered and bullet. Pass target:{listItem} + kind:"ordered" or "bullet". Adjacent compatible sequences are merged automatically to preserve continuous numbering.\n• "detach": convert a list item back to a plain paragraph. Pass target:{listItem}.\n\nITEMS & NESTING:\n• "insert": add a new list item adjacent to an existing item in the same list. Pass target:{listItem} + position:"before"|"after" + optional text. Use this (NOT superdoc_create) to add items to an existing list.\n• "indent" / "outdent": bump the target item\'s nesting level by one (0-8 range). Pass target:{listItem}.\n• "set_level": jump the target item to an explicit level. Pass target:{listItem} + level:0..8.\n\nNUMBERING (ordered lists):\n• "set_value": restart numbering at the target. Pass target:{listItem} + value: (e.g. value:1 to start over) or value:null to clear a previous override. Mid-sequence targets are atomically split off into their own sequence.\n• "continue_previous": make the target\'s sequence continue numbering from the nearest compatible previous sequence (same abstract definition). Pass target:{listItem of the sequence you want to renumber}. Fails with NO_COMPATIBLE_PREVIOUS or INCOMPATIBLE_DEFINITIONS if no matching prior sequence exists.\n\nSEQUENCE SHAPE (merge / split):\n• "merge": merge the target\'s sequence with an adjacent one into one continuous list. Pass target:{listItem} + direction:"withPrevious" or "withNext". Absorbed items adopt the absorbing sequence\'s numbering definition, and empty paragraphs between the two sequences are removed so numbering flows continuously.\n• "split": split the target\'s sequence at the target item into two independent lists. The target and everything after become a new sequence that restarts numbering at 1. Pass target:{listItem}; add restartNumbering:false to keep the count continuing instead of restarting.\n\nEXAMPLES:\n 1. {"action":"create","mode":"fromParagraphs","preset":"disc","target":{"from":{"kind":"block","nodeType":"paragraph","nodeId":""},"to":{"kind":"block","nodeType":"paragraph","nodeId":""}}}\n 2. {"action":"set_type","target":{"kind":"block","nodeType":"listItem","nodeId":""},"kind":"ordered"}\n 3. {"action":"insert","target":{"kind":"block","nodeType":"listItem","nodeId":""},"position":"after","text":"New list item"}\n 4. {"action":"indent","target":{"kind":"block","nodeType":"listItem","nodeId":""}}\n 5. {"action":"merge","target":{"kind":"block","nodeType":"listItem","nodeId":""},"direction":"withPrevious"}\n 6. {"action":"split","target":{"kind":"block","nodeType":"listItem","nodeId":""}}\n 7. {"action":"set_value","target":{"kind":"block","nodeType":"listItem","nodeId":""},"value":1}\n 8. {"action":"continue_previous","target":{"kind":"block","nodeType":"listItem","nodeId":""}}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: [ + 'attach', + 'continue_previous', + 'create', + 'delete', + 'detach', + 'indent', + 'insert', + 'merge', + 'outdent', + 'set_level', + 'set_type', + 'set_value', + 'split', ], - "description": "The action to perform. One of: attach, continue_previous, create, delete, detach, indent, insert, merge, outdent, set_level, set_type, set_value, split." + description: + 'The action to perform. One of: attach, continue_previous, create, delete, detach, indent, insert, merge, outdent, set_level, set_type, set_value, split.', }, - "force": { - "type": "boolean", - "description": "Bypass confirmation checks." + force: { + type: 'boolean', + description: 'Bypass confirmation checks.', }, - "changeMode": { - "type": "string", - "enum": [ - "direct", - "tracked" - ], - "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + changeMode: { + type: 'string', + enum: ['direct', 'tracked'], + description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', }, - "dryRun": { - "type": "boolean", - "description": "Preview the result without applying changes." + dryRun: { + type: 'boolean', + description: 'Preview the result without applying changes.', }, - "target": { - "oneOf": [ + target: { + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/ListItemAddress", - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + $ref: '#/$defs/ListItemAddress', + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/BlockAddressOrRange", - "description": "Required when mode is 'fromParagraphs'. Each call converts ONE paragraph into a list item. To make a list with N items, create N separate paragraphs first, then call superdoc_list create for EACH one. Format: {kind:'block', nodeType:'paragraph', nodeId:''}." - } + $ref: '#/$defs/BlockAddressOrRange', + description: + "Required when mode is 'fromParagraphs'. Each call converts ONE paragraph into a list item. To make a list with N items, create N separate paragraphs first, then call superdoc_list create for EACH one. Format: {kind:'block', nodeType:'paragraph', nodeId:''}.", + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/BlockAddressOrRange" - } + $ref: '#/$defs/BlockAddressOrRange', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}." + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}.", }, { - "$ref": "#/$defs/ListItemAddress" - } - ], - "description": "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}. Required for actions 'insert', 'attach', 'detach', 'delete', 'indent', 'outdent', 'merge', 'split', 'set_level', 'set_value', 'continue_previous', 'set_type'." - }, - "position": { - "enum": [ - "before", - "after" - ], - "description": "Required. Insert position relative to target: 'before' or 'after'. Required for action 'insert'." - }, - "text": { - "type": "string", - "description": "Text content for the new list item. Only for action 'insert'. Omit for other actions." - }, - "input": { - "type": "object", - "description": "Operation input as JSON object." - }, - "nodeId": { - "type": "string", - "description": "Node ID of the target list item." - }, - "mode": { - "enum": [ - "empty", - "fromParagraphs" - ], - "description": "Required. 'fromParagraphs' converts existing paragraphs into list items: each paragraph becomes one item, so create one paragraph per item first. 'empty' creates a new empty list at 'at'. Required for action 'create'." - }, - "at": { - "$ref": "#/$defs/BlockAddress", - "description": "Required when mode is 'empty'. The paragraph to create the list at. Format: {kind:'block', nodeType:'paragraph', nodeId:''}. Only for action 'create'. Omit for other actions." - }, - "kind": { - "enum": [ - "ordered", - "bullet" + $ref: '#/$defs/ListItemAddress', + }, ], - "description": "List type: 'bullet' for bullet points, 'ordered' for numbered lists. Required for action 'set_type'." - }, - "level": { - "oneOf": [ + description: + "The target list item. For 'insert': the item to insert relative to. For 'create' with mode 'fromParagraphs': use nodeType 'paragraph' instead. Format: {kind:'block', nodeType:'listItem', nodeId:''}. Required for actions 'insert', 'attach', 'detach', 'delete', 'indent', 'outdent', 'merge', 'split', 'set_level', 'set_value', 'continue_previous', 'set_type'.", + }, + position: { + enum: ['before', 'after'], + description: + "Required. Insert position relative to target: 'before' or 'after'. Required for action 'insert'.", + }, + text: { + type: 'string', + description: "Text content for the new list item. Only for action 'insert'. Omit for other actions.", + }, + input: { + type: 'object', + description: 'Operation input as JSON object.', + }, + nodeId: { + type: 'string', + description: 'Node ID of the target list item.', + }, + mode: { + enum: ['empty', 'fromParagraphs'], + description: + "Required. 'fromParagraphs' converts existing paragraphs into list items: each paragraph becomes one item, so create one paragraph per item first. 'empty' creates a new empty list at 'at'. Required for action 'create'.", + }, + at: { + $ref: '#/$defs/BlockAddress', + description: + "Required when mode is 'empty'. The paragraph to create the list at. Format: {kind:'block', nodeType:'paragraph', nodeId:''}. Only for action 'create'. Omit for other actions.", + }, + kind: { + enum: ['ordered', 'bullet'], + description: + "List type: 'bullet' for bullet points, 'ordered' for numbered lists. Required for action 'set_type'.", + }, + level: { + oneOf: [ { - "oneOf": [ + oneOf: [ { - "type": "integer", - "minimum": 0, - "maximum": 8, - "description": "List nesting level (0-8). 0 is the top level." + type: 'integer', + minimum: 0, + maximum: 8, + description: 'List nesting level (0-8). 0 is the top level.', }, { - "type": "integer", - "minimum": 0, - "maximum": 8 - } + type: 'integer', + minimum: 0, + maximum: 8, + }, ], - "description": "List nesting level (0-8). 0 is the top level." + description: 'List nesting level (0-8). 0 is the top level.', }, { - "type": "integer", - "minimum": 0, - "maximum": 8 - } + type: 'integer', + minimum: 0, + maximum: 8, + }, ], - "description": "List nesting level (0-8). 0 is the top level. Required for action 'set_level'." - }, - "preset": { - "enum": [ - "decimal", - "decimalParenthesis", - "lowerLetter", - "upperLetter", - "lowerRoman", - "upperRoman", - "disc", - "circle", - "square", - "dash" + description: "List nesting level (0-8). 0 is the top level. Required for action 'set_level'.", + }, + preset: { + enum: [ + 'decimal', + 'decimalParenthesis', + 'lowerLetter', + 'upperLetter', + 'lowerRoman', + 'upperRoman', + 'disc', + 'circle', + 'square', + 'dash', ], - "description": "Predefined list style preset. Overrides 'kind' with a specific numbering or bullet format. Only for action 'create'. Omit for other actions." - }, - "style": { - "type": "object", - "properties": { - "version": { - "const": 1, - "type": "number" + description: + "Predefined list style preset. Overrides 'kind' with a specific numbering or bullet format. Only for action 'create'. Omit for other actions.", + }, + style: { + type: 'object', + properties: { + version: { + const: 1, + type: 'number', }, - "levels": { - "type": "array", - "items": { - "type": "object", - "properties": { - "level": { - "type": "integer", - "minimum": 0, - "maximum": 8 + levels: { + type: 'array', + items: { + type: 'object', + properties: { + level: { + type: 'integer', + minimum: 0, + maximum: 8, }, - "numFmt": { - "type": "string" + numFmt: { + type: 'string', }, - "lvlText": { - "type": "string" + lvlText: { + type: 'string', }, - "start": { - "type": "integer" + start: { + type: 'integer', }, - "alignment": { - "enum": [ - "left", - "center", - "right" - ] + alignment: { + enum: ['left', 'center', 'right'], }, - "indents": { - "type": "object", - "properties": { - "left": { - "type": "integer" + indents: { + type: 'object', + properties: { + left: { + type: 'integer', + }, + hanging: { + type: 'integer', }, - "hanging": { - "type": "integer" + firstLine: { + type: 'integer', }, - "firstLine": { - "type": "integer" - } }, - "additionalProperties": false + additionalProperties: false, }, - "trailingCharacter": { - "enum": [ - "tab", - "space", - "nothing" - ] + trailingCharacter: { + enum: ['tab', 'space', 'nothing'], }, - "markerFont": { - "type": "string" + markerFont: { + type: 'string', }, - "pictureBulletId": { - "type": "integer" + pictureBulletId: { + type: 'integer', }, - "tabStopAt": { - "type": [ - "integer", - "null" - ] - } - }, - "additionalProperties": false, - "required": [ - "level" - ] - } - } + tabStopAt: { + type: ['integer', 'null'], + }, + }, + additionalProperties: false, + required: ['level'], + }, + }, }, - "additionalProperties": false, - "required": [ - "version", - "levels" - ], - "description": "Only for action 'create'. Omit for other actions." + additionalProperties: false, + required: ['version', 'levels'], + description: "Only for action 'create'. Omit for other actions.", }, - "sequence": { - "oneOf": [ + sequence: { + oneOf: [ { - "type": "object", - "properties": { - "mode": { - "const": "new", - "type": "string" - }, - "startAt": { - "type": "integer", - "minimum": 1 - } + type: 'object', + properties: { + mode: { + const: 'new', + type: 'string', + }, + startAt: { + type: 'integer', + minimum: 1, + }, }, - "additionalProperties": false, - "required": [ - "mode" - ] + additionalProperties: false, + required: ['mode'], }, { - "type": "object", - "properties": { - "mode": { - "const": "continuePrevious", - "type": "string" - } + type: 'object', + properties: { + mode: { + const: 'continuePrevious', + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "mode" - ] - } + additionalProperties: false, + required: ['mode'], + }, ], - "description": "Only for action 'create'. Omit for other actions." + description: "Only for action 'create'. Omit for other actions.", }, - "attachTo": { - "$ref": "#/$defs/ListItemAddress", - "description": "Required for action 'attach'." + attachTo: { + $ref: '#/$defs/ListItemAddress', + description: "Required for action 'attach'.", }, - "direction": { - "enum": [ - "withPrevious", - "withNext" - ], - "description": "Required for action 'merge'." + direction: { + enum: ['withPrevious', 'withNext'], + description: "Required for action 'merge'.", }, - "restartNumbering": { - "type": "boolean", - "description": "Only for action 'split'. Omit for other actions." + restartNumbering: { + type: 'boolean', + description: "Only for action 'split'. Omit for other actions.", }, - "value": { - "type": [ - "integer", - "null" - ], - "description": "Required for action 'set_value'." + value: { + type: ['integer', 'null'], + description: "Required for action 'set_value'.", + }, + continuity: { + enum: ['preserve', 'none'], + description: + "Numbering continuity: 'preserve' keeps numbering; 'none' restarts. Only for action 'set_type'. Omit for other actions.", }, - "continuity": { - "enum": [ - "preserve", - "none" - ], - "description": "Numbering continuity: 'preserve' keeps numbering; 'none' restarts. Only for action 'set_type'. Omit for other actions." - } }, - "required": [ - "action" - ], - "additionalProperties": false + required: ['action'], + additionalProperties: false, }, - "mutates": true, - "operations": [ + mutates: true, + operations: [ { - "operationId": "doc.lists.insert", - "intentAction": "insert", - "required": [ - "target", - "position" - ] + operationId: 'doc.lists.insert', + intentAction: 'insert', + required: ['target', 'position'], }, { - "operationId": "doc.lists.create", - "intentAction": "create", - "required": [ - "mode" - ] + operationId: 'doc.lists.create', + intentAction: 'create', + required: ['mode'], }, { - "operationId": "doc.lists.attach", - "intentAction": "attach", - "required": [ - "target", - "attachTo" - ] + operationId: 'doc.lists.attach', + intentAction: 'attach', + required: ['target', 'attachTo'], }, { - "operationId": "doc.lists.detach", - "intentAction": "detach", - "required": [ - "target" - ] + operationId: 'doc.lists.detach', + intentAction: 'detach', + required: ['target'], }, { - "operationId": "doc.lists.delete", - "intentAction": "delete", - "required": [ - "target" - ] + operationId: 'doc.lists.delete', + intentAction: 'delete', + required: ['target'], }, { - "operationId": "doc.lists.indent", - "intentAction": "indent", - "required": [ - "target" - ] + operationId: 'doc.lists.indent', + intentAction: 'indent', + required: ['target'], }, { - "operationId": "doc.lists.outdent", - "intentAction": "outdent", - "required": [ - "target" - ] + operationId: 'doc.lists.outdent', + intentAction: 'outdent', + required: ['target'], }, { - "operationId": "doc.lists.merge", - "intentAction": "merge", - "required": [ - "target", - "direction" - ] + operationId: 'doc.lists.merge', + intentAction: 'merge', + required: ['target', 'direction'], }, { - "operationId": "doc.lists.split", - "intentAction": "split", - "required": [ - "target" - ] + operationId: 'doc.lists.split', + intentAction: 'split', + required: ['target'], }, { - "operationId": "doc.lists.setLevel", - "intentAction": "set_level", - "required": [ - "target", - "level" - ] + operationId: 'doc.lists.setLevel', + intentAction: 'set_level', + required: ['target', 'level'], }, { - "operationId": "doc.lists.setValue", - "intentAction": "set_value", - "required": [ - "target", - "value" - ] + operationId: 'doc.lists.setValue', + intentAction: 'set_value', + required: ['target', 'value'], }, { - "operationId": "doc.lists.continuePrevious", - "intentAction": "continue_previous", - "required": [ - "target" - ] + operationId: 'doc.lists.continuePrevious', + intentAction: 'continue_previous', + required: ['target'], }, { - "operationId": "doc.lists.setType", - "intentAction": "set_type", - "required": [ - "target", - "kind" - ] - } - ] + operationId: 'doc.lists.setType', + intentAction: 'set_type', + required: ['target', 'kind'], + }, + ], }, { - "toolName": "superdoc_comment", - "description": "Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action \"create\" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:\"text\", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:\"text\", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass \"parentId\" with the parent comment ID. Action \"list\" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action \"get\" retrieves a single comment by ID. Action \"update\" changes status to \"resolved\" or marks as internal. Action \"delete\" removes a comment or reply by ID. Do NOT pass \"ref\", \"id\", or \"parentId\" when creating a new top-level comment; only \"action\", \"text\", and \"target\" are needed.\n\nEXAMPLES:\n 1. {\"action\":\"create\",\"text\":\"Please review this section.\",\"target\":{\"kind\":\"text\",\"blockId\":\"\",\"range\":{\"start\":5,\"end\":25}}}\n 2. {\"action\":\"list\",\"limit\":20,\"offset\":0}\n 3. {\"action\":\"update\",\"id\":\"\",\"status\":\"resolved\"}\n 4. {\"action\":\"delete\",\"id\":\"\"}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "create", - "delete", - "get", - "list", - "update" - ], - "description": "The action to perform. One of: create, delete, get, list, update." - }, - "force": { - "type": "boolean", - "description": "Bypass confirmation checks." - }, - "changeMode": { - "type": "string", - "enum": [ - "direct", - "tracked" - ], - "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." - }, - "text": { - "oneOf": [ + toolName: 'superdoc_comment', + description: + 'Manage document comment threads: create, read, update, and delete. To create a comment, first use superdoc_search to find the target text, then pass action "create" with the comment text and a target built from items[0].blocks. For a single-block match use {kind:"text", blockId: items[0].blocks[0].blockId, range: items[0].blocks[0].range}. For a cross-block match use {kind:"text", segments: items[0].blocks.map(b => ({blockId: b.blockId, range: b.range}))}. Do NOT use items[0].highlightRange (snippet-relative, not block-relative) or items[0].target (a SelectionTarget, not accepted by comments.create). For threaded replies, pass "parentId" with the parent comment ID. Action "list" returns all comments with optional pagination (limit, offset) and filtering (includeResolved:true to include resolved). Action "get" retrieves a single comment by ID. Action "update" changes status to "resolved" or marks as internal. Action "delete" removes a comment or reply by ID. Do NOT pass "ref", "id", or "parentId" when creating a new top-level comment; only "action", "text", and "target" are needed.\n\nEXAMPLES:\n 1. {"action":"create","text":"Please review this section.","target":{"kind":"text","blockId":"","range":{"start":5,"end":25}}}\n 2. {"action":"list","limit":20,"offset":0}\n 3. {"action":"update","id":"","status":"resolved"}\n 4. {"action":"delete","id":""}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['create', 'delete', 'get', 'list', 'update'], + description: 'The action to perform. One of: create, delete, get, list, update.', + }, + force: { + type: 'boolean', + description: 'Bypass confirmation checks.', + }, + changeMode: { + type: 'string', + enum: ['direct', 'tracked'], + description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', + }, + text: { + oneOf: [ { - "type": "string", - "description": "Comment text content." + type: 'string', + description: 'Comment text content.', }, { - "type": "string", - "description": "Updated comment text." - } + type: 'string', + description: 'Updated comment text.', + }, ], - "description": "Comment text content. Required for action 'create'." + description: "Comment text content. Required for action 'create'.", }, - "target": { - "oneOf": [ + target: { + oneOf: [ { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TextAddress" + $ref: '#/$defs/TextAddress', }, { - "$ref": "#/$defs/TextTarget" + $ref: '#/$defs/TextTarget', }, { - "$ref": "#/$defs/SelectionTarget" + $ref: '#/$defs/SelectionTarget', }, { - "$ref": "#/$defs/CommentTrackedChangeTarget" - } + $ref: '#/$defs/CommentTrackedChangeTarget', + }, ], - "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content." + description: + "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content.", }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TextAddress" + $ref: '#/$defs/TextAddress', }, { - "$ref": "#/$defs/TextTarget" + $ref: '#/$defs/TextTarget', }, { - "$ref": "#/$defs/SelectionTarget" + $ref: '#/$defs/SelectionTarget', }, { - "$ref": "#/$defs/CommentTrackedChangeTarget" - } - ] - } + $ref: '#/$defs/CommentTrackedChangeTarget', + }, + ], + }, ], - "description": "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content. Only for actions 'create', 'update'. Omit for other actions." + description: + "Comment target. Accepts a TextAddress, TextTarget, SelectionTarget, or {trackedChangeId, kind?:'trackedChange'} to anchor directly on tracked content. Only for actions 'create', 'update'. Omit for other actions.", }, - "parentId": { - "type": "string", - "description": "Parent comment ID for creating a threaded reply. Only for action 'create'. Omit for other actions." + parentId: { + type: 'string', + description: + "Parent comment ID for creating a threaded reply. Only for action 'create'. Omit for other actions.", }, - "id": { - "type": "string", - "description": "Required for actions 'delete', 'get'." + id: { + type: 'string', + description: "Required for actions 'delete', 'get'.", }, - "status": { - "enum": [ - "resolved", - "active" - ], - "description": "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse). Only for action 'update'. Omit for other actions." + status: { + enum: ['resolved', 'active'], + description: + "Set comment status. Use 'resolved' to resolve a comment, or 'active' to reopen a previously resolved comment (lifecycle inverse). Only for action 'update'. Omit for other actions.", + }, + isInternal: { + type: 'boolean', + description: + "When true, marks the comment as internal (hidden from external collaborators). Only for action 'update'. Omit for other actions.", }, - "isInternal": { - "type": "boolean", - "description": "When true, marks the comment as internal (hidden from external collaborators). Only for action 'update'. Omit for other actions." + includeResolved: { + type: 'boolean', + description: + "When true, includes resolved comments in results. Default: false. Only for action 'list'. Omit for other actions.", }, - "includeResolved": { - "type": "boolean", - "description": "When true, includes resolved comments in results. Default: false. Only for action 'list'. Omit for other actions." + limit: { + type: 'integer', + description: "Maximum number of comments to return. Only for action 'list'. Omit for other actions.", }, - "limit": { - "type": "integer", - "description": "Maximum number of comments to return. Only for action 'list'. Omit for other actions." + offset: { + type: 'integer', + description: "Number of comments to skip for pagination. Only for action 'list'. Omit for other actions.", }, - "offset": { - "type": "integer", - "description": "Number of comments to skip for pagination. Only for action 'list'. Omit for other actions." - } }, - "required": [ - "action" - ], - "additionalProperties": false + required: ['action'], + additionalProperties: false, }, - "mutates": true, - "operations": [ + mutates: true, + operations: [ { - "operationId": "doc.comments.create", - "intentAction": "create", - "required": [ - "text" - ] + operationId: 'doc.comments.create', + intentAction: 'create', + required: ['text'], }, { - "operationId": "doc.comments.patch", - "intentAction": "update" + operationId: 'doc.comments.patch', + intentAction: 'update', }, { - "operationId": "doc.comments.delete", - "intentAction": "delete", - "required": [ - "id" - ] + operationId: 'doc.comments.delete', + intentAction: 'delete', + required: ['id'], }, { - "operationId": "doc.comments.get", - "intentAction": "get", - "required": [ - "id" - ] + operationId: 'doc.comments.get', + intentAction: 'get', + required: ['id'], }, { - "operationId": "doc.comments.list", - "intentAction": "list" - } - ] + operationId: 'doc.comments.list', + intentAction: 'list', + }, + ], }, { - "toolName": "superdoc_track_changes", - "description": "Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action \"list\" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action \"decide\" accepts or rejects changes. Pass decision:\"accept\" to apply the change permanently, or decision:\"reject\" to discard it. Target a single change with {id:\"\"}, a partial selection with {kind:\"range\", range:{...}}, or all changes at once with {scope:\"all\"} (optionally plus story). Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {\"action\":\"list\"}\n 2. {\"action\":\"list\",\"type\":\"replacement\",\"limit\":10}\n 3. {\"action\":\"decide\",\"decision\":\"accept\",\"target\":{\"id\":\"\"}}\n 4. {\"action\":\"decide\",\"decision\":\"reject\",\"target\":{\"kind\":\"range\",\"range\":{\"kind\":\"text\",\"segments\":[{\"blockId\":\"\",\"range\":{\"start\":0,\"end\":5}}]}}}\n 5. {\"action\":\"decide\",\"decision\":\"reject\",\"target\":{\"scope\":\"all\"}}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "decide", - "list" - ], - "description": "The action to perform. One of: decide, list." - }, - "limit": { - "type": "integer", - "description": "Maximum number of tracked changes to return. Only for action 'list'. Omit for other actions." - }, - "offset": { - "type": "integer", - "description": "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions." - }, - "type": { - "enum": [ - "insert", - "delete", - "replacement", - "format" - ], - "description": "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions." - }, - "force": { - "type": "boolean", - "description": "Bypass confirmation checks. Only for action 'decide'. Omit for other actions." - }, - "changeMode": { - "type": "string", - "enum": [ - "direct", - "tracked" - ], - "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions. Only for action 'decide'. Omit for other actions." - }, - "decision": { - "enum": [ - "accept", - "reject" - ], - "description": "Required for action 'decide'." - }, - "target": { - "oneOf": [ + toolName: 'superdoc_track_changes', + description: + 'Review and resolve tracked changes (insertions, deletions, replacements, format changes) in the document. Action "list" returns all tracked changes with optional filtering by type (insert, delete, replacement, format) and pagination (limit, offset). Each change includes an ID, type, author, timestamp, and content preview. Action "decide" accepts or rejects changes. Pass decision:"accept" to apply the change permanently, or decision:"reject" to discard it. Target a single change with {id:""}, a partial selection with {kind:"range", range:{...}}, or all changes at once with {scope:"all"} (optionally plus story). Do NOT use this tool unless the document has tracked changes. Use superdoc_get_content info to check the tracked change count first.\n\nEXAMPLES:\n 1. {"action":"list"}\n 2. {"action":"list","type":"replacement","limit":10}\n 3. {"action":"decide","decision":"accept","target":{"id":""}}\n 4. {"action":"decide","decision":"reject","target":{"kind":"range","range":{"kind":"text","segments":[{"blockId":"","range":{"start":0,"end":5}}]}}}\n 5. {"action":"decide","decision":"reject","target":{"scope":"all"}}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['decide', 'list'], + description: 'The action to perform. One of: decide, list.', + }, + limit: { + type: 'integer', + description: "Maximum number of tracked changes to return. Only for action 'list'. Omit for other actions.", + }, + offset: { + type: 'integer', + description: + "Number of tracked changes to skip for pagination. Only for action 'list'. Omit for other actions.", + }, + type: { + enum: ['insert', 'delete', 'replacement', 'format'], + description: + "Filter by change type: 'insert', 'delete', 'replacement', or 'format'. Only for action 'list'. Omit for other actions.", + }, + force: { + type: 'boolean', + description: "Bypass confirmation checks. Only for action 'decide'. Omit for other actions.", + }, + changeMode: { + type: 'string', + enum: ['direct', 'tracked'], + description: + 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions. Only for action \'decide\'. Omit for other actions.', + }, + decision: { + enum: ['accept', 'reject'], + description: "Required for action 'decide'.", + }, + target: { + oneOf: [ { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "story": { - "$ref": "#/$defs/StoryLocator" - } + type: 'object', + properties: { + id: { + type: 'string', + }, + story: { + $ref: '#/$defs/StoryLocator', + }, }, - "additionalProperties": false, - "required": [ - "id" - ] + additionalProperties: false, + required: ['id'], }, { - "type": "object", - "properties": { - "kind": { - "const": "range", - "type": "string" - }, - "range": { - "$ref": "#/$defs/TextTarget" - }, - "story": { - "$ref": "#/$defs/StoryLocator" - }, - "part": { - "type": "string", - "description": "Optional part discriminator for the range target." - } + type: 'object', + properties: { + kind: { + const: 'range', + type: 'string', + }, + range: { + $ref: '#/$defs/TextTarget', + }, + story: { + $ref: '#/$defs/StoryLocator', + }, + part: { + type: 'string', + description: 'Optional part discriminator for the range target.', + }, }, - "additionalProperties": false, - "required": [ - "kind", - "range" - ] + additionalProperties: false, + required: ['kind', 'range'], }, { - "type": "object", - "properties": { - "scope": { - "enum": [ - "all" - ] - }, - "story": { - "oneOf": [ + type: 'object', + properties: { + scope: { + enum: ['all'], + }, + story: { + oneOf: [ { - "$ref": "#/$defs/StoryLocator" + $ref: '#/$defs/StoryLocator', }, { - "const": "all", - "type": "string" - } + const: 'all', + type: 'string', + }, ], - "description": "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story." - } + description: + "Optional explicit bulk filter. Omit or pass 'all' to target every revision-capable story, or pass a StoryLocator to scope the decision to one story.", + }, }, - "additionalProperties": false, - "required": [ - "scope" - ] - } + additionalProperties: false, + required: ['scope'], + }, ], - "description": "Required for action 'decide'." - } + description: "Required for action 'decide'.", + }, }, - "required": [ - "action" - ], - "additionalProperties": false + required: ['action'], + additionalProperties: false, }, - "mutates": true, - "operations": [ + mutates: true, + operations: [ { - "operationId": "doc.trackChanges.list", - "intentAction": "list" + operationId: 'doc.trackChanges.list', + intentAction: 'list', }, { - "operationId": "doc.trackChanges.decide", - "intentAction": "decide", - "required": [ - "decision", - "target" - ] - } - ] + operationId: 'doc.trackChanges.decide', + intentAction: 'decide', + required: ['decision', 'target'], + }, + ], }, { - "toolName": "superdoc_search", - "description": "Find text patterns or nodes in the document and get ref handles for targeting edits and formatting. Refs expire after any mutation that changes the document. Re-search before the next edit when using individual tools (superdoc_edit, superdoc_format). Within a superdoc_mutations batch, selectors in \"where\" clauses resolve automatically at compile time; no manual re-searching needed between steps. Text search returns handle.ref covering only the matched substring. Node search finds blocks by type (paragraph, heading, table, listItem, etc.). The \"require\" parameter controls match cardinality: \"first\" returns one match, \"all\" returns every match, \"exactlyOne\" fails if not exactly one match. Supports scoping via \"within\" to search inside a single block. Do NOT use regex or markdown formatting markers (#, **, etc.) in search patterns; patterns are plain text only. Do NOT use this tool when you already have a ref from superdoc_get_content blocks or superdoc_create; use that ref directly.\n\nEXAMPLES:\n 1. {\"select\":{\"type\":\"text\",\"pattern\":\"Introduction\"},\"require\":\"first\"}\n 2. {\"select\":{\"type\":\"text\",\"pattern\":\"total amount\"},\"require\":\"all\"}\n 3. {\"select\":{\"type\":\"node\",\"nodeType\":\"heading\"},\"require\":\"all\"}\n 4. {\"select\":{\"type\":\"text\",\"pattern\":\"contract\"},\"within\":{\"kind\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"abc123\"},\"require\":\"first\"}", - "inputSchema": { - "type": "object", - "properties": { - "select": { - "description": "Search selector. Use {type:'text', pattern:'...'} for text search or {type:'node', nodeType:'paragraph'|'heading'|...} for node search.", - "oneOf": [ + toolName: 'superdoc_search', + description: + 'Find text patterns or nodes in the document and get ref handles for targeting edits and formatting. Refs expire after any mutation that changes the document. Re-search before the next edit when using individual tools (superdoc_edit, superdoc_format). Within a superdoc_mutations batch, selectors in "where" clauses resolve automatically at compile time; no manual re-searching needed between steps. Text search returns handle.ref covering only the matched substring. Node search finds blocks by type (paragraph, heading, table, listItem, etc.). The "require" parameter controls match cardinality: "first" returns one match, "all" returns every match, "exactlyOne" fails if not exactly one match. Supports scoping via "within" to search inside a single block. Do NOT use regex or markdown formatting markers (#, **, etc.) in search patterns; patterns are plain text only. Do NOT use this tool when you already have a ref from superdoc_get_content blocks or superdoc_create; use that ref directly.\n\nEXAMPLES:\n 1. {"select":{"type":"text","pattern":"Introduction"},"require":"first"}\n 2. {"select":{"type":"text","pattern":"total amount"},"require":"all"}\n 3. {"select":{"type":"node","nodeType":"heading"},"require":"all"}\n 4. {"select":{"type":"text","pattern":"contract"},"within":{"kind":"block","nodeType":"paragraph","nodeId":"abc123"},"require":"first"}', + inputSchema: { + type: 'object', + properties: { + select: { + description: + "Search selector. Use {type:'text', pattern:'...'} for text search or {type:'node', nodeType:'paragraph'|'heading'|...} for node search.", + oneOf: [ { - "type": "object", - "properties": { - "type": { - "const": "text", - "description": "Must be 'text' for text pattern search.", - "type": "string" - }, - "pattern": { - "type": "string", - "description": "Text or regex pattern to match." - }, - "mode": { - "enum": [ - "contains", - "regex" - ], - "description": "Match mode: 'contains' (substring) or 'regex'." + type: 'object', + properties: { + type: { + const: 'text', + description: "Must be 'text' for text pattern search.", + type: 'string', + }, + pattern: { + type: 'string', + description: 'Text or regex pattern to match.', + }, + mode: { + enum: ['contains', 'regex'], + description: "Match mode: 'contains' (substring) or 'regex'.", + }, + caseSensitive: { + type: 'boolean', + description: 'Case-sensitive matching. Default: false.', }, - "caseSensitive": { - "type": "boolean", - "description": "Case-sensitive matching. Default: false." - } }, - "additionalProperties": false, - "required": [ - "type", - "pattern" - ] + additionalProperties: false, + required: ['type', 'pattern'], }, { - "type": "object", - "properties": { - "type": { - "const": "node", - "description": "Must be 'node' for node type search.", - "type": "string" - }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt", - "run", - "bookmark", - "comment", - "hyperlink", - "footnoteRef", - "endnoteRef", - "crossRef", - "indexEntry", - "citation", - "authorityEntry", - "sequenceField", - "tab", - "lineBreak" - ], - "description": "Block type to match (paragraph, heading, table, listItem, etc.)." + type: 'object', + properties: { + type: { + const: 'node', + description: "Must be 'node' for node type search.", + type: 'string', }, - "kind": { - "enum": [ - "block", - "inline" + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'footnoteRef', + 'endnoteRef', + 'crossRef', + 'indexEntry', + 'citation', + 'authorityEntry', + 'sequenceField', + 'tab', + 'lineBreak', ], - "description": "Filter: 'block' or 'inline'." - } + description: 'Block type to match (paragraph, heading, table, listItem, etc.).', + }, + kind: { + enum: ['block', 'inline'], + description: "Filter: 'block' or 'inline'.", + }, }, - "additionalProperties": false, - "required": [ - "type" - ] - } - ] - }, - "within": { - "$ref": "#/$defs/BlockNodeAddress", - "description": "Limit search scope to within a specific block: {kind:'block', nodeType:'...', nodeId:'...'}." - }, - "require": { - "enum": [ - "any", - "first", - "exactlyOne", - "all" + additionalProperties: false, + required: ['type'], + }, ], - "description": "Match cardinality: 'any' (all matches), 'first' (only first), 'exactlyOne' (fail if != 1), 'all' (fail if 0)." }, - "mode": { - "enum": [ - "strict", - "candidates" - ], - "description": "Search mode: 'strict' (default, exact matching) or 'candidates' (returns scored potential matches)." - }, - "includeNodes": { - "type": "boolean", - "description": "When true, includes full node data in results. Default: false." - }, - "limit": { - "type": "integer", - "minimum": 1, - "description": "Maximum number of matches to return." - }, - "offset": { - "type": "integer", - "minimum": 0, - "description": "Number of matches to skip for pagination." - } + within: { + $ref: '#/$defs/BlockNodeAddress', + description: "Limit search scope to within a specific block: {kind:'block', nodeType:'...', nodeId:'...'}.", + }, + require: { + enum: ['any', 'first', 'exactlyOne', 'all'], + description: + "Match cardinality: 'any' (all matches), 'first' (only first), 'exactlyOne' (fail if != 1), 'all' (fail if 0).", + }, + mode: { + enum: ['strict', 'candidates'], + description: + "Search mode: 'strict' (default, exact matching) or 'candidates' (returns scored potential matches).", + }, + includeNodes: { + type: 'boolean', + description: 'When true, includes full node data in results. Default: false.', + }, + limit: { + type: 'integer', + minimum: 1, + description: 'Maximum number of matches to return.', + }, + offset: { + type: 'integer', + minimum: 0, + description: 'Number of matches to skip for pagination.', + }, }, - "required": [ - "select" - ], - "additionalProperties": false + required: ['select'], + additionalProperties: false, }, - "mutates": false, - "operations": [ + mutates: false, + operations: [ { - "operationId": "doc.query.match", - "intentAction": "match", - "required": [ - "select" - ] - } - ] + operationId: 'doc.query.match', + intentAction: 'match', + required: ['select'], + }, + ], }, { - "toolName": "superdoc_mutations", - "description": "All steps succeed or all fail; no partial application. Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. Each step has an id, an op, a \"where\" clause for targeting ({by:\"select\", select:{...}, require:\"first\"|\"exactlyOne\"|\"all\"} or {by:\"ref\", ref:\"...\"} or {by:\"block\", nodeType:\"paragraph\", nodeId:\"...\"}), and \"args\" with operation-specific parameters. Use {by:\"block\", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action \"blocks\" without relying on text matching. For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:\"blocks\" and includeText:true, then rewrite the matching block by nodeId. Use {by:\"select\"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. For create steps, \"where\" targets an existing anchor block and args.position (\"before\" or \"after\") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. For format.apply with require \"all\", use a node selector to format every heading or paragraph at once: {by:\"select\", select:{type:\"node\", nodeType:\"heading\"}, require:\"all\"}. Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. Action \"preview\" dry-runs the plan. Action \"apply\" executes it. If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.\n\nEXAMPLES:\n 1. {\"action\":\"apply\",\"atomic\":true,\"changeMode\":\"direct\",\"steps\":[{\"id\":\"s1\",\"op\":\"text.rewrite\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\"old term\"},\"require\":\"all\"},\"args\":{\"replacement\":{\"text\":\"new term\"}}},{\"id\":\"s2\",\"op\":\"text.delete\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\" (deprecated)\"},\"require\":\"all\"},\"args\":{}}]}\n 2. {\"action\":\"apply\",\"steps\":[{\"id\":\"r1\",\"op\":\"text.rewrite\",\"where\":{\"by\":\"block\",\"nodeType\":\"paragraph\",\"nodeId\":\"\"},\"args\":{\"replacement\":{\"text\":\"Updated clause text.\"}}},{\"id\":\"f1\",\"op\":\"format.apply\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"node\",\"nodeType\":\"heading\"},\"require\":\"all\"},\"args\":{\"inline\":{\"color\":\"#FF0000\"}}},{\"id\":\"f2\",\"op\":\"format.apply\",\"where\":{\"by\":\"select\",\"select\":{\"type\":\"text\",\"pattern\":\"Confidential Information\"},\"require\":\"all\"},\"args\":{\"inline\":{\"bold\":true}}}]}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "apply", - "preview" - ], - "description": "The action to perform. One of: apply, preview." - }, - "expectedRevision": { - "type": "string", - "description": "Document revision for optimistic concurrency. Mutation fails if document was modified since this revision. Only for action 'preview'. Omit for other actions." - }, - "atomic": { - "const": true, - "type": "boolean", - "description": "Must be true. All steps execute as one atomic transaction." - }, - "changeMode": { - "enum": [ - "direct", - "tracked" - ], - "description": "Required. Use 'direct' for immediate edits or 'tracked' for suggestions. Must always be provided." - }, - "steps": { - "type": "array", - "items": { - "oneOf": [ + toolName: 'superdoc_mutations', + description: + 'All steps succeed or all fail; no partial application. Execute multiple operations atomically in one batch. Use this for any workflow needing 2+ changes. Supported step types: text (text.rewrite, text.insert, text.delete), format (format.apply), create (create.heading, create.paragraph, create.table), assert. Each step has an id, an op, a "where" clause for targeting ({by:"select", select:{...}, require:"first"|"exactlyOne"|"all"} or {by:"ref", ref:"..."} or {by:"block", nodeType:"paragraph", nodeId:"..."}), and "args" with operation-specific parameters. Use {by:"block", nodeType, nodeId} when you want to rewrite, delete, format, or anchor against a whole known block from superdoc_get_content action "blocks" without relying on text matching. For full-paragraph or full-clause rewrites, first call superdoc_get_content with action:"blocks" and includeText:true, then rewrite the matching block by nodeId. Use {by:"select"} only for substring edits, discovery, or insertion relative to a sentence fragment; do NOT use a shortened text selector to replace an entire known block. For create steps, "where" targets an existing anchor block and args.position ("before" or "after") controls placement. Sequential creates targeting the same anchor maintain correct order via internal position mapping. For format.apply with require "all", use a node selector to format every heading or paragraph at once: {by:"select", select:{type:"node", nodeType:"heading"}, require:"all"}. Selectors resolve at compile time (before execution). This means format.apply steps CANNOT target content created by earlier create steps in the same batch. Split creates and formatting into separate batches: first a mutations call with creates, then a mutations call with format.apply. Action "preview" dry-runs the plan. Action "apply" executes it. If a selector matches nothing, the failure reports the step id plus selector details so you can retry with a shorter or more distinctive anchor. Do NOT create two steps that target overlapping text in the same block; combine them into a single text.rewrite step.\n\nEXAMPLES:\n 1. {"action":"apply","atomic":true,"changeMode":"direct","steps":[{"id":"s1","op":"text.rewrite","where":{"by":"select","select":{"type":"text","pattern":"old term"},"require":"all"},"args":{"replacement":{"text":"new term"}}},{"id":"s2","op":"text.delete","where":{"by":"select","select":{"type":"text","pattern":" (deprecated)"},"require":"all"},"args":{}}]}\n 2. {"action":"apply","steps":[{"id":"r1","op":"text.rewrite","where":{"by":"block","nodeType":"paragraph","nodeId":""},"args":{"replacement":{"text":"Updated clause text."}}},{"id":"f1","op":"format.apply","where":{"by":"select","select":{"type":"node","nodeType":"heading"},"require":"all"},"args":{"inline":{"color":"#FF0000"}}},{"id":"f2","op":"format.apply","where":{"by":"select","select":{"type":"text","pattern":"Confidential Information"},"require":"all"},"args":{"inline":{"bold":true}}}]}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['apply', 'preview'], + description: 'The action to perform. One of: apply, preview.', + }, + expectedRevision: { + type: 'string', + description: + "Document revision for optimistic concurrency. Mutation fails if document was modified since this revision. Only for action 'preview'. Omit for other actions.", + }, + atomic: { + const: true, + type: 'boolean', + description: 'Must be true. All steps execute as one atomic transaction.', + }, + changeMode: { + enum: ['direct', 'tracked'], + description: + "Required. Use 'direct' for immediate edits or 'tracked' for suggestions. Must always be provided.", + }, + steps: { + type: 'array', + items: { + oneOf: [ { - "type": "object", - "properties": { - "id": { - "type": "string" + type: 'object', + properties: { + id: { + type: 'string', }, - "op": { - "const": "text.rewrite", - "type": "string" + op: { + const: 'text.rewrite', + type: 'string', }, - "where": { - "oneOf": [ + where: { + oneOf: [ { - "type": "object", - "properties": { - "by": { - "const": "select", - "type": "string" + type: 'object', + properties: { + by: { + const: 'select', + type: 'string', }, - "select": { - "oneOf": [ + select: { + oneOf: [ { - "type": "object", - "properties": { - "type": { - "const": "text", - "description": "Must be 'text' for text pattern search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'text', + description: "Must be 'text' for text pattern search.", + type: 'string', }, - "pattern": { - "type": "string", - "description": "Text or regex pattern to match." + pattern: { + type: 'string', + description: 'Text or regex pattern to match.', }, - "mode": { - "enum": [ - "contains", - "regex" - ], - "description": "Match mode: 'contains' (substring) or 'regex'." + mode: { + enum: ['contains', 'regex'], + description: "Match mode: 'contains' (substring) or 'regex'.", + }, + caseSensitive: { + type: 'boolean', + description: 'Case-sensitive matching. Default: false.', }, - "caseSensitive": { - "type": "boolean", - "description": "Case-sensitive matching. Default: false." - } }, - "additionalProperties": false, - "required": [ - "type", - "pattern" - ] + additionalProperties: false, + required: ['type', 'pattern'], }, { - "type": "object", - "properties": { - "type": { - "const": "node", - "description": "Must be 'node' for node type search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'node', + description: "Must be 'node' for node type search.", + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt", - "run", - "bookmark", - "comment", - "hyperlink", - "footnoteRef", - "endnoteRef", - "crossRef", - "indexEntry", - "citation", - "authorityEntry", - "sequenceField", - "tab", - "lineBreak" + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'footnoteRef', + 'endnoteRef', + 'crossRef', + 'indexEntry', + 'citation', + 'authorityEntry', + 'sequenceField', + 'tab', + 'lineBreak', ], - "description": "Block type to match (paragraph, heading, table, listItem, etc.)." + description: 'Block type to match (paragraph, heading, table, listItem, etc.).', + }, + kind: { + enum: ['block', 'inline'], + description: "Filter: 'block' or 'inline'.", }, - "kind": { - "enum": [ - "block", - "inline" - ], - "description": "Filter: 'block' or 'inline'." - } }, - "additionalProperties": false, - "required": [ - "type" - ] - } - ] + additionalProperties: false, + required: ['type'], + }, + ], + }, + within: { + $ref: '#/$defs/BlockNodeAddress', }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" + require: { + enum: ['first', 'exactlyOne', 'all'], }, - "require": { - "enum": [ - "first", - "exactlyOne", - "all" - ] - } }, - "additionalProperties": false, - "required": [ - "by", - "select", - "require" - ] + additionalProperties: false, + required: ['by', 'select', 'require'], }, { - "type": "object", - "properties": { - "by": { - "const": "ref", - "type": "string" + type: 'object', + properties: { + by: { + const: 'ref', + type: 'string', }, - "ref": { - "type": "string" + ref: { + type: 'string', + }, + within: { + $ref: '#/$defs/BlockNodeAddress', }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "by", - "ref" - ] + additionalProperties: false, + required: ['by', 'ref'], }, { - "type": "object", - "properties": { - "by": { - "const": "target", - "type": "string" + type: 'object', + properties: { + by: { + const: 'target', + type: 'string', + }, + target: { + $ref: '#/$defs/SelectionTarget', }, - "target": { - "$ref": "#/$defs/SelectionTarget" - } }, - "additionalProperties": false, - "required": [ - "by", - "target" - ] + additionalProperties: false, + required: ['by', 'target'], }, { - "type": "object", - "properties": { - "by": { - "const": "block", - "type": "string" + type: 'object', + properties: { + by: { + const: 'block', + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt" - ] + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + ], + }, + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "additionalProperties": false, - "required": [ - "by", - "nodeType", - "nodeId" - ] - } - ] + additionalProperties: false, + required: ['by', 'nodeType', 'nodeId'], + }, + ], }, - "args": { - "type": "object", - "properties": { - "replacement": { - "oneOf": [ + args: { + type: 'object', + properties: { + replacement: { + oneOf: [ { - "type": "object", - "properties": { - "text": { - "type": "string" - } + type: 'object', + properties: { + text: { + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "text" - ] + additionalProperties: false, + required: ['text'], }, { - "type": "object", - "properties": { - "blocks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "text": { - "type": "string" - } + type: 'object', + properties: { + blocks: { + type: 'array', + items: { + type: 'object', + properties: { + text: { + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "text" - ] - } - } + additionalProperties: false, + required: ['text'], + }, + }, }, - "additionalProperties": false, - "required": [ - "blocks" - ] - } - ] + additionalProperties: false, + required: ['blocks'], + }, + ], }, - "style": { - "type": "object", - "properties": { - "inline": { - "type": "object", - "properties": { - "mode": { - "enum": [ - "preserve", - "set", - "clear", - "merge" - ], - "type": "string" + style: { + type: 'object', + properties: { + inline: { + type: 'object', + properties: { + mode: { + enum: ['preserve', 'set', 'clear', 'merge'], + type: 'string', }, - "requireUniform": { - "type": "boolean" + requireUniform: { + type: 'boolean', }, - "onNonUniform": { - "enum": [ - "error", - "useLeadingRun", - "majority", - "union" - ] + onNonUniform: { + enum: ['error', 'useLeadingRun', 'majority', 'union'], }, - "setMarks": { - "type": "object", - "properties": { - "bold": { - "enum": [ - "on", - "off", - "clear" - ] + setMarks: { + type: 'object', + properties: { + bold: { + enum: ['on', 'off', 'clear'], + }, + italic: { + enum: ['on', 'off', 'clear'], }, - "italic": { - "enum": [ - "on", - "off", - "clear" - ] + underline: { + enum: ['on', 'off', 'clear'], }, - "underline": { - "enum": [ - "on", - "off", - "clear" - ] + strike: { + enum: ['on', 'off', 'clear'], }, - "strike": { - "enum": [ - "on", - "off", - "clear" - ] - } }, - "additionalProperties": false - } + additionalProperties: false, + }, }, - "additionalProperties": false, - "required": [ - "mode" - ] + additionalProperties: false, + required: ['mode'], }, - "paragraph": { - "type": "object", - "properties": { - "mode": { - "enum": [ - "preserve", - "set", - "clear" - ], - "type": "string" - } + paragraph: { + type: 'object', + properties: { + mode: { + enum: ['preserve', 'set', 'clear'], + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "mode" - ] - } + additionalProperties: false, + required: ['mode'], + }, }, - "additionalProperties": false, - "required": [ - "inline" - ] - } + additionalProperties: false, + required: ['inline'], + }, }, - "additionalProperties": false, - "required": [ - "replacement" - ] - } - }, - "additionalProperties": false, - "required": [ - "id", - "op", - "where", - "args" - ] + additionalProperties: false, + required: ['replacement'], + }, + }, + additionalProperties: false, + required: ['id', 'op', 'where', 'args'], }, { - "type": "object", - "properties": { - "id": { - "type": "string" + type: 'object', + properties: { + id: { + type: 'string', }, - "op": { - "const": "text.insert", - "type": "string" + op: { + const: 'text.insert', + type: 'string', }, - "where": { - "oneOf": [ + where: { + oneOf: [ { - "type": "object", - "properties": { - "by": { - "const": "select", - "type": "string" + type: 'object', + properties: { + by: { + const: 'select', + type: 'string', }, - "select": { - "oneOf": [ + select: { + oneOf: [ { - "type": "object", - "properties": { - "type": { - "const": "text", - "description": "Must be 'text' for text pattern search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'text', + description: "Must be 'text' for text pattern search.", + type: 'string', }, - "pattern": { - "type": "string", - "description": "Text or regex pattern to match." + pattern: { + type: 'string', + description: 'Text or regex pattern to match.', }, - "mode": { - "enum": [ - "contains", - "regex" - ], - "description": "Match mode: 'contains' (substring) or 'regex'." + mode: { + enum: ['contains', 'regex'], + description: "Match mode: 'contains' (substring) or 'regex'.", + }, + caseSensitive: { + type: 'boolean', + description: 'Case-sensitive matching. Default: false.', }, - "caseSensitive": { - "type": "boolean", - "description": "Case-sensitive matching. Default: false." - } }, - "additionalProperties": false, - "required": [ - "type", - "pattern" - ] + additionalProperties: false, + required: ['type', 'pattern'], }, { - "type": "object", - "properties": { - "type": { - "const": "node", - "description": "Must be 'node' for node type search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'node', + description: "Must be 'node' for node type search.", + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt", - "run", - "bookmark", - "comment", - "hyperlink", - "footnoteRef", - "endnoteRef", - "crossRef", - "indexEntry", - "citation", - "authorityEntry", - "sequenceField", - "tab", - "lineBreak" + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'footnoteRef', + 'endnoteRef', + 'crossRef', + 'indexEntry', + 'citation', + 'authorityEntry', + 'sequenceField', + 'tab', + 'lineBreak', ], - "description": "Block type to match (paragraph, heading, table, listItem, etc.)." + description: 'Block type to match (paragraph, heading, table, listItem, etc.).', + }, + kind: { + enum: ['block', 'inline'], + description: "Filter: 'block' or 'inline'.", }, - "kind": { - "enum": [ - "block", - "inline" - ], - "description": "Filter: 'block' or 'inline'." - } }, - "additionalProperties": false, - "required": [ - "type" - ] - } - ] + additionalProperties: false, + required: ['type'], + }, + ], + }, + within: { + $ref: '#/$defs/BlockNodeAddress', }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" + require: { + enum: ['first', 'exactlyOne'], }, - "require": { - "enum": [ - "first", - "exactlyOne" - ] - } }, - "additionalProperties": false, - "required": [ - "by", - "select", - "require" - ] + additionalProperties: false, + required: ['by', 'select', 'require'], }, { - "type": "object", - "properties": { - "by": { - "const": "ref", - "type": "string" + type: 'object', + properties: { + by: { + const: 'ref', + type: 'string', + }, + ref: { + type: 'string', }, - "ref": { - "type": "string" + within: { + $ref: '#/$defs/BlockNodeAddress', }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "by", - "ref" - ] + additionalProperties: false, + required: ['by', 'ref'], }, { - "type": "object", - "properties": { - "by": { - "const": "target", - "type": "string" + type: 'object', + properties: { + by: { + const: 'target', + type: 'string', + }, + target: { + $ref: '#/$defs/SelectionTarget', }, - "target": { - "$ref": "#/$defs/SelectionTarget" - } }, - "additionalProperties": false, - "required": [ - "by", - "target" - ] + additionalProperties: false, + required: ['by', 'target'], }, { - "type": "object", - "properties": { - "by": { - "const": "block", - "type": "string" + type: 'object', + properties: { + by: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + ], }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt" - ] + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "additionalProperties": false, - "required": [ - "by", - "nodeType", - "nodeId" - ] - } - ] + additionalProperties: false, + required: ['by', 'nodeType', 'nodeId'], + }, + ], }, - "args": { - "type": "object", - "properties": { - "position": { - "enum": [ - "before", - "after" - ] + args: { + type: 'object', + properties: { + position: { + enum: ['before', 'after'], }, - "content": { - "type": "object", - "properties": { - "text": { - "type": "string" - } + content: { + type: 'object', + properties: { + text: { + type: 'string', + }, }, - "additionalProperties": false, - "required": [ - "text" - ] + additionalProperties: false, + required: ['text'], }, - "style": { - "type": "object", - "properties": { - "inline": { - "type": "object", - "properties": { - "mode": { - "enum": [ - "inherit", - "set", - "clear" - ], - "type": "string" + style: { + type: 'object', + properties: { + inline: { + type: 'object', + properties: { + mode: { + enum: ['inherit', 'set', 'clear'], + type: 'string', }, - "setMarks": { - "type": "object", - "properties": { - "bold": { - "enum": [ - "on", - "off", - "clear" - ] + setMarks: { + type: 'object', + properties: { + bold: { + enum: ['on', 'off', 'clear'], + }, + italic: { + enum: ['on', 'off', 'clear'], }, - "italic": { - "enum": [ - "on", - "off", - "clear" - ] + underline: { + enum: ['on', 'off', 'clear'], }, - "underline": { - "enum": [ - "on", - "off", - "clear" - ] + strike: { + enum: ['on', 'off', 'clear'], }, - "strike": { - "enum": [ - "on", - "off", - "clear" - ] - } }, - "additionalProperties": false - } + additionalProperties: false, + }, }, - "additionalProperties": false, - "required": [ - "mode" - ] - } + additionalProperties: false, + required: ['mode'], + }, }, - "additionalProperties": false, - "required": [ - "inline" - ] - } + additionalProperties: false, + required: ['inline'], + }, }, - "additionalProperties": false, - "required": [ - "position", - "content" - ] - } - }, - "additionalProperties": false, - "required": [ - "id", - "op", - "where", - "args" - ] + additionalProperties: false, + required: ['position', 'content'], + }, + }, + additionalProperties: false, + required: ['id', 'op', 'where', 'args'], }, { - "type": "object", - "properties": { - "id": { - "type": "string" + type: 'object', + properties: { + id: { + type: 'string', }, - "op": { - "const": "text.delete", - "type": "string" + op: { + const: 'text.delete', + type: 'string', }, - "where": { - "oneOf": [ + where: { + oneOf: [ { - "type": "object", - "properties": { - "by": { - "const": "select", - "type": "string" + type: 'object', + properties: { + by: { + const: 'select', + type: 'string', }, - "select": { - "oneOf": [ + select: { + oneOf: [ { - "type": "object", - "properties": { - "type": { - "const": "text", - "description": "Must be 'text' for text pattern search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'text', + description: "Must be 'text' for text pattern search.", + type: 'string', }, - "pattern": { - "type": "string", - "description": "Text or regex pattern to match." + pattern: { + type: 'string', + description: 'Text or regex pattern to match.', }, - "mode": { - "enum": [ - "contains", - "regex" - ], - "description": "Match mode: 'contains' (substring) or 'regex'." + mode: { + enum: ['contains', 'regex'], + description: "Match mode: 'contains' (substring) or 'regex'.", + }, + caseSensitive: { + type: 'boolean', + description: 'Case-sensitive matching. Default: false.', }, - "caseSensitive": { - "type": "boolean", - "description": "Case-sensitive matching. Default: false." - } }, - "additionalProperties": false, - "required": [ - "type", - "pattern" - ] + additionalProperties: false, + required: ['type', 'pattern'], }, { - "type": "object", - "properties": { - "type": { - "const": "node", - "description": "Must be 'node' for node type search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'node', + description: "Must be 'node' for node type search.", + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt", - "run", - "bookmark", - "comment", - "hyperlink", - "footnoteRef", - "endnoteRef", - "crossRef", - "indexEntry", - "citation", - "authorityEntry", - "sequenceField", - "tab", - "lineBreak" + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'footnoteRef', + 'endnoteRef', + 'crossRef', + 'indexEntry', + 'citation', + 'authorityEntry', + 'sequenceField', + 'tab', + 'lineBreak', ], - "description": "Block type to match (paragraph, heading, table, listItem, etc.)." + description: 'Block type to match (paragraph, heading, table, listItem, etc.).', + }, + kind: { + enum: ['block', 'inline'], + description: "Filter: 'block' or 'inline'.", }, - "kind": { - "enum": [ - "block", - "inline" - ], - "description": "Filter: 'block' or 'inline'." - } }, - "additionalProperties": false, - "required": [ - "type" - ] - } - ] + additionalProperties: false, + required: ['type'], + }, + ], + }, + within: { + $ref: '#/$defs/BlockNodeAddress', }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" + require: { + enum: ['first', 'exactlyOne', 'all'], }, - "require": { - "enum": [ - "first", - "exactlyOne", - "all" - ] - } }, - "additionalProperties": false, - "required": [ - "by", - "select", - "require" - ] + additionalProperties: false, + required: ['by', 'select', 'require'], }, { - "type": "object", - "properties": { - "by": { - "const": "ref", - "type": "string" + type: 'object', + properties: { + by: { + const: 'ref', + type: 'string', }, - "ref": { - "type": "string" + ref: { + type: 'string', + }, + within: { + $ref: '#/$defs/BlockNodeAddress', }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "by", - "ref" - ] + additionalProperties: false, + required: ['by', 'ref'], }, { - "type": "object", - "properties": { - "by": { - "const": "target", - "type": "string" + type: 'object', + properties: { + by: { + const: 'target', + type: 'string', + }, + target: { + $ref: '#/$defs/SelectionTarget', }, - "target": { - "$ref": "#/$defs/SelectionTarget" - } }, - "additionalProperties": false, - "required": [ - "by", - "target" - ] + additionalProperties: false, + required: ['by', 'target'], }, { - "type": "object", - "properties": { - "by": { - "const": "block", - "type": "string" + type: 'object', + properties: { + by: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + ], }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt" - ] + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "additionalProperties": false, - "required": [ - "by", - "nodeType", - "nodeId" - ] - } - ] + additionalProperties: false, + required: ['by', 'nodeType', 'nodeId'], + }, + ], }, - "args": { - "type": "object", - "properties": { - "behavior": { - "$ref": "#/$defs/DeleteBehavior" - } + args: { + type: 'object', + properties: { + behavior: { + $ref: '#/$defs/DeleteBehavior', + }, }, - "additionalProperties": false - } - }, - "additionalProperties": false, - "required": [ - "id", - "op", - "where", - "args" - ] + additionalProperties: false, + }, + }, + additionalProperties: false, + required: ['id', 'op', 'where', 'args'], }, { - "type": "object", - "properties": { - "id": { - "type": "string" + type: 'object', + properties: { + id: { + type: 'string', }, - "op": { - "const": "format.apply", - "type": "string" + op: { + const: 'format.apply', + type: 'string', }, - "where": { - "oneOf": [ + where: { + oneOf: [ { - "type": "object", - "properties": { - "by": { - "const": "select", - "type": "string" + type: 'object', + properties: { + by: { + const: 'select', + type: 'string', }, - "select": { - "oneOf": [ + select: { + oneOf: [ { - "type": "object", - "properties": { - "type": { - "const": "text", - "description": "Must be 'text' for text pattern search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'text', + description: "Must be 'text' for text pattern search.", + type: 'string', + }, + pattern: { + type: 'string', + description: 'Text or regex pattern to match.', }, - "pattern": { - "type": "string", - "description": "Text or regex pattern to match." + mode: { + enum: ['contains', 'regex'], + description: "Match mode: 'contains' (substring) or 'regex'.", }, - "mode": { - "enum": [ - "contains", - "regex" - ], - "description": "Match mode: 'contains' (substring) or 'regex'." + caseSensitive: { + type: 'boolean', + description: 'Case-sensitive matching. Default: false.', }, - "caseSensitive": { - "type": "boolean", - "description": "Case-sensitive matching. Default: false." - } }, - "additionalProperties": false, - "required": [ - "type", - "pattern" - ] + additionalProperties: false, + required: ['type', 'pattern'], }, { - "type": "object", - "properties": { - "type": { - "const": "node", - "description": "Must be 'node' for node type search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'node', + description: "Must be 'node' for node type search.", + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt", - "run", - "bookmark", - "comment", - "hyperlink", - "footnoteRef", - "endnoteRef", - "crossRef", - "indexEntry", - "citation", - "authorityEntry", - "sequenceField", - "tab", - "lineBreak" + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'footnoteRef', + 'endnoteRef', + 'crossRef', + 'indexEntry', + 'citation', + 'authorityEntry', + 'sequenceField', + 'tab', + 'lineBreak', ], - "description": "Block type to match (paragraph, heading, table, listItem, etc.)." + description: 'Block type to match (paragraph, heading, table, listItem, etc.).', + }, + kind: { + enum: ['block', 'inline'], + description: "Filter: 'block' or 'inline'.", }, - "kind": { - "enum": [ - "block", - "inline" - ], - "description": "Filter: 'block' or 'inline'." - } }, - "additionalProperties": false, - "required": [ - "type" - ] - } - ] + additionalProperties: false, + required: ['type'], + }, + ], }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" + within: { + $ref: '#/$defs/BlockNodeAddress', + }, + require: { + enum: ['first', 'exactlyOne', 'all'], }, - "require": { - "enum": [ - "first", - "exactlyOne", - "all" - ] - } }, - "additionalProperties": false, - "required": [ - "by", - "select", - "require" - ] + additionalProperties: false, + required: ['by', 'select', 'require'], }, { - "type": "object", - "properties": { - "by": { - "const": "ref", - "type": "string" + type: 'object', + properties: { + by: { + const: 'ref', + type: 'string', + }, + ref: { + type: 'string', }, - "ref": { - "type": "string" + within: { + $ref: '#/$defs/BlockNodeAddress', }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "by", - "ref" - ] + additionalProperties: false, + required: ['by', 'ref'], }, { - "type": "object", - "properties": { - "by": { - "const": "target", - "type": "string" + type: 'object', + properties: { + by: { + const: 'target', + type: 'string', + }, + target: { + $ref: '#/$defs/SelectionTarget', }, - "target": { - "$ref": "#/$defs/SelectionTarget" - } }, - "additionalProperties": false, - "required": [ - "by", - "target" - ] + additionalProperties: false, + required: ['by', 'target'], }, { - "type": "object", - "properties": { - "by": { - "const": "block", - "type": "string" + type: 'object', + properties: { + by: { + const: 'block', + type: 'string', + }, + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + ], }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt" - ] + nodeId: { + type: 'string', }, - "nodeId": { - "type": "string" - } }, - "additionalProperties": false, - "required": [ - "by", - "nodeType", - "nodeId" - ] - } - ] + additionalProperties: false, + required: ['by', 'nodeType', 'nodeId'], + }, + ], }, - "args": { - "type": "object", - "properties": { - "inline": { - "type": "object", - "properties": { - "bold": { - "oneOf": [ + args: { + type: 'object', + properties: { + inline: { + type: 'object', + properties: { + bold: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "italic": { - "oneOf": [ + italic: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "strike": { - "oneOf": [ + strike: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "underline": { - "oneOf": [ + underline: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" + type: 'null', }, { - "type": "object", - "properties": { - "style": { - "oneOf": [ + type: 'object', + properties: { + style: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "themeColor": { - "oneOf": [ + themeColor: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 - } - ] + additionalProperties: false, + minProperties: 1, + }, + ], }, - "highlight": { - "oneOf": [ + highlight: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "fontSize": { - "oneOf": [ + fontSize: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "fontFamily": { - "oneOf": [ + fontFamily: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "letterSpacing": { - "oneOf": [ + letterSpacing: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "vertAlign": { - "oneOf": [ + vertAlign: { + oneOf: [ { - "enum": [ - "superscript", - "subscript", - "baseline" - ] + enum: ['superscript', 'subscript', 'baseline'], }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "position": { - "oneOf": [ + position: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "dstrike": { - "oneOf": [ + dstrike: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "smallCaps": { - "oneOf": [ + smallCaps: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "caps": { - "oneOf": [ + caps: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "shading": { - "oneOf": [ + shading: { + oneOf: [ { - "type": "object", - "properties": { - "fill": { - "oneOf": [ + type: 'object', + properties: { + fill: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "val": { - "oneOf": [ + val: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "border": { - "oneOf": [ + border: { + oneOf: [ { - "type": "object", - "properties": { - "val": { - "oneOf": [ + type: 'object', + properties: { + val: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "sz": { - "oneOf": [ + sz: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "space": { - "oneOf": [ + space: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "outline": { - "oneOf": [ + outline: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "shadow": { - "oneOf": [ + shadow: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "emboss": { - "oneOf": [ + emboss: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "imprint": { - "oneOf": [ + imprint: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "charScale": { - "oneOf": [ + charScale: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "kerning": { - "oneOf": [ + kerning: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "vanish": { - "oneOf": [ + vanish: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "webHidden": { - "oneOf": [ + webHidden: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "specVanish": { - "oneOf": [ + specVanish: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "rtl": { - "oneOf": [ + rtl: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "cs": { - "oneOf": [ + cs: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "bCs": { - "oneOf": [ + bCs: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "iCs": { - "oneOf": [ + iCs: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "eastAsianLayout": { - "oneOf": [ + eastAsianLayout: { + oneOf: [ { - "type": "object", - "properties": { - "id": { - "oneOf": [ + type: 'object', + properties: { + id: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "combine": { - "oneOf": [ + combine: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "combineBrackets": { - "oneOf": [ + combineBrackets: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "vert": { - "oneOf": [ + vert: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "vertCompress": { - "oneOf": [ + vertCompress: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "em": { - "oneOf": [ + em: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "fitText": { - "oneOf": [ + fitText: { + oneOf: [ { - "type": "object", - "properties": { - "val": { - "oneOf": [ + type: 'object', + properties: { + val: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "id": { - "oneOf": [ + id: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "snapToGrid": { - "oneOf": [ + snapToGrid: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "lang": { - "oneOf": [ + lang: { + oneOf: [ { - "type": "object", - "properties": { - "val": { - "oneOf": [ + type: 'object', + properties: { + val: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "eastAsia": { - "oneOf": [ + eastAsia: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "bidi": { - "oneOf": [ + bidi: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "oMath": { - "oneOf": [ + oMath: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "rStyle": { - "oneOf": [ + rStyle: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "rFonts": { - "oneOf": [ + rFonts: { + oneOf: [ { - "type": "object", - "properties": { - "ascii": { - "oneOf": [ + type: 'object', + properties: { + ascii: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "hAnsi": { - "oneOf": [ + hAnsi: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "eastAsia": { - "oneOf": [ + eastAsia: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "cs": { - "oneOf": [ + cs: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "asciiTheme": { - "oneOf": [ + asciiTheme: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "hAnsiTheme": { - "oneOf": [ + hAnsiTheme: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "eastAsiaTheme": { - "oneOf": [ + eastAsiaTheme: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "csTheme": { - "oneOf": [ + csTheme: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "hint": { - "oneOf": [ + hint: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "fontSizeCs": { - "oneOf": [ + fontSizeCs: { + oneOf: [ { - "type": "number" + type: 'number', }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "ligatures": { - "oneOf": [ + ligatures: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "numForm": { - "oneOf": [ + numForm: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "numSpacing": { - "oneOf": [ + numSpacing: { + oneOf: [ { - "type": "string", - "minLength": 1 + type: 'string', + minLength: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "stylisticSets": { - "oneOf": [ + stylisticSets: { + oneOf: [ { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "number" + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'number', + }, + val: { + type: 'boolean', }, - "val": { - "type": "boolean" - } }, - "required": [ - "id" - ], - "additionalProperties": false + required: ['id'], + additionalProperties: false, }, - "minItems": 1 + minItems: 1, }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "contextualAlternates": { - "oneOf": [ + contextualAlternates: { + oneOf: [ { - "type": "boolean" + type: 'boolean', }, { - "type": "null" - } - ] - } + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "minProperties": 1 + additionalProperties: false, + minProperties: 1, }, - "alignment": { - "type": "string", - "enum": [ - "left", - "center", - "right", - "justify" - ], - "description": "Set paragraph alignment on the target block(s). Can be combined with inline formatting in the same step." + alignment: { + type: 'string', + enum: ['left', 'center', 'right', 'justify'], + description: + 'Set paragraph alignment on the target block(s). Can be combined with inline formatting in the same step.', + }, + scope: { + type: 'string', + enum: ['match', 'block'], + description: + 'When "block", inline formatting expands to cover the entire parent paragraph(s), not just the matched text. Use "block" after markdown inserts to format whole paragraphs with a short identifying pattern. Default: "match".', }, - "scope": { - "type": "string", - "enum": [ - "match", - "block" - ], - "description": "When \"block\", inline formatting expands to cover the entire parent paragraph(s), not just the matched text. Use \"block\" after markdown inserts to format whole paragraphs with a short identifying pattern. Default: \"match\"." - } }, - "additionalProperties": false, - "minProperties": 1 - } - }, - "additionalProperties": false, - "required": [ - "id", - "op", - "where", - "args" - ] + additionalProperties: false, + minProperties: 1, + }, + }, + additionalProperties: false, + required: ['id', 'op', 'where', 'args'], }, { - "type": "object", - "properties": { - "id": { - "type": "string" + type: 'object', + properties: { + id: { + type: 'string', }, - "op": { - "const": "assert", - "type": "string" + op: { + const: 'assert', + type: 'string', }, - "where": { - "type": "object", - "properties": { - "by": { - "const": "select", - "type": "string" + where: { + type: 'object', + properties: { + by: { + const: 'select', + type: 'string', }, - "select": { - "oneOf": [ + select: { + oneOf: [ { - "type": "object", - "properties": { - "type": { - "const": "text", - "description": "Must be 'text' for text pattern search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'text', + description: "Must be 'text' for text pattern search.", + type: 'string', }, - "pattern": { - "type": "string", - "description": "Text or regex pattern to match." + pattern: { + type: 'string', + description: 'Text or regex pattern to match.', }, - "mode": { - "enum": [ - "contains", - "regex" - ], - "description": "Match mode: 'contains' (substring) or 'regex'." + mode: { + enum: ['contains', 'regex'], + description: "Match mode: 'contains' (substring) or 'regex'.", + }, + caseSensitive: { + type: 'boolean', + description: 'Case-sensitive matching. Default: false.', }, - "caseSensitive": { - "type": "boolean", - "description": "Case-sensitive matching. Default: false." - } }, - "additionalProperties": false, - "required": [ - "type", - "pattern" - ] + additionalProperties: false, + required: ['type', 'pattern'], }, { - "type": "object", - "properties": { - "type": { - "const": "node", - "description": "Must be 'node' for node type search.", - "type": "string" + type: 'object', + properties: { + type: { + const: 'node', + description: "Must be 'node' for node type search.", + type: 'string', }, - "nodeType": { - "enum": [ - "paragraph", - "heading", - "listItem", - "table", - "tableRow", - "tableCell", - "tableOfContents", - "image", - "sdt", - "run", - "bookmark", - "comment", - "hyperlink", - "footnoteRef", - "endnoteRef", - "crossRef", - "indexEntry", - "citation", - "authorityEntry", - "sequenceField", - "tab", - "lineBreak" + nodeType: { + enum: [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'tableRow', + 'tableCell', + 'tableOfContents', + 'image', + 'sdt', + 'run', + 'bookmark', + 'comment', + 'hyperlink', + 'footnoteRef', + 'endnoteRef', + 'crossRef', + 'indexEntry', + 'citation', + 'authorityEntry', + 'sequenceField', + 'tab', + 'lineBreak', ], - "description": "Block type to match (paragraph, heading, table, listItem, etc.)." + description: 'Block type to match (paragraph, heading, table, listItem, etc.).', + }, + kind: { + enum: ['block', 'inline'], + description: "Filter: 'block' or 'inline'.", }, - "kind": { - "enum": [ - "block", - "inline" - ], - "description": "Filter: 'block' or 'inline'." - } }, - "additionalProperties": false, - "required": [ - "type" - ] - } - ] + additionalProperties: false, + required: ['type'], + }, + ], + }, + within: { + $ref: '#/$defs/BlockNodeAddress', }, - "within": { - "$ref": "#/$defs/BlockNodeAddress" - } }, - "additionalProperties": false, - "required": [ - "by", - "select" - ] + additionalProperties: false, + required: ['by', 'select'], }, - "args": { - "type": "object", - "properties": { - "expectCount": { - "type": "number" - } + args: { + type: 'object', + properties: { + expectCount: { + type: 'number', + }, }, - "additionalProperties": false, - "required": [ - "expectCount" - ] - } - }, - "additionalProperties": false, - "required": [ - "id", - "op", - "where", - "args" - ] - } - ] + additionalProperties: false, + required: ['expectCount'], + }, + }, + additionalProperties: false, + required: ['id', 'op', 'where', 'args'], + }, + ], }, - "description": "Ordered array of mutation steps. Each step needs 'op' (text.rewrite, text.insert, text.delete, format.apply, or assert) and a 'where' targeting clause." + description: + "Ordered array of mutation steps. Each step needs 'op' (text.rewrite, text.insert, text.delete, format.apply, or assert) and a 'where' targeting clause.", + }, + force: { + type: 'boolean', + description: "Bypass confirmation checks. Only for action 'apply'. Omit for other actions.", }, - "force": { - "type": "boolean", - "description": "Bypass confirmation checks. Only for action 'apply'. Omit for other actions." - } }, - "required": [ - "action", - "atomic", - "changeMode", - "steps" - ], - "additionalProperties": false + required: ['action', 'atomic', 'changeMode', 'steps'], + additionalProperties: false, }, - "mutates": true, - "operations": [ + mutates: true, + operations: [ { - "operationId": "doc.mutations.preview", - "intentAction": "preview", - "required": [ - "atomic", - "changeMode", - "steps" - ] + operationId: 'doc.mutations.preview', + intentAction: 'preview', + required: ['atomic', 'changeMode', 'steps'], }, { - "operationId": "doc.mutations.apply", - "intentAction": "apply", - "required": [ - "atomic", - "steps", - "changeMode" - ] - } - ] + operationId: 'doc.mutations.apply', + intentAction: 'apply', + required: ['atomic', 'steps', 'changeMode'], + }, + ], }, { - "toolName": "superdoc_table", - "description": "Create and modify table structure, content, and styling. Find table/row/cell nodeIds via superdoc_get_content({action:\"blocks\"}) or superdoc_search.\n\nACTIONS:\n• Structure: delete, insert_row, delete_row, insert_column, delete_column, merge_cells, unmerge_cells.\n• Cell content: set_cell_text (text). set_cell (vAlign / wrap / fit / preferred width).\n• Row / column: set_row (height + rule), set_row_options (repeat-header, allow-break), set_column (widthPt).\n• Table styling: set_borders, set_shading, set_style_options (headerRow / bandedRows / firstColumn / lastColumn / lastRow / bandedColumns), set_layout (autofit / alignment / direction / preferredWidth), set_options (default cell margins + cell spacing).\n\nLOCATORS (the shapes ops accept):\n• insert_row append shorthand: { nodeId: \"\" } with no rowIndex/position appends at the end. Three other forms: target a row + position, table + rowIndex + position, or any of the above with count:N for multiple.\n• insert_column shorthand: position:\"first\"|\"last\" with no columnIndex. Otherwise columnIndex + position:\"left\"|\"right\".\n• merge_cells: table target + start:{rowIndex, columnIndex} + end:{rowIndex, columnIndex}.\n• set_cell_text: table target + rowIndex + columnIndex (preferred) OR cell target.\n• set_cell: cell target only. Does NOT accept table+rowIndex+columnIndex.\n• set_borders / set_shading: table OR cell target. NOT a row target.\n\nCOLOR FORMAT:\nHex strings accept #RRGGBB, RRGGBB, #RGB, or 3-digit RGB; also \"auto\"; also null to clear (where supported). Stored canonically as uppercase RRGGBB. Always pass a concrete color when one is implied. Never call set_borders with `auto` for a \"make it look [X]\" ask.\n\nSTYLING (TWO MODES):\n\nA. STRUCTURAL CHANGE → re-apply the existing styling.\n Triggers: insert_row / insert_column / delete_row / delete_column / merge_cells / unmerge_cells. (NOT set_cell_text or set_cell: those don't disturb borders/shading.)\n Recipe: read the current borders/shading/cnf flags via superdoc_get_content({action:\"blocks\"}) before the change, then re-apply the SAME values after with set_borders + set_shading + set_style_options. The goal is consistency, not a redesign.\n Skip on a freshly created table. A new table starts un-styled.\n\nB. STYLE-CHANGE REQUEST (\"make it look [X]\" / \"style the whole table\") → apply the FULL set with concrete colors.\n Touch every axis: borders, shading, text alignment, font color/weight, cnf flags, spacing. A single set_borders call without shading and font tweaks always looks half-finished. That's the #1 cause of \"no visual change\" complaints.\n Color palette: discover the document's palette by reading superdoc_get_content({action:\"blocks\"}) and reusing the colors on existing tables/headings. When no palette is obvious, default to corporate blue \"1F3864\" or dark grey \"444444\" for accents and \"F2F2F2\" / \"E7E6E6\" for banding.\n Recipe (call ALL of these):\n 1. set_borders applyTo:\"all\" with an explicit color and weight.\n 2. set_shading on the header row cells with the accent color. Add banding on alternate body rows if appropriate.\n 3. set_style_options { headerRow: true, bandedRows?: true } so cnf regions are recognized.\n 4. Cell-text alignment via superdoc_format action:\"set_alignment\". Center the header, left-align body, right-align numeric columns. Paragraph-level: target the paragraph inside each cell.\n 5. Font color + weight via superdoc_format action:\"inline\". Header gets a contrasting color (white on dark fill, accent on light fill) plus bold:true.\n 6. set_options if the user asks for tighter or looser spacing.\n Steps 4–5 cross to superdoc_format. Use superdoc_mutations to batch many format.apply steps in one call.\n\nAFTER set_cell_text, match the new cell to its siblings:\nset_cell_text writes plain text with the document's default font/size/color and no weight. Always follow up with one superdoc_format inline call copying fontFamily/fontSize/color/bold from a sibling cell (or any non-empty body paragraph if the table is fresh and has no sibling content). If sibling cells show a bold-prefix pattern like \"Label: value\", replicate it on the new cell via superdoc_search + superdoc_format inline (or one superdoc_mutations batch with format.apply steps).\n\nLIST-TO-TABLE:\n(1) superdoc_create action:\"table\" with the desired rows/columns. (2) Populate cells with set_cell_text using rowIndex/columnIndex (one call per cell). (3) DELETE THE WHOLE LIST in one call: superdoc_list({action:\"delete\", target:{kind:\"block\", nodeType:\"listItem\", nodeId:\"\"}}). The op walks the contiguous list and removes all items.\nWrong paths for list deletion (all leave bullets/empty paragraphs behind): text.delete, superdoc_edit action:\"delete\" on text refs, lists.detach, lists.convertToText.\n\nEXAMPLES:\n 1. {\"action\":\"insert_row\",\"nodeId\":\"\"}\n 2. {\"action\":\"insert_column\",\"nodeId\":\"\",\"position\":\"last\"}\n 3. {\"action\":\"merge_cells\",\"nodeId\":\"\",\"start\":{\"rowIndex\":0,\"columnIndex\":0},\"end\":{\"rowIndex\":1,\"columnIndex\":1}}\n 4. {\"action\":\"set_cell_text\",\"nodeId\":\"\",\"rowIndex\":0,\"columnIndex\":0,\"text\":\"Q1 Revenue\"}\n 5. {\"action\":\"set_row\",\"nodeId\":\"\",\"rowIndex\":0,\"heightPt\":24,\"rule\":\"atLeast\"}\n 6. {\"action\":\"set_borders\",\"nodeId\":\"\",\"mode\":\"applyTo\",\"applyTo\":\"all\",\"border\":{\"lineStyle\":\"single\",\"lineWeightPt\":1,\"color\":\"#000000\"}}\n 7. {\"action\":\"set_shading\",\"target\":{\"kind\":\"block\",\"nodeType\":\"tableCell\",\"nodeId\":\"\"},\"color\":\"#E3F2FD\"}\n 8. {\"action\":\"set_style_options\",\"nodeId\":\"\",\"styleOptions\":{\"headerRow\":true,\"bandedRows\":true}}", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "delete", - "delete_column", - "delete_row", - "insert_column", - "insert_row", - "merge_cells", - "set_borders", - "set_cell", - "set_cell_text", - "set_column", - "set_layout", - "set_options", - "set_row", - "set_row_options", - "set_shading", - "set_style_options", - "unmerge_cells" + toolName: 'superdoc_table', + description: + 'Create and modify table structure, content, and styling. Find table/row/cell nodeIds via superdoc_get_content({action:"blocks"}) or superdoc_search.\n\nACTIONS:\n• Structure: delete, insert_row, delete_row, insert_column, delete_column, merge_cells, unmerge_cells.\n• Cell content: set_cell_text (text). set_cell (vAlign / wrap / fit / preferred width).\n• Row / column: set_row (height + rule), set_row_options (repeat-header, allow-break), set_column (widthPt).\n• Table styling: set_borders, set_shading, set_style_options (headerRow / bandedRows / firstColumn / lastColumn / lastRow / bandedColumns), set_layout (autofit / alignment / direction / preferredWidth), set_options (default cell margins + cell spacing).\n\nLOCATORS (the shapes ops accept):\n• insert_row append shorthand: { nodeId: "" } with no rowIndex/position appends at the end. Three other forms: target a row + position, table + rowIndex + position, or any of the above with count:N for multiple.\n• insert_column shorthand: position:"first"|"last" with no columnIndex. Otherwise columnIndex + position:"left"|"right".\n• merge_cells: table target + start:{rowIndex, columnIndex} + end:{rowIndex, columnIndex}.\n• set_cell_text: table target + rowIndex + columnIndex (preferred) OR cell target.\n• set_cell: cell target only. Does NOT accept table+rowIndex+columnIndex.\n• set_borders / set_shading: table OR cell target. NOT a row target.\n\nCOLOR FORMAT:\nHex strings accept #RRGGBB, RRGGBB, #RGB, or 3-digit RGB; also "auto"; also null to clear (where supported). Stored canonically as uppercase RRGGBB. Always pass a concrete color when one is implied. Never call set_borders with `auto` for a "make it look [X]" ask.\n\nSTYLING (TWO MODES):\n\nA. STRUCTURAL CHANGE → re-apply the existing styling.\n Triggers: insert_row / insert_column / delete_row / delete_column / merge_cells / unmerge_cells. (NOT set_cell_text or set_cell: those don\'t disturb borders/shading.)\n Recipe: read the current borders/shading/cnf flags via superdoc_get_content({action:"blocks"}) before the change, then re-apply the SAME values after with set_borders + set_shading + set_style_options. The goal is consistency, not a redesign.\n Skip on a freshly created table. A new table starts un-styled.\n\nB. STYLE-CHANGE REQUEST ("make it look [X]" / "style the whole table") → apply the FULL set with concrete colors.\n Touch every axis: borders, shading, text alignment, font color/weight, cnf flags, spacing. A single set_borders call without shading and font tweaks always looks half-finished. That\'s the #1 cause of "no visual change" complaints.\n Color palette: discover the document\'s palette by reading superdoc_get_content({action:"blocks"}) and reusing the colors on existing tables/headings. When no palette is obvious, default to corporate blue "1F3864" or dark grey "444444" for accents and "F2F2F2" / "E7E6E6" for banding.\n Recipe (call ALL of these):\n 1. set_borders applyTo:"all" with an explicit color and weight.\n 2. set_shading on the header row cells with the accent color. Add banding on alternate body rows if appropriate.\n 3. set_style_options { headerRow: true, bandedRows?: true } so cnf regions are recognized.\n 4. Cell-text alignment via superdoc_format action:"set_alignment". Center the header, left-align body, right-align numeric columns. Paragraph-level: target the paragraph inside each cell.\n 5. Font color + weight via superdoc_format action:"inline". Header gets a contrasting color (white on dark fill, accent on light fill) plus bold:true.\n 6. set_options if the user asks for tighter or looser spacing.\n Steps 4–5 cross to superdoc_format. Use superdoc_mutations to batch many format.apply steps in one call.\n\nAFTER set_cell_text, match the new cell to its siblings:\nset_cell_text writes plain text with the document\'s default font/size/color and no weight. Always follow up with one superdoc_format inline call copying fontFamily/fontSize/color/bold from a sibling cell (or any non-empty body paragraph if the table is fresh and has no sibling content). If sibling cells show a bold-prefix pattern like "Label: value", replicate it on the new cell via superdoc_search + superdoc_format inline (or one superdoc_mutations batch with format.apply steps).\n\nLIST-TO-TABLE:\n(1) superdoc_create action:"table" with the desired rows/columns. (2) Populate cells with set_cell_text using rowIndex/columnIndex (one call per cell). (3) DELETE THE WHOLE LIST in one call: superdoc_list({action:"delete", target:{kind:"block", nodeType:"listItem", nodeId:""}}). The op walks the contiguous list and removes all items.\nWrong paths for list deletion (all leave bullets/empty paragraphs behind): text.delete, superdoc_edit action:"delete" on text refs, lists.detach, lists.convertToText.\n\nEXAMPLES:\n 1. {"action":"insert_row","nodeId":""}\n 2. {"action":"insert_column","nodeId":"","position":"last"}\n 3. {"action":"merge_cells","nodeId":"","start":{"rowIndex":0,"columnIndex":0},"end":{"rowIndex":1,"columnIndex":1}}\n 4. {"action":"set_cell_text","nodeId":"","rowIndex":0,"columnIndex":0,"text":"Q1 Revenue"}\n 5. {"action":"set_row","nodeId":"","rowIndex":0,"heightPt":24,"rule":"atLeast"}\n 6. {"action":"set_borders","nodeId":"","mode":"applyTo","applyTo":"all","border":{"lineStyle":"single","lineWeightPt":1,"color":"#000000"}}\n 7. {"action":"set_shading","target":{"kind":"block","nodeType":"tableCell","nodeId":""},"color":"#E3F2FD"}\n 8. {"action":"set_style_options","nodeId":"","styleOptions":{"headerRow":true,"bandedRows":true}}', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: [ + 'delete', + 'delete_column', + 'delete_row', + 'insert_column', + 'insert_row', + 'merge_cells', + 'set_borders', + 'set_cell', + 'set_cell_text', + 'set_column', + 'set_layout', + 'set_options', + 'set_row', + 'set_row_options', + 'set_shading', + 'set_style_options', + 'unmerge_cells', ], - "description": "The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, set_borders, set_cell, set_cell_text, set_column, set_layout, set_options, set_row, set_row_options, set_shading, set_style_options, unmerge_cells." + description: + 'The action to perform. One of: delete, delete_column, delete_row, insert_column, insert_row, merge_cells, set_borders, set_cell, set_cell_text, set_column, set_layout, set_options, set_row, set_row_options, set_shading, set_style_options, unmerge_cells.', }, - "force": { - "type": "boolean", - "description": "Bypass confirmation checks." + force: { + type: 'boolean', + description: 'Bypass confirmation checks.', }, - "changeMode": { - "type": "string", - "enum": [ - "direct", - "tracked" - ], - "description": "Edit mode: \"direct\" applies changes immediately, \"tracked\" records as suggestions." + changeMode: { + type: 'string', + enum: ['direct', 'tracked'], + description: 'Edit mode: "direct" applies changes immediately, "tracked" records as suggestions.', }, - "dryRun": { - "type": "boolean", - "description": "Preview the result without applying changes." + dryRun: { + type: 'boolean', + description: 'Preview the result without applying changes.', }, - "target": { - "oneOf": [ + target: { + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TableAddress" + $ref: '#/$defs/TableAddress', }, { - "oneOf": [ + oneOf: [ { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TableRowAddress" + $ref: '#/$defs/TableRowAddress', }, { - "$ref": "#/$defs/TableAddress" - } - ] + $ref: '#/$defs/TableAddress', + }, + ], }, { - "$ref": "#/$defs/TableAddress" - } - ] - } - ] + $ref: '#/$defs/TableAddress', + }, + ], + }, + ], }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TableRowAddress" + $ref: '#/$defs/TableRowAddress', }, { - "$ref": "#/$defs/TableAddress" - } - ] - } - ] + $ref: '#/$defs/TableAddress', + }, + ], + }, + ], }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TableRowAddress" + $ref: '#/$defs/TableRowAddress', }, { - "$ref": "#/$defs/TableAddress" - } - ] - } - ] + $ref: '#/$defs/TableAddress', + }, + ], + }, + ], }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TableRowAddress" + $ref: '#/$defs/TableRowAddress', }, { - "$ref": "#/$defs/TableAddress" - } - ] - } - ] + $ref: '#/$defs/TableAddress', + }, + ], + }, + ], }, { - "$ref": "#/$defs/TableAddress" - } - ] + $ref: '#/$defs/TableAddress', + }, + ], }, { - "$ref": "#/$defs/TableAddress" - } - ] + $ref: '#/$defs/TableAddress', + }, + ], }, { - "$ref": "#/$defs/TableAddress" - } - ] + $ref: '#/$defs/TableAddress', + }, + ], }, { - "$ref": "#/$defs/TableAddress" - } - ] + $ref: '#/$defs/TableAddress', + }, + ], }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TableCellAddress" + $ref: '#/$defs/TableCellAddress', }, { - "$ref": "#/$defs/TableAddress" - } - ] - } - ] + $ref: '#/$defs/TableAddress', + }, + ], + }, + ], }, { - "$ref": "#/$defs/TableCellAddress" - } - ] + $ref: '#/$defs/TableCellAddress', + }, + ], }, { - "oneOf": [ + oneOf: [ { - "$ref": "#/$defs/TableCellAddress" + $ref: '#/$defs/TableCellAddress', }, { - "$ref": "#/$defs/TableAddress" - } - ] - } - ] + $ref: '#/$defs/TableAddress', + }, + ], + }, + ], }, { - "$ref": "#/$defs/TableOrCellAddress" - } - ] + $ref: '#/$defs/TableOrCellAddress', + }, + ], }, { - "$ref": "#/$defs/BlockNodeAddress" - } - ] + $ref: '#/$defs/BlockNodeAddress', + }, + ], }, { - "$ref": "#/$defs/BlockNodeAddress" - } - ] + $ref: '#/$defs/BlockNodeAddress', + }, + ], }, { - "$ref": "#/$defs/BlockNodeAddress" - } - ], - "description": "Target address. For inline/set_style: prefer 'ref' from superdoc_search, or use {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. For paragraph actions (set_alignment, set_indentation, set_spacing, set_direction, set_flow_options): use {kind:'block', nodeType:'paragraph'|'heading'|'listItem', nodeId:''}." - }, - "nodeId": { - "type": "string" - }, - "preferredWidth": { - "type": "number", - "description": "Only for action 'set_layout'. Omit for other actions." - }, - "alignment": { - "enum": [ - "left", - "center", - "right" - ], - "description": "Only for action 'set_layout'. Omit for other actions." - }, - "leftIndentPt": { - "type": "number", - "description": "Only for action 'set_layout'. Omit for other actions." - }, - "autoFitMode": { - "enum": [ - "fixedWidth", - "fitContents", - "fitWindow" - ], - "description": "Only for action 'set_layout'. Omit for other actions." - }, - "tableDirection": { - "enum": [ - "ltr", - "rtl" - ], - "description": "Only for action 'set_layout'. Omit for other actions." - }, - "position": { - "enum": [ - "above", - "below", - "left", - "right", - "first", - "last" - ], - "description": "Required for action 'insert_column'." - }, - "count": { - "type": "integer", - "minimum": 1, - "description": "Only for actions 'insert_row', 'insert_column'. Omit for other actions." - }, - "rowIndex": { - "type": "integer", - "minimum": 0, - "description": "Only for actions 'insert_row', 'delete_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions." - }, - "heightPt": { - "type": "number", - "exclusiveMinimum": 0, - "description": "Required for action 'set_row'." - }, - "rule": { - "enum": [ - "atLeast", - "exact", - "auto" + $ref: '#/$defs/BlockNodeAddress', + }, ], - "description": "Required for action 'set_row'." - }, - "allowBreakAcrossPages": { - "type": "boolean", - "description": "Only for action 'set_row_options'. Omit for other actions." - }, - "repeatHeader": { - "type": "boolean", - "description": "Only for action 'set_row_options'. Omit for other actions." - }, - "columnIndex": { - "type": "integer", - "minimum": 0, - "description": "Required for actions 'delete_column', 'set_column'." - }, - "widthPt": { - "type": "number", - "exclusiveMinimum": 0, - "description": "Required for action 'set_column'." - }, - "start": { - "type": "object", - "properties": { - "rowIndex": { - "type": "integer", - "minimum": 0 + description: + "Target address. For inline/set_style: prefer 'ref' from superdoc_search, or use {kind:'selection', start:{kind:'text', blockId, offset}, end:{kind:'text', blockId, offset}}. For paragraph actions (set_alignment, set_indentation, set_spacing, set_direction, set_flow_options): use {kind:'block', nodeType:'paragraph'|'heading'|'listItem', nodeId:''}.", + }, + nodeId: { + type: 'string', + }, + preferredWidth: { + type: 'number', + description: "Only for action 'set_layout'. Omit for other actions.", + }, + alignment: { + enum: ['left', 'center', 'right'], + description: "Only for action 'set_layout'. Omit for other actions.", + }, + leftIndentPt: { + type: 'number', + description: "Only for action 'set_layout'. Omit for other actions.", + }, + autoFitMode: { + enum: ['fixedWidth', 'fitContents', 'fitWindow'], + description: "Only for action 'set_layout'. Omit for other actions.", + }, + tableDirection: { + enum: ['ltr', 'rtl'], + description: "Only for action 'set_layout'. Omit for other actions.", + }, + position: { + enum: ['above', 'below', 'left', 'right', 'first', 'last'], + description: "Required for action 'insert_column'.", + }, + count: { + type: 'integer', + minimum: 1, + description: "Only for actions 'insert_row', 'insert_column'. Omit for other actions.", + }, + rowIndex: { + type: 'integer', + minimum: 0, + description: + "Only for actions 'insert_row', 'delete_row', 'set_row', 'set_row_options', 'unmerge_cells', 'set_cell_text'. Omit for other actions.", + }, + heightPt: { + type: 'number', + exclusiveMinimum: 0, + description: "Required for action 'set_row'.", + }, + rule: { + enum: ['atLeast', 'exact', 'auto'], + description: "Required for action 'set_row'.", + }, + allowBreakAcrossPages: { + type: 'boolean', + description: "Only for action 'set_row_options'. Omit for other actions.", + }, + repeatHeader: { + type: 'boolean', + description: "Only for action 'set_row_options'. Omit for other actions.", + }, + columnIndex: { + type: 'integer', + minimum: 0, + description: "Required for actions 'delete_column', 'set_column'.", + }, + widthPt: { + type: 'number', + exclusiveMinimum: 0, + description: "Required for action 'set_column'.", + }, + start: { + type: 'object', + properties: { + rowIndex: { + type: 'integer', + minimum: 0, + }, + columnIndex: { + type: 'integer', + minimum: 0, }, - "columnIndex": { - "type": "integer", - "minimum": 0 - } }, - "additionalProperties": false, - "required": [ - "rowIndex", - "columnIndex" - ], - "description": "Required for action 'merge_cells'." - }, - "end": { - "type": "object", - "properties": { - "rowIndex": { - "type": "integer", - "minimum": 0 + additionalProperties: false, + required: ['rowIndex', 'columnIndex'], + description: "Required for action 'merge_cells'.", + }, + end: { + type: 'object', + properties: { + rowIndex: { + type: 'integer', + minimum: 0, + }, + columnIndex: { + type: 'integer', + minimum: 0, }, - "columnIndex": { - "type": "integer", - "minimum": 0 - } }, - "additionalProperties": false, - "required": [ - "rowIndex", - "columnIndex" - ], - "description": "Required for action 'merge_cells'." + additionalProperties: false, + required: ['rowIndex', 'columnIndex'], + description: "Required for action 'merge_cells'.", }, - "preferredWidthPt": { - "type": "number", - "description": "Only for action 'set_cell'. Omit for other actions." + preferredWidthPt: { + type: 'number', + description: "Only for action 'set_cell'. Omit for other actions.", }, - "verticalAlign": { - "enum": [ - "top", - "center", - "bottom" - ], - "description": "Only for action 'set_cell'. Omit for other actions." + verticalAlign: { + enum: ['top', 'center', 'bottom'], + description: "Only for action 'set_cell'. Omit for other actions.", }, - "wrapText": { - "type": "boolean", - "description": "Only for action 'set_cell'. Omit for other actions." + wrapText: { + type: 'boolean', + description: "Only for action 'set_cell'. Omit for other actions.", }, - "fitText": { - "type": "boolean", - "description": "Only for action 'set_cell'. Omit for other actions." + fitText: { + type: 'boolean', + description: "Only for action 'set_cell'. Omit for other actions.", }, - "text": { - "type": "string", - "description": "Required for action 'set_cell_text'." + text: { + type: 'string', + description: "Required for action 'set_cell_text'.", }, - "color": { - "oneOf": [ + color: { + oneOf: [ { - "type": "string", - "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" + type: 'string', + pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', }, { - "type": "null" - } + type: 'null', + }, ], - "description": "Required for action 'set_shading'." + description: "Required for action 'set_shading'.", }, - "styleId": { - "type": "string", - "description": "Only for action 'set_style_options'. Omit for other actions." + styleId: { + type: 'string', + description: "Only for action 'set_style_options'. Omit for other actions.", }, - "styleOptions": { - "type": "object", - "properties": { - "headerRow": { - "type": "boolean" + styleOptions: { + type: 'object', + properties: { + headerRow: { + type: 'boolean', }, - "lastRow": { - "type": "boolean" + lastRow: { + type: 'boolean', }, - "totalRow": { - "type": "boolean" + totalRow: { + type: 'boolean', }, - "firstColumn": { - "type": "boolean" + firstColumn: { + type: 'boolean', }, - "lastColumn": { - "type": "boolean" + lastColumn: { + type: 'boolean', }, - "bandedRows": { - "type": "boolean" + bandedRows: { + type: 'boolean', + }, + bandedColumns: { + type: 'boolean', }, - "bandedColumns": { - "type": "boolean" - } }, - "additionalProperties": false, - "description": "Only for action 'set_style_options'. Omit for other actions." + additionalProperties: false, + description: "Only for action 'set_style_options'. Omit for other actions.", }, - "mode": { - "enum": [ - "applyTo", - "edges" - ], - "description": "Required for action 'set_borders'." - }, - "applyTo": { - "enum": [ - "all", - "outside", - "inside", - "top", - "bottom", - "left", - "right", - "insideH", - "insideV" - ], - "description": "Only for action 'set_borders'. Omit for other actions." + mode: { + enum: ['applyTo', 'edges'], + description: "Required for action 'set_borders'.", + }, + applyTo: { + enum: ['all', 'outside', 'inside', 'top', 'bottom', 'left', 'right', 'insideH', 'insideV'], + description: "Only for action 'set_borders'. Omit for other actions.", }, - "border": { - "oneOf": [ + border: { + oneOf: [ { - "type": "object", - "properties": { - "lineStyle": { - "type": "string" - }, - "lineWeightPt": { - "type": "number", - "exclusiveMinimum": 0 - }, - "color": { - "type": "string", - "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" - } + type: 'object', + properties: { + lineStyle: { + type: 'string', + }, + lineWeightPt: { + type: 'number', + exclusiveMinimum: 0, + }, + color: { + type: 'string', + pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', + }, }, - "additionalProperties": false, - "required": [ - "lineStyle", - "lineWeightPt", - "color" - ] + additionalProperties: false, + required: ['lineStyle', 'lineWeightPt', 'color'], }, { - "type": "null" - } + type: 'null', + }, ], - "description": "Only for action 'set_borders'. Omit for other actions." - }, - "edges": { - "type": "object", - "properties": { - "top": { - "oneOf": [ - { - "type": "object", - "properties": { - "lineStyle": { - "type": "string" + description: "Only for action 'set_borders'. Omit for other actions.", + }, + edges: { + type: 'object', + properties: { + top: { + oneOf: [ + { + type: 'object', + properties: { + lineStyle: { + type: 'string', }, - "lineWeightPt": { - "type": "number", - "exclusiveMinimum": 0 + lineWeightPt: { + type: 'number', + exclusiveMinimum: 0, + }, + color: { + type: 'string', + pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', }, - "color": { - "type": "string", - "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" - } }, - "additionalProperties": false, - "required": [ - "lineStyle", - "lineWeightPt", - "color" - ] + additionalProperties: false, + required: ['lineStyle', 'lineWeightPt', 'color'], }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "bottom": { - "oneOf": [ + bottom: { + oneOf: [ { - "type": "object", - "properties": { - "lineStyle": { - "type": "string" + type: 'object', + properties: { + lineStyle: { + type: 'string', + }, + lineWeightPt: { + type: 'number', + exclusiveMinimum: 0, }, - "lineWeightPt": { - "type": "number", - "exclusiveMinimum": 0 + color: { + type: 'string', + pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', }, - "color": { - "type": "string", - "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" - } }, - "additionalProperties": false, - "required": [ - "lineStyle", - "lineWeightPt", - "color" - ] + additionalProperties: false, + required: ['lineStyle', 'lineWeightPt', 'color'], }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "left": { - "oneOf": [ + left: { + oneOf: [ { - "type": "object", - "properties": { - "lineStyle": { - "type": "string" + type: 'object', + properties: { + lineStyle: { + type: 'string', }, - "lineWeightPt": { - "type": "number", - "exclusiveMinimum": 0 + lineWeightPt: { + type: 'number', + exclusiveMinimum: 0, + }, + color: { + type: 'string', + pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', }, - "color": { - "type": "string", - "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" - } }, - "additionalProperties": false, - "required": [ - "lineStyle", - "lineWeightPt", - "color" - ] + additionalProperties: false, + required: ['lineStyle', 'lineWeightPt', 'color'], }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "right": { - "oneOf": [ + right: { + oneOf: [ { - "type": "object", - "properties": { - "lineStyle": { - "type": "string" + type: 'object', + properties: { + lineStyle: { + type: 'string', + }, + lineWeightPt: { + type: 'number', + exclusiveMinimum: 0, }, - "lineWeightPt": { - "type": "number", - "exclusiveMinimum": 0 + color: { + type: 'string', + pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', }, - "color": { - "type": "string", - "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" - } }, - "additionalProperties": false, - "required": [ - "lineStyle", - "lineWeightPt", - "color" - ] + additionalProperties: false, + required: ['lineStyle', 'lineWeightPt', 'color'], }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "insideH": { - "oneOf": [ + insideH: { + oneOf: [ { - "type": "object", - "properties": { - "lineStyle": { - "type": "string" + type: 'object', + properties: { + lineStyle: { + type: 'string', }, - "lineWeightPt": { - "type": "number", - "exclusiveMinimum": 0 + lineWeightPt: { + type: 'number', + exclusiveMinimum: 0, + }, + color: { + type: 'string', + pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', }, - "color": { - "type": "string", - "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" - } }, - "additionalProperties": false, - "required": [ - "lineStyle", - "lineWeightPt", - "color" - ] + additionalProperties: false, + required: ['lineStyle', 'lineWeightPt', 'color'], }, { - "type": "null" - } - ] + type: 'null', + }, + ], }, - "insideV": { - "oneOf": [ + insideV: { + oneOf: [ { - "type": "object", - "properties": { - "lineStyle": { - "type": "string" + type: 'object', + properties: { + lineStyle: { + type: 'string', + }, + lineWeightPt: { + type: 'number', + exclusiveMinimum: 0, }, - "lineWeightPt": { - "type": "number", - "exclusiveMinimum": 0 + color: { + type: 'string', + pattern: '^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$', }, - "color": { - "type": "string", - "pattern": "^(#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})|auto)$" - } }, - "additionalProperties": false, - "required": [ - "lineStyle", - "lineWeightPt", - "color" - ] - }, - { - "type": "null" - } - ] - } + additionalProperties: false, + required: ['lineStyle', 'lineWeightPt', 'color'], + }, + { + type: 'null', + }, + ], + }, }, - "additionalProperties": false, - "description": "Only for action 'set_borders'. Omit for other actions." - }, - "defaultCellMargins": { - "type": "object", - "properties": { - "topPt": { - "type": "number", - "minimum": 0 + additionalProperties: false, + description: "Only for action 'set_borders'. Omit for other actions.", + }, + defaultCellMargins: { + type: 'object', + properties: { + topPt: { + type: 'number', + minimum: 0, + }, + rightPt: { + type: 'number', + minimum: 0, }, - "rightPt": { - "type": "number", - "minimum": 0 + bottomPt: { + type: 'number', + minimum: 0, }, - "bottomPt": { - "type": "number", - "minimum": 0 + leftPt: { + type: 'number', + minimum: 0, }, - "leftPt": { - "type": "number", - "minimum": 0 - } }, - "additionalProperties": false, - "required": [ - "topPt", - "rightPt", - "bottomPt", - "leftPt" - ], - "description": "Only for action 'set_options'. Omit for other actions." + additionalProperties: false, + required: ['topPt', 'rightPt', 'bottomPt', 'leftPt'], + description: "Only for action 'set_options'. Omit for other actions.", }, - "cellSpacingPt": { - "oneOf": [ + cellSpacingPt: { + oneOf: [ { - "type": "number", - "minimum": 0 + type: 'number', + minimum: 0, }, { - "type": "null" - } + type: 'null', + }, ], - "description": "Only for action 'set_options'. Omit for other actions." - } + description: "Only for action 'set_options'. Omit for other actions.", + }, }, - "required": [ - "action" - ], - "additionalProperties": false + required: ['action'], + additionalProperties: false, }, - "mutates": true, - "operations": [ + mutates: true, + operations: [ { - "operationId": "doc.tables.delete", - "intentAction": "delete", - "requiredOneOf": [ - [ - "target" - ], - [ - "nodeId" - ] - ] + operationId: 'doc.tables.delete', + intentAction: 'delete', + requiredOneOf: [['target'], ['nodeId']], }, { - "operationId": "doc.tables.setLayout", - "intentAction": "set_layout", - "requiredOneOf": [ - [ - "target" - ], - [ - "nodeId" - ] - ] + operationId: 'doc.tables.setLayout', + intentAction: 'set_layout', + requiredOneOf: [['target'], ['nodeId']], }, { - "operationId": "doc.tables.insertRow", - "intentAction": "insert_row", - "requiredOneOf": [ - [ - "target", - "position" - ], - [ - "target", - "rowIndex", - "position" - ], - [ - "nodeId", - "rowIndex", - "position" - ], - [ - "target" - ], - [ - "nodeId" - ] - ] + operationId: 'doc.tables.insertRow', + intentAction: 'insert_row', + requiredOneOf: [ + ['target', 'position'], + ['target', 'rowIndex', 'position'], + ['nodeId', 'rowIndex', 'position'], + ['target'], + ['nodeId'], + ], }, { - "operationId": "doc.tables.deleteRow", - "intentAction": "delete_row", - "requiredOneOf": [ - [ - "target" - ], - [ - "target", - "rowIndex" - ], - [ - "nodeId", - "rowIndex" - ] - ] + operationId: 'doc.tables.deleteRow', + intentAction: 'delete_row', + requiredOneOf: [['target'], ['target', 'rowIndex'], ['nodeId', 'rowIndex']], }, { - "operationId": "doc.tables.setRowHeight", - "intentAction": "set_row", - "requiredOneOf": [ - [ - "target", - "heightPt", - "rule" - ], - [ - "target", - "rowIndex", - "heightPt", - "rule" - ], - [ - "nodeId", - "rowIndex", - "heightPt", - "rule" - ] - ] + operationId: 'doc.tables.setRowHeight', + intentAction: 'set_row', + requiredOneOf: [ + ['target', 'heightPt', 'rule'], + ['target', 'rowIndex', 'heightPt', 'rule'], + ['nodeId', 'rowIndex', 'heightPt', 'rule'], + ], }, { - "operationId": "doc.tables.setRowOptions", - "intentAction": "set_row_options", - "requiredOneOf": [ - [ - "target" - ], - [ - "target", - "rowIndex" - ], - [ - "nodeId", - "rowIndex" - ] - ] + operationId: 'doc.tables.setRowOptions', + intentAction: 'set_row_options', + requiredOneOf: [['target'], ['target', 'rowIndex'], ['nodeId', 'rowIndex']], }, { - "operationId": "doc.tables.insertColumn", - "intentAction": "insert_column", - "requiredOneOf": [ - [ - "position", - "target" - ], - [ - "position", - "nodeId" - ] - ] + operationId: 'doc.tables.insertColumn', + intentAction: 'insert_column', + requiredOneOf: [ + ['position', 'target'], + ['position', 'nodeId'], + ], }, { - "operationId": "doc.tables.deleteColumn", - "intentAction": "delete_column", - "requiredOneOf": [ - [ - "columnIndex", - "target" - ], - [ - "columnIndex", - "nodeId" - ] - ] + operationId: 'doc.tables.deleteColumn', + intentAction: 'delete_column', + requiredOneOf: [ + ['columnIndex', 'target'], + ['columnIndex', 'nodeId'], + ], }, { - "operationId": "doc.tables.setColumnWidth", - "intentAction": "set_column", - "requiredOneOf": [ - [ - "columnIndex", - "widthPt", - "target" - ], - [ - "columnIndex", - "widthPt", - "nodeId" - ] - ] + operationId: 'doc.tables.setColumnWidth', + intentAction: 'set_column', + requiredOneOf: [ + ['columnIndex', 'widthPt', 'target'], + ['columnIndex', 'widthPt', 'nodeId'], + ], }, { - "operationId": "doc.tables.mergeCells", - "intentAction": "merge_cells", - "requiredOneOf": [ - [ - "start", - "end", - "target" - ], - [ - "start", - "end", - "nodeId" - ] - ] + operationId: 'doc.tables.mergeCells', + intentAction: 'merge_cells', + requiredOneOf: [ + ['start', 'end', 'target'], + ['start', 'end', 'nodeId'], + ], }, { - "operationId": "doc.tables.unmergeCells", - "intentAction": "unmerge_cells", - "requiredOneOf": [ - [ - "target" - ], - [ - "nodeId" - ], - [ - "target", - "rowIndex", - "columnIndex" - ], - [ - "nodeId", - "rowIndex", - "columnIndex" - ] - ] + operationId: 'doc.tables.unmergeCells', + intentAction: 'unmerge_cells', + requiredOneOf: [ + ['target'], + ['nodeId'], + ['target', 'rowIndex', 'columnIndex'], + ['nodeId', 'rowIndex', 'columnIndex'], + ], }, { - "operationId": "doc.tables.setCellProperties", - "intentAction": "set_cell", - "requiredOneOf": [ - [ - "target" - ], - [ - "nodeId" - ] - ] + operationId: 'doc.tables.setCellProperties', + intentAction: 'set_cell', + requiredOneOf: [['target'], ['nodeId']], }, { - "operationId": "doc.tables.setCellText", - "intentAction": "set_cell_text", - "requiredOneOf": [ - [ - "target", - "text" - ], - [ - "nodeId", - "text" - ], - [ - "target", - "rowIndex", - "columnIndex", - "text" - ], - [ - "nodeId", - "rowIndex", - "columnIndex", - "text" - ] - ] + operationId: 'doc.tables.setCellText', + intentAction: 'set_cell_text', + requiredOneOf: [ + ['target', 'text'], + ['nodeId', 'text'], + ['target', 'rowIndex', 'columnIndex', 'text'], + ['nodeId', 'rowIndex', 'columnIndex', 'text'], + ], }, { - "operationId": "doc.tables.setShading", - "intentAction": "set_shading", - "requiredOneOf": [ - [ - "color", - "target" - ], - [ - "color", - "nodeId" - ] - ] + operationId: 'doc.tables.setShading', + intentAction: 'set_shading', + requiredOneOf: [ + ['color', 'target'], + ['color', 'nodeId'], + ], }, { - "operationId": "doc.tables.applyStyle", - "intentAction": "set_style_options", - "requiredOneOf": [ - [ - "target" - ], - [ - "nodeId" - ] - ] + operationId: 'doc.tables.applyStyle', + intentAction: 'set_style_options', + requiredOneOf: [['target'], ['nodeId']], }, { - "operationId": "doc.tables.setBorders", - "intentAction": "set_borders", - "requiredOneOf": [ - [ - "mode", - "applyTo", - "border", - "target" - ], - [ - "mode", - "applyTo", - "border", - "nodeId" - ], - [ - "mode", - "edges", - "target" - ], - [ - "mode", - "edges", - "nodeId" - ] - ] + operationId: 'doc.tables.setBorders', + intentAction: 'set_borders', + requiredOneOf: [ + ['mode', 'applyTo', 'border', 'target'], + ['mode', 'applyTo', 'border', 'nodeId'], + ['mode', 'edges', 'target'], + ['mode', 'edges', 'nodeId'], + ], }, { - "operationId": "doc.tables.setTableOptions", - "intentAction": "set_options", - "requiredOneOf": [ - [ - "target" - ], - [ - "nodeId" - ] - ] - } - ] - } - ] + operationId: 'doc.tables.setTableOptions', + intentAction: 'set_options', + requiredOneOf: [['target'], ['nodeId']], + }, + ], + }, + ], } as const; diff --git a/apps/mcp/src/generated/intent-dispatch.generated.ts b/apps/mcp/src/generated/intent-dispatch.generated.ts index caaba98191..82bbd96b37 100644 --- a/apps/mcp/src/generated/intent-dispatch.generated.ts +++ b/apps/mcp/src/generated/intent-dispatch.generated.ts @@ -10,84 +10,132 @@ export function dispatchIntentTool( case 'superdoc_get_content': { const { action, ...rest } = args; switch (action) { - case 'text': return execute('doc.getText', rest); - case 'markdown': return execute('doc.getMarkdown', rest); - case 'html': return execute('doc.getHtml', rest); - case 'info': return execute('doc.info', rest); - case 'extract': return execute('doc.extract', rest); - case 'blocks': return execute('doc.blocks.list', rest); - default: throw new Error(`Unknown action for superdoc_get_content: ${action}`); + case 'text': + return execute('doc.getText', rest); + case 'markdown': + return execute('doc.getMarkdown', rest); + case 'html': + return execute('doc.getHtml', rest); + case 'info': + return execute('doc.info', rest); + case 'extract': + return execute('doc.extract', rest); + case 'blocks': + return execute('doc.blocks.list', rest); + default: + throw new Error(`Unknown action for superdoc_get_content: ${action}`); } } case 'superdoc_edit': { const { action, ...rest } = args; switch (action) { - case 'insert': return execute('doc.insert', rest); - case 'replace': return execute('doc.replace', rest); - case 'delete': return execute('doc.delete', rest); - case 'undo': return execute('doc.history.undo', rest); - case 'redo': return execute('doc.history.redo', rest); - default: throw new Error(`Unknown action for superdoc_edit: ${action}`); + case 'insert': + return execute('doc.insert', rest); + case 'replace': + return execute('doc.replace', rest); + case 'delete': + return execute('doc.delete', rest); + case 'undo': + return execute('doc.history.undo', rest); + case 'redo': + return execute('doc.history.redo', rest); + default: + throw new Error(`Unknown action for superdoc_edit: ${action}`); } } case 'superdoc_format': { const { action, ...rest } = args; switch (action) { - case 'inline': return execute('doc.format.apply', rest); - case 'set_style': return execute('doc.styles.paragraph.setStyle', rest); - case 'set_alignment': return execute('doc.format.paragraph.setAlignment', rest); - case 'set_indentation': return execute('doc.format.paragraph.setIndentation', rest); - case 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest); - case 'set_flow_options': return execute('doc.format.paragraph.setFlowOptions', rest); - case 'set_direction': return execute('doc.format.paragraph.setDirection', rest); - default: throw new Error(`Unknown action for superdoc_format: ${action}`); + case 'inline': + return execute('doc.format.apply', rest); + case 'set_style': + return execute('doc.styles.paragraph.setStyle', rest); + case 'set_alignment': + return execute('doc.format.paragraph.setAlignment', rest); + case 'set_indentation': + return execute('doc.format.paragraph.setIndentation', rest); + case 'set_spacing': + return execute('doc.format.paragraph.setSpacing', rest); + case 'set_flow_options': + return execute('doc.format.paragraph.setFlowOptions', rest); + case 'set_direction': + return execute('doc.format.paragraph.setDirection', rest); + default: + throw new Error(`Unknown action for superdoc_format: ${action}`); } } case 'superdoc_create': { const { action, ...rest } = args; switch (action) { - case 'paragraph': return execute('doc.create.paragraph', rest); - case 'heading': return execute('doc.create.heading', rest); - case 'table': return execute('doc.create.table', rest); - default: throw new Error(`Unknown action for superdoc_create: ${action}`); + case 'paragraph': + return execute('doc.create.paragraph', rest); + case 'heading': + return execute('doc.create.heading', rest); + case 'table': + return execute('doc.create.table', rest); + default: + throw new Error(`Unknown action for superdoc_create: ${action}`); } } case 'superdoc_list': { const { action, ...rest } = args; switch (action) { - case 'insert': return execute('doc.lists.insert', rest); - case 'create': return execute('doc.lists.create', rest); - case 'attach': return execute('doc.lists.attach', rest); - case 'detach': return execute('doc.lists.detach', rest); - case 'delete': return execute('doc.lists.delete', rest); - case 'indent': return execute('doc.lists.indent', rest); - case 'outdent': return execute('doc.lists.outdent', rest); - case 'merge': return execute('doc.lists.merge', rest); - case 'split': return execute('doc.lists.split', rest); - case 'set_level': return execute('doc.lists.setLevel', rest); - case 'set_value': return execute('doc.lists.setValue', rest); - case 'continue_previous': return execute('doc.lists.continuePrevious', rest); - case 'set_type': return execute('doc.lists.setType', rest); - default: throw new Error(`Unknown action for superdoc_list: ${action}`); + case 'insert': + return execute('doc.lists.insert', rest); + case 'create': + return execute('doc.lists.create', rest); + case 'attach': + return execute('doc.lists.attach', rest); + case 'detach': + return execute('doc.lists.detach', rest); + case 'delete': + return execute('doc.lists.delete', rest); + case 'indent': + return execute('doc.lists.indent', rest); + case 'outdent': + return execute('doc.lists.outdent', rest); + case 'merge': + return execute('doc.lists.merge', rest); + case 'split': + return execute('doc.lists.split', rest); + case 'set_level': + return execute('doc.lists.setLevel', rest); + case 'set_value': + return execute('doc.lists.setValue', rest); + case 'continue_previous': + return execute('doc.lists.continuePrevious', rest); + case 'set_type': + return execute('doc.lists.setType', rest); + default: + throw new Error(`Unknown action for superdoc_list: ${action}`); } } case 'superdoc_comment': { const { action, ...rest } = args; switch (action) { - case 'create': return execute('doc.comments.create', rest); - case 'update': return execute('doc.comments.patch', rest); - case 'delete': return execute('doc.comments.delete', rest); - case 'get': return execute('doc.comments.get', rest); - case 'list': return execute('doc.comments.list', rest); - default: throw new Error(`Unknown action for superdoc_comment: ${action}`); + case 'create': + return execute('doc.comments.create', rest); + case 'update': + return execute('doc.comments.patch', rest); + case 'delete': + return execute('doc.comments.delete', rest); + case 'get': + return execute('doc.comments.get', rest); + case 'list': + return execute('doc.comments.list', rest); + default: + throw new Error(`Unknown action for superdoc_comment: ${action}`); } } case 'superdoc_track_changes': { const { action, ...rest } = args; switch (action) { - case 'list': return execute('doc.trackChanges.list', rest); - case 'decide': return execute('doc.trackChanges.decide', rest); - default: throw new Error(`Unknown action for superdoc_track_changes: ${action}`); + case 'list': + return execute('doc.trackChanges.list', rest); + case 'decide': + return execute('doc.trackChanges.decide', rest); + default: + throw new Error(`Unknown action for superdoc_track_changes: ${action}`); } } case 'superdoc_search': @@ -95,32 +143,53 @@ export function dispatchIntentTool( case 'superdoc_mutations': { const { action, ...rest } = args; switch (action) { - case 'preview': return execute('doc.mutations.preview', rest); - case 'apply': return execute('doc.mutations.apply', rest); - default: throw new Error(`Unknown action for superdoc_mutations: ${action}`); + case 'preview': + return execute('doc.mutations.preview', rest); + case 'apply': + return execute('doc.mutations.apply', rest); + default: + throw new Error(`Unknown action for superdoc_mutations: ${action}`); } } case 'superdoc_table': { const { action, ...rest } = args; switch (action) { - case 'delete': return execute('doc.tables.delete', rest); - case 'set_layout': return execute('doc.tables.setLayout', rest); - case 'insert_row': return execute('doc.tables.insertRow', rest); - case 'delete_row': return execute('doc.tables.deleteRow', rest); - case 'set_row': return execute('doc.tables.setRowHeight', rest); - case 'set_row_options': return execute('doc.tables.setRowOptions', rest); - case 'insert_column': return execute('doc.tables.insertColumn', rest); - case 'delete_column': return execute('doc.tables.deleteColumn', rest); - case 'set_column': return execute('doc.tables.setColumnWidth', rest); - case 'merge_cells': return execute('doc.tables.mergeCells', rest); - case 'unmerge_cells': return execute('doc.tables.unmergeCells', rest); - case 'set_cell': return execute('doc.tables.setCellProperties', rest); - case 'set_cell_text': return execute('doc.tables.setCellText', rest); - case 'set_shading': return execute('doc.tables.setShading', rest); - case 'set_style_options': return execute('doc.tables.applyStyle', rest); - case 'set_borders': return execute('doc.tables.setBorders', rest); - case 'set_options': return execute('doc.tables.setTableOptions', rest); - default: throw new Error(`Unknown action for superdoc_table: ${action}`); + case 'delete': + return execute('doc.tables.delete', rest); + case 'set_layout': + return execute('doc.tables.setLayout', rest); + case 'insert_row': + return execute('doc.tables.insertRow', rest); + case 'delete_row': + return execute('doc.tables.deleteRow', rest); + case 'set_row': + return execute('doc.tables.setRowHeight', rest); + case 'set_row_options': + return execute('doc.tables.setRowOptions', rest); + case 'insert_column': + return execute('doc.tables.insertColumn', rest); + case 'delete_column': + return execute('doc.tables.deleteColumn', rest); + case 'set_column': + return execute('doc.tables.setColumnWidth', rest); + case 'merge_cells': + return execute('doc.tables.mergeCells', rest); + case 'unmerge_cells': + return execute('doc.tables.unmergeCells', rest); + case 'set_cell': + return execute('doc.tables.setCellProperties', rest); + case 'set_cell_text': + return execute('doc.tables.setCellText', rest); + case 'set_shading': + return execute('doc.tables.setShading', rest); + case 'set_style_options': + return execute('doc.tables.applyStyle', rest); + case 'set_borders': + return execute('doc.tables.setBorders', rest); + case 'set_options': + return execute('doc.tables.setTableOptions', rest); + default: + throw new Error(`Unknown action for superdoc_table: ${action}`); } } default: diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index bfc8dac321..531721922b 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -31,10 +31,8 @@ const TEMPLATES_APPLY_RECEIPT_FAILURE_CODES = [ // Exhaustiveness: assigning the union to the array's element type (and vice // versa) guarantees the list above covers every TemplatesApplyFailureCode value. -type _TemplatesFailureCoverageForward = TemplatesApplyFailureCode extends - (typeof TEMPLATES_APPLY_RECEIPT_FAILURE_CODES)[number] - ? true - : never; +type _TemplatesFailureCoverageForward = + TemplatesApplyFailureCode extends (typeof TEMPLATES_APPLY_RECEIPT_FAILURE_CODES)[number] ? true : never; const _templatesFailureCoverage: _TemplatesFailureCoverageForward = true; void _templatesFailureCoverage; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 4c216fb02e..122f25cde1 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -3816,10 +3816,10 @@ const operationSchemas: Record = { 'sectionDefaults', ], }; - const scopeReportSchema = objectSchema( - { scope: scopeEnum, part: { type: 'string' }, detail: { type: 'string' } }, - ['scope', 'part'], - ); + const scopeReportSchema = objectSchema({ scope: scopeEnum, part: { type: 'string' }, detail: { type: 'string' } }, [ + 'scope', + 'part', + ]); const scopeSkipSchema = objectSchema( { scope: { type: 'string' }, diff --git a/packages/document-api/src/templates/apply.test.ts b/packages/document-api/src/templates/apply.test.ts index 2fe11296ac..d92d3c300e 100644 --- a/packages/document-api/src/templates/apply.test.ts +++ b/packages/document-api/src/templates/apply.test.ts @@ -99,9 +99,9 @@ describe('executeTemplatesApply contract', () => { it('throws INVALID_INPUT when path source has empty path', () => { const adapter = makeAdapter(); - expect(() => - executeTemplatesApply(adapter, { source: { kind: 'path', path: '' } } as TemplatesApplyInput), - ).toThrow(DocumentApiValidationError); + expect(() => executeTemplatesApply(adapter, { source: { kind: 'path', path: '' } } as TemplatesApplyInput)).toThrow( + DocumentApiValidationError, + ); }); it('throws INVALID_INPUT when base64 source has empty data', () => { diff --git a/packages/document-api/src/templates/apply.ts b/packages/document-api/src/templates/apply.ts index 2de160ecfc..c7dd9d5d33 100644 --- a/packages/document-api/src/templates/apply.ts +++ b/packages/document-api/src/templates/apply.ts @@ -76,11 +76,7 @@ export interface TemplateScopeReport { detail?: string; } -export type TemplateSkipReason = - | 'NOT_PRESENT_IN_SOURCE' - | 'OUT_OF_SCOPE' - | 'NO_CHANGE' - | 'CAPABILITY_UNAVAILABLE'; +export type TemplateSkipReason = 'NOT_PRESENT_IN_SOURCE' | 'OUT_OF_SCOPE' | 'NO_CHANGE' | 'CAPABILITY_UNAVAILABLE'; export interface TemplateScopeSkip { scope: string; diff --git a/packages/layout-engine/contracts/src/column-layout.test.ts b/packages/layout-engine/contracts/src/column-layout.test.ts index 308db41899..56dacc4040 100644 --- a/packages/layout-engine/contracts/src/column-layout.test.ts +++ b/packages/layout-engine/contracts/src/column-layout.test.ts @@ -1,6 +1,21 @@ import { describe, expect, it } from 'vitest'; import type { ColumnLayout } from './index.js'; -import { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; +import { + cloneColumnLayout, + columnLayoutsEqual, + columnRenderLayoutsEqual, + getColumnAtX, + getColumnGapAfter, + getColumnGeometry, + getColumnSeparatorPositions, + getColumnWidth, + getColumnX, + normalizeColumnLayout, + resolveColumnCount, + resolveColumnLayout, + resolveColumnMode, + widthsEqual, +} from './column-layout.js'; describe('widthsEqual', () => { it('treats two missing width arrays as equal', () => { @@ -135,3 +150,205 @@ describe('normalizeColumnLayout', () => { }); }); }); + +describe('getColumnGeometry + geometry helpers (SD-2629, behavior-preserving)', () => { + it('mirrors equal-width normalized output (uniform gap, content-relative x)', () => { + const geom = getColumnGeometry(normalizeColumnLayout({ count: 2, gap: 24 }, 624)); + expect(geom).toEqual([ + { index: 0, x: 0, width: 300, gapAfter: 24 }, + { index: 1, x: 324, width: 300, gapAfter: 0 }, + ]); + }); + + it('mirrors explicit (scaled) widths', () => { + const geom = getColumnGeometry( + normalizeColumnLayout({ count: 2, gap: 24, widths: [100, 200], equalWidth: false }, 624), + ); + expect(geom).toEqual([ + { index: 0, x: 0, width: 200, gapAfter: 24 }, + { index: 1, x: 224, width: 400, gapAfter: 0 }, + ]); + }); + + it('reflects the F8 count clamp (4 declared, 2 widths => 2 columns)', () => { + const geom = getColumnGeometry( + normalizeColumnLayout({ count: 4, gap: 48, widths: [192, 384], equalWidth: false }, 624), + ); + expect(geom).toHaveLength(2); + expect(geom.map((c) => c.width)).toEqual([192, 384]); + }); + + it('places a separator centered in the gap after each non-last column', () => { + const geom = getColumnGeometry(normalizeColumnLayout({ count: 2, gap: 24, withSeparator: true }, 624)); + expect(geom[0].separatorX).toBe(312); + expect(geom[1].separatorX).toBeUndefined(); + expect(getColumnSeparatorPositions(geom, 96)).toEqual([408]); + }); + + it('resolves width / x / gap / column-at-x with an explicit originX', () => { + const geom = getColumnGeometry(normalizeColumnLayout({ count: 2, gap: 24 }, 624)); + expect(getColumnWidth(geom, 1)).toBe(300); + expect(getColumnX(geom, 1, 96)).toBe(420); + expect(getColumnGapAfter(geom, 0)).toBe(24); + expect(getColumnGapAfter(geom, 1)).toBe(0); + expect(getColumnAtX(geom, 96 + 330, 96)).toBe(1); + expect(getColumnAtX(geom, 96 + 100, 96)).toBe(0); + }); + + it('does NOT let per-column gaps drive geometry yet (step 1 is behavior-preserving)', () => { + // `gaps` is raw explicit-mode input; geometry still uses the scalar gap until the step-4 flip. + const geom = getColumnGeometry({ count: 2, gap: 24, widths: [300, 300], gaps: [999], width: 300 }); + expect(geom[0].gapAfter).toBe(24); + }); +}); + +describe('columnLayoutsEqual', () => { + it('treats layouts differing only by gaps as not equal', () => { + const a: ColumnLayout = { count: 2, gap: 24, widths: [200, 400], gaps: [24], equalWidth: false }; + const b: ColumnLayout = { count: 2, gap: 24, widths: [200, 400], gaps: [48], equalWidth: false }; + expect(columnLayoutsEqual(a, b)).toBe(false); + expect(columnLayoutsEqual(a, { ...a, gaps: [24] })).toBe(true); + }); + + it('matches on the full shape and handles missing inputs', () => { + expect(columnLayoutsEqual(undefined, undefined)).toBe(true); + expect(columnLayoutsEqual({ count: 2, gap: 24 }, { count: 3, gap: 24 })).toBe(false); + }); +}); + +describe('resolveColumnMode (SD-2629)', () => { + it('is explicit only when equalWidth is false AND usable widths exist', () => { + expect(resolveColumnMode({ count: 2, gap: 24, widths: [100, 200], equalWidth: false })).toBe('explicit'); + }); + + it('is equal when equalWidth is true, even with widths present', () => { + expect(resolveColumnMode({ count: 2, gap: 24, widths: [100, 200], equalWidth: true })).toBe('equal'); + }); + + it('is equal when equalWidth is omitted (Word divides evenly)', () => { + expect(resolveColumnMode({ count: 2, gap: 24, widths: [100, 200] })).toBe('equal'); + }); + + it('is equal when explicit mode is declared but no usable widths are supplied', () => { + expect(resolveColumnMode({ count: 2, gap: 24, equalWidth: false })).toBe('equal'); + expect(resolveColumnMode({ count: 2, gap: 24, widths: [0, -5], equalWidth: false })).toBe('equal'); + }); + + it('is equal for missing input', () => { + expect(resolveColumnMode(undefined)).toBe('equal'); + }); +}); + +describe('resolveColumnCount (SD-2629)', () => { + it('clamps explicit count to the usable-width count (min(num, widths))', () => { + expect(resolveColumnCount({ count: 4, gap: 20, widths: [192, 384], equalWidth: false })).toBe(2); + expect(resolveColumnCount({ count: 4, gap: 20, widths: [192], equalWidth: false })).toBe(1); + }); + + it('keeps num when it does not exceed the usable-width count', () => { + expect(resolveColumnCount({ count: 2, gap: 20, widths: [192, 384], equalWidth: false })).toBe(2); + }); + + it('does not clamp in equal mode (no usable explicit widths)', () => { + expect(resolveColumnCount({ count: 3, gap: 20 })).toBe(3); + expect(resolveColumnCount({ count: 4, gap: 20, widths: [192, 384], equalWidth: true })).toBe(4); + expect(resolveColumnCount({ count: 4, gap: 20, equalWidth: false })).toBe(4); + }); + + it('floors to a minimum of 1', () => { + expect(resolveColumnCount({ count: 0, gap: 0 })).toBe(1); + expect(resolveColumnCount(undefined)).toBe(1); + }); + + it('agrees with normalizeColumnLayout.count (single count authority)', () => { + const input: ColumnLayout = { count: 4, gap: 20, widths: [192, 384], equalWidth: false }; + expect(normalizeColumnLayout(input, 600).count).toBe(resolveColumnCount(input)); + }); +}); + +describe('resolveColumnLayout (SD-2629)', () => { + it('clamps count without advertising phantom columns (count:4 with two widths -> 2)', () => { + expect(resolveColumnLayout({ count: 4, gap: 20, widths: [192, 384], equalWidth: false })).toEqual({ + count: 2, + gap: 20, + widths: [192, 384], + equalWidth: false, + }); + }); + + it('slices surplus widths/gaps when num is below the supplied widths', () => { + expect( + resolveColumnLayout({ count: 2, gap: 20, widths: [100, 200, 300, 400], gaps: [10, 20, 30], equalWidth: false }), + ).toEqual({ count: 2, gap: 20, widths: [100, 200], gaps: [10], equalWidth: false }); + }); + + it('leaves an already-consistent config unchanged', () => { + const input: ColumnLayout = { count: 2, gap: 20, widths: [100, 400], equalWidth: false, withSeparator: true }; + expect(resolveColumnLayout(input)).toEqual(input); + }); + + it('does not slice in equal mode (no explicit widths)', () => { + expect(resolveColumnLayout({ count: 3, gap: 20 })).toEqual({ count: 3, gap: 20 }); + }); + + it('drops stray widths/gaps in equal mode (the renderer would treat any widths as explicit)', () => { + expect(resolveColumnLayout({ count: 2, gap: 20, widths: [100, 200], gaps: [10], equalWidth: true })).toEqual({ + count: 2, + gap: 20, + equalWidth: true, + }); + // Omitted equalWidth is equal mode too. + expect(resolveColumnLayout({ count: 2, gap: 20, widths: [100, 200] })).toEqual({ count: 2, gap: 20 }); + }); +}); + +describe('columnRenderLayoutsEqual (SD-2629)', () => { + it('treats equalWidth:true and omitted equalWidth as render-equal (both equal mode)', () => { + expect(columnRenderLayoutsEqual({ count: 2, gap: 24, equalWidth: true }, { count: 2, gap: 24 })).toBe(true); + }); + + it('treats num>widths and num===widths as render-equal when the resolved columns match', () => { + expect( + columnRenderLayoutsEqual( + { count: 4, gap: 24, widths: [192, 384], equalWidth: false }, + { count: 2, gap: 24, widths: [192, 384], equalWidth: false }, + ), + ).toBe(true); + }); + + it('distinguishes a separator toggle', () => { + expect( + columnRenderLayoutsEqual({ count: 2, gap: 24, withSeparator: true }, { count: 2, gap: 24, withSeparator: false }), + ).toBe(false); + }); + + it('distinguishes a different gap', () => { + expect(columnRenderLayoutsEqual({ count: 2, gap: 24 }, { count: 2, gap: 48 })).toBe(false); + }); + + it('treats explicit layouts differing only by per-column gaps as render-equal until geometry flips', () => { + expect( + columnRenderLayoutsEqual( + { count: 3, gap: 24, widths: [100, 100, 300], gaps: [24, 24], equalWidth: false }, + { count: 3, gap: 24, widths: [100, 100, 300], gaps: [24, 96], equalWidth: false }, + ), + ).toBe(true); + }); + + it('distinguishes explicit vs equal mode and different resolved widths', () => { + expect( + columnRenderLayoutsEqual({ count: 2, gap: 24, widths: [192, 384], equalWidth: false }, { count: 2, gap: 24 }), + ).toBe(false); + expect( + columnRenderLayoutsEqual( + { count: 2, gap: 24, widths: [192, 384], equalWidth: false }, + { count: 2, gap: 24, widths: [100, 400], equalWidth: false }, + ), + ).toBe(false); + }); + + it('handles missing inputs', () => { + expect(columnRenderLayoutsEqual(undefined, undefined)).toBe(true); + expect(columnRenderLayoutsEqual({ count: 2, gap: 24 }, undefined)).toBe(false); + }); +}); diff --git a/packages/layout-engine/contracts/src/column-layout.ts b/packages/layout-engine/contracts/src/column-layout.ts index 9369e32ed4..0902b43121 100644 --- a/packages/layout-engine/contracts/src/column-layout.ts +++ b/packages/layout-engine/contracts/src/column-layout.ts @@ -1,5 +1,20 @@ import type { ColumnLayout } from './index.js'; +/** + * Resolved geometry for a single column. `x` and `separatorX` are CONTENT-RELATIVE (measured from + * the content-area left edge); add the content-left / left margin to get an absolute page x. This + * is the single source every column consumer should read for positioning. (SD-2629) + */ +export type ColumnGeometry = { + index: number; + x: number; + width: number; + /** Gap after this column; 0 for the last column. */ + gapAfter: number; + /** Separator x (content-relative); present only when a separator line is drawn after this column. */ + separatorX?: number; +}; + export type NormalizedColumnLayout = ColumnLayout & { width: number }; export function widthsEqual(a?: number[], b?: number[]): boolean { @@ -12,40 +27,106 @@ export function widthsEqual(a?: number[], b?: number[]): boolean { return true; } +/** + * Usable explicit widths: finite and > 0. Empty unless explicit mode applies. (SD-2629) + */ +function usableExplicitWidths(input: ColumnLayout | undefined): number[] { + if (!input || input.equalWidth !== false || !Array.isArray(input.widths)) return []; + return input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0); +} + +/** + * Resolved column mode. Explicit ONLY when `equalWidth === false` AND at least one usable child + * width exists; otherwise equal mode. In equal mode Word ignores any child `w:col/@w` and divides + * the content area evenly, so this is the single explicit/equal decision shared by extraction, + * normalization, and geometry. (SD-2324 / SD-2629) + */ +export function resolveColumnMode(input: ColumnLayout | undefined): 'explicit' | 'equal' { + return usableExplicitWidths(input).length > 0 ? 'explicit' : 'equal'; +} + +/** + * Resolved column count and the SINGLE authority for "how many columns exist": the raw `w:num` + * (default 1, floored, min 1) clamped to the usable explicit-width count in explicit mode (Word + * renders min(num, valid-width count)). Both `normalizeColumnLayout` (width math) and the paginator + * fill loop read this, so the two tracks cannot disagree: a section that declares more columns + * than it supplies widths (e.g. w:num="4" with two ) neither pads surplus columns to ~0px + * slivers nor advances the fill into non-existent columns. Content-width-independent. (SD-2324 F8 / + * SD-2629) + */ +export function resolveColumnCount(input: ColumnLayout | undefined): number { + const rawCount = input && Number.isFinite(input.count) ? Math.max(1, Math.floor(input.count)) : 1; + const explicit = usableExplicitWidths(input); + return explicit.length > 0 ? Math.min(rawCount, explicit.length) : rawCount; +} + export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout { return columns ? { count: columns.count, gap: columns.gap, ...(Array.isArray(columns.widths) ? { widths: [...columns.widths] } : {}), + ...(Array.isArray(columns.gaps) ? { gaps: [...columns.gaps] } : {}), ...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}), ...(columns.withSeparator !== undefined ? { withSeparator: columns.withSeparator } : {}), } : { count: 1, gap: 0 }; } +/** + * Resolve an authored column config to what actually renders: count clamped to resolveColumnCount, + * and per-column data reconciled with the mode. In explicit mode widths/gaps are sliced to the + * resolved count (drop surplus); in equal mode they are dropped entirely, because Word ignores + * child widths/spaces and divides evenly, and consumers like the DOM painter treat any `widths` as + * explicit. NOT scaled to a content width; that is normalizeColumnLayout's job. Use for + * render-facing metadata (page.columns / layout.columns / columnRegions) so it never advertises + * phantom columns or stray explicit widths, e.g. count:4 with two widths becomes count:2. (SD-2629) + */ +export function resolveColumnLayout(input: ColumnLayout): ColumnLayout { + const count = resolveColumnCount(input); + const resolved = cloneColumnLayout(input); + resolved.count = count; + if (resolveColumnMode(input) === 'explicit') { + if (Array.isArray(resolved.widths)) resolved.widths = resolved.widths.slice(0, count); + if (Array.isArray(resolved.gaps)) resolved.gaps = resolved.gaps.slice(0, Math.max(0, count - 1)); + } else { + delete resolved.widths; + delete resolved.gaps; + } + return resolved; +} + +/** + * Build resolved per-column geometry from already-resolved widths and the uniform scalar gap. + * SD-2629 step 1 keeps this behavior-preserving: it mirrors today's normalized output (scaled + * widths, uniform gap). Per-column `gaps` do NOT drive geometry until the semantic flip (step 4). + */ +function buildColumnGeometry(widths: number[], gap: number, withSeparator: boolean): ColumnGeometry[] { + const geometry: ColumnGeometry[] = []; + let x = 0; + for (let i = 0; i < widths.length; i += 1) { + const width = widths[i]; + const isLast = i === widths.length - 1; + const gapAfter = isLast ? 0 : gap; + const col: ColumnGeometry = { index: i, x, width, gapAfter }; + if (withSeparator && !isLast) col.separatorX = x + width + gap / 2; + geometry.push(col); + x += width + gapAfter; + } + return geometry; +} + export function normalizeColumnLayout( input: ColumnLayout | undefined, contentWidth: number, epsilon = 0.0001, ): NormalizedColumnLayout { - const rawCount = input && Number.isFinite(input.count) ? Math.floor(input.count) : 1; - let count = Math.max(1, rawCount || 1); + const count = resolveColumnCount(input); const gap = Math.max(0, input?.gap ?? 0); - // Honor per-column widths ONLY in explicit mode (`equalWidth === false`). In equal mode - // (true or omitted) Word ignores child widths and divides the content area evenly, so any - // widths that reach here are not authoritative and must not drive geometry. (SD-2324) - const explicitWidths = - input?.equalWidth === false && Array.isArray(input?.widths) && input.widths.length > 0 - ? input.widths.filter((width) => typeof width === 'number' && Number.isFinite(width) && width > 0) - : []; - // Explicit columns are defined by their widths. When the section declares more - // columns than it supplies widths (e.g. w:num="4" with two ), the surplus columns - // have no width and previously padded to ~0px, rendering as 1px slivers of vertical text - // (SD-2324 F8). Clamp the count to the widths actually provided so every column renders. - if (explicitWidths.length > 0 && explicitWidths.length < count) { - count = explicitWidths.length; - } + // Honor per-column widths ONLY in explicit mode (`equalWidth === false` with usable widths). + // In equal mode (true or omitted) Word ignores child widths and divides the content area evenly, + // so any widths that reach here are not authoritative and must not drive geometry. (SD-2324) + const explicitWidths = usableExplicitWidths(input); const totalGap = gap * (count - 1); const availableWidth = contentWidth - totalGap; @@ -86,3 +167,100 @@ export function normalizeColumnLayout( width, }; } + +/** + * Resolve per-column geometry for an already-normalized layout. This is the SD-2629 consumer API: + * fill/positioning/separators/hit-testing/footnotes/floating anchors/balancing should read this + * single source rather than re-deriving from `widths`/`gap`. Behavior-preserving in step 1: it + * mirrors today's normalized widths + scalar gap; per-column `gaps` drive it only after the flip. + */ +export function getColumnGeometry(normalized: NormalizedColumnLayout): ColumnGeometry[] { + const widths = + Array.isArray(normalized.widths) && normalized.widths.length > 0 ? normalized.widths : [normalized.width]; + return buildColumnGeometry(widths, normalized.gap, Boolean(normalized.withSeparator)); +} + +// --------------------------------------------------------------------------- +// Resolved-geometry consumer API (SD-2629). All x values are CONTENT-RELATIVE; +// callers pass the content-left / left margin as `originX` to get an absolute page x. +// --------------------------------------------------------------------------- + +function clampColumnIndex(geometry: ColumnGeometry[], index: number): number { + if (geometry.length === 0) return 0; + return Math.max(0, Math.min(index, geometry.length - 1)); +} + +/** Width of the column at `index` (px). */ +export function getColumnWidth(geometry: ColumnGeometry[], index: number): number { + return geometry[clampColumnIndex(geometry, index)]?.width ?? 0; +} + +/** Left edge of the column at `index`, as `originX + content-relative x`. */ +export function getColumnX(geometry: ColumnGeometry[], index: number, originX = 0): number { + return originX + (geometry[clampColumnIndex(geometry, index)]?.x ?? 0); +} + +/** Gap after the column at `index` (0 for the last column). */ +export function getColumnGapAfter(geometry: ColumnGeometry[], index: number): number { + return geometry[clampColumnIndex(geometry, index)]?.gapAfter ?? 0; +} + +/** Absolute x of each separator line (only columns that draw one), as `originX + content-relative`. */ +export function getColumnSeparatorPositions(geometry: ColumnGeometry[], originX = 0): number[] { + return geometry + .filter((col) => typeof col.separatorX === 'number') + .map((col) => originX + (col.separatorX as number)); +} + +/** Index of the column containing absolute `x` (clicks in a gap map to the preceding column). */ +export function getColumnAtX(geometry: ColumnGeometry[], x: number, originX = 0): number { + if (geometry.length === 0) return 0; + const cx = x - originX; + let result = 0; + for (const col of geometry) { + if (cx >= col.x) result = col.index; + else break; + } + return result; +} + +/** Structural equality of two column layouts, including per-column `gaps`. */ +export function columnLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + return ( + a.count === b.count && + a.gap === b.gap && + a.equalWidth === b.equalWidth && + Boolean(a.withSeparator) === Boolean(b.withSeparator) && + widthsEqual(a.widths, b.widths) && + widthsEqual(a.gaps, b.gaps) + ); +} + +/** + * Render equality: true when two column configs produce the SAME rendered layout even if their raw + * fields differ. Compares the canonical render form for today's renderer (resolved mode + count, + * scalar gap, withSeparator, and in explicit mode the sliced widths) and deliberately ignores raw + * `equalWidth` and the surplus count/widths that resolution discards. Per-column `gaps` are + * intentionally ignored until geometry/separators consume them (step 4), so a gaps-only authored + * delta does not split regions or invalidate the normalized-columns cache before it becomes + * paint-significant. Use for region/cache change detection so e.g. `{num:4, widths:[a,b]}` vs + * `{num:2, widths:[a,b]}`, or `equalWidth:true` vs an omitted equalWidth, do not split into + * separate regions. (SD-2629) + */ +export function columnRenderLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + const mode = resolveColumnMode(a); + if (mode !== resolveColumnMode(b)) return false; + if (resolveColumnCount(a) !== resolveColumnCount(b)) return false; + if ((a.gap ?? 0) !== (b.gap ?? 0)) return false; + if (Boolean(a.withSeparator) !== Boolean(b.withSeparator)) return false; + if (mode === 'explicit') { + const ra = resolveColumnLayout(a); + const rb = resolveColumnLayout(b); + if (!widthsEqual(ra.widths, rb.widths)) return false; + } + return true; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index afacffd35c..b8317cc776 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -119,8 +119,23 @@ export type { LayoutStoryLocator, } from './layout-identity.js'; import type { LayoutSourceIdentity } from './layout-identity.js'; -export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; -export type { NormalizedColumnLayout } from './column-layout.js'; +export { + cloneColumnLayout, + columnLayoutsEqual, + columnRenderLayoutsEqual, + getColumnAtX, + getColumnGapAfter, + getColumnGeometry, + getColumnSeparatorPositions, + getColumnWidth, + getColumnX, + normalizeColumnLayout, + resolveColumnCount, + resolveColumnLayout, + resolveColumnMode, + widthsEqual, +} from './column-layout.js'; +export type { ColumnGeometry, NormalizedColumnLayout } from './column-layout.js'; export { authorFromTrackedChangeMeta, authorIdentityKey, @@ -1777,6 +1792,12 @@ export type ColumnLayout = { withSeparator?: boolean; widths?: number[]; equalWidth?: boolean; + /** + * Per-column inter-column gaps in px, length `count - 1`: the gap after each column except the + * last. Explicit mode (`equalWidth === false`) only, derived from each ``; equal + * mode uses the scalar `gap`. When absent, consumers fall back to the uniform `gap`. (SD-2629) + */ + gaps?: number[]; }; /** diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 9820fc4144..2ce6471234 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -279,6 +279,38 @@ describe('layoutDocument', () => { }); }); + it('caps the fill at the resolved column count when w:num exceeds the supplied widths (SD-2629)', () => { + // count:4 but only two explicit widths -> the resolved count is 2 (Word renders min(num, + // widths)). The fill loop must advance through 2 columns then start a new page, NOT into + // phantom columns 3-4. Before SD-2629, advanceColumn read the raw count (4) while width math + // read the clamped count (2): two answers for "how many columns exist". + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 4, gap: 20, widths: [192, 384], equalWidth: false }, + }; + + // Eight 350px lines: each 720px column fits two, so a 2-column page holds four lines -> exactly + // two pages. Under the bug (4 columns), all eight fit on one page across four column positions. + const layout = layoutDocument([block], [makeMeasure([350, 350, 350, 350, 350, 350, 350, 350])], options); + + const columnXs = new Set(layout.pages.flatMap((page) => page.fragments.map((fragment) => Math.round(fragment.x)))); + expect(columnXs.size).toBe(2); + expect(layout.pages).toHaveLength(2); + }); + + it('resolves page/document column metadata to the rendered count, not the raw w:num (SD-2629)', () => { + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 4, gap: 20, widths: [192, 384], equalWidth: false }, + }; + const layout = layoutDocument([block], [makeMeasure([350])], options); + // count:4 with two widths renders two columns; metadata must not advertise four. + expect(layout.columns).toEqual({ count: 2, gap: 20, widths: [192, 384], equalWidth: false }); + expect(layout.pages[0].columns).toEqual({ count: 2, gap: 20, widths: [192, 384], equalWidth: false }); + }); + it('does not set "page.columns" on single column layout', () => { const options: LayoutOptions = { pageSize: { w: 600, h: 800 }, @@ -304,6 +336,34 @@ describe('layoutDocument', () => { expect(layout.columns).toEqual({ count: 2, gap: 20, withSeparator: false }); }); + it('resolves mid-page region column metadata to the rendered count (SD-2629)', () => { + // A continuous break to count:4 with two widths must surface as a 2-column region, not 4 - the + // renderer prefers columnRegions over page.columns and reads the config raw. + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'intro', runs: [] }, + { + kind: 'sectionBreak', + id: 'sb-continuous', + type: 'continuous', + columns: { count: 4, gap: 20, widths: [192, 384], equalWidth: false }, + }, + { kind: 'paragraph', id: 'body', runs: [] }, + ]; + const measures: Measure[] = [makeMeasure([30]), { kind: 'sectionBreak' }, makeMeasure([30, 30, 30])]; + const options: LayoutOptions = { + pageSize: { w: 600, h: 800 }, + margins: { top: 40, right: 40, bottom: 40, left: 40 }, + columns: { count: 2, gap: 20 }, + }; + + const layout = layoutDocument(blocks, measures, options); + const regions = layout.pages[0].columnRegions; + expect(regions).toBeDefined(); + const last = regions![regions!.length - 1]; + expect(last.columns.count).toBe(2); + expect(last.columns.widths).toEqual([192, 384]); + }); + it('emits page.columnRegions for continuous section breaks that change column config mid-page', () => { // Two sections on the same page: first 2-col with separator, then a // continuous break that switches to 3-col still with separator. The @@ -2155,6 +2215,32 @@ describe('layoutDocument', () => { expect(p2.y).toBe(options.margins!.top); }); + it('treats the last resolved column as last for column breaks when w:num exceeds widths (SD-2629)', () => { + // count:4 but two explicit widths -> resolved count 2. The first break moves to column 1 (the + // last resolved column); the second must start a new page, NOT advance into a phantom column + // 2. Mirror of the advanceColumn fix for explicit handling. + const blocks: FlowBlock[] = [ + { kind: 'columnBreak', id: 'br-1' } as ColumnBreakBlock, + { kind: 'columnBreak', id: 'br-2' } as ColumnBreakBlock, + { kind: 'paragraph', id: 'p2', runs: [] }, + ]; + + const measures: Measure[] = [{ kind: 'columnBreak' }, { kind: 'columnBreak' }, makeMeasure([40])]; + + const options: LayoutOptions = { + pageSize: { w: 612, h: 792 }, + margins: { top: 72, right: 72, bottom: 72, left: 72 }, + columns: { count: 4, gap: 48, widths: [192, 384], equalWidth: false }, + }; + + const layout = layoutDocument(blocks, measures, options); + + expect(layout.pages.length).toBe(2); + const p2 = layout.pages[1].fragments.find((f) => f.blockId === 'p2') as ParaFragment; + expect(p2.x).toBeCloseTo(options.margins!.left); + expect(p2.y).toBe(options.margins!.top); + }); + it('starts a new page when columnBreak occurs in last column', () => { const blocks: FlowBlock[] = [ // First columnBreak moves to column 2, second starts a new page @@ -4449,6 +4535,70 @@ describe('requirePageBoundary edge cases', () => { expect(p3.width).toBeCloseTo(550); }); + it('keeps the current explicit column after a manual column break when only later per-column gaps differ', () => { + const toExplicitColumns: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-explicit', + type: 'continuous', + columns: { count: 3, gap: 48, widths: [100, 100, 300], gaps: [48, 48], equalWidth: false }, + margins: {}, + }; + const laterGapsOnlyDelta: FlowBlock = { + kind: 'sectionBreak', + id: 'sb-gaps-only', + type: 'continuous', + columns: { count: 3, gap: 48, widths: [100, 100, 300], gaps: [48, 96], equalWidth: false }, + margins: {}, + }; + + const blocks: FlowBlock[] = [ + { kind: 'paragraph', id: 'p1', runs: [] }, + toExplicitColumns, + { kind: 'paragraph', id: 'p2', runs: [] }, + { kind: 'columnBreak', id: 'br-1' } as ColumnBreakBlock, + { kind: 'paragraph', id: 'p3', runs: [] }, + laterGapsOnlyDelta, + { kind: 'paragraph', id: 'p4', runs: [] }, + ]; + + const measures: Measure[] = [ + makeMeasure([40]), + { kind: 'sectionBreak' }, + makeMeasure([40]), + { kind: 'columnBreak' }, + makeMeasure([40]), + { kind: 'sectionBreak' }, + makeMeasure([40]), + ]; + + const options: LayoutOptions = { + pageSize: { w: 700, h: 792 }, + margins: { top: 72, right: 50, bottom: 72, left: 50 }, + }; + + const layout = layoutDocument(blocks, measures, options); + const page = layout.pages[0]; + const contentWidth = options.pageSize!.w - options.margins!.left - options.margins!.right; + const totalGap = 48 * 2; + const expectedSecondColumnX = 50 + (100 * (contentWidth - totalGap)) / (100 + 100 + 300) + 48; + + const p2 = page.fragments.find((f) => f.blockId === 'p2') as ParaFragment; + const p3 = page.fragments.find((f) => f.blockId === 'p3') as ParaFragment; + const p4 = page.fragments.find((f) => f.blockId === 'p4') as ParaFragment; + + expect(p2.x).toBeCloseTo(50); + expect(p3.x).toBeCloseTo(expectedSecondColumnX); + expect(p4.x).toBeCloseTo(expectedSecondColumnX); + expect(page.columnRegions).toHaveLength(2); + expect(page.columnRegions?.[1]?.columns).toEqual({ + count: 3, + gap: 48, + widths: [100, 100, 300], + gaps: [48, 48], + equalWidth: false, + }); + }); + it('does not balance the final page for explicit custom-width columns', () => { const blocks: FlowBlock[] = [ { diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 8fc49161e8..01c9d63a03 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -28,6 +28,7 @@ import type { SectionNumbering, FlowMode, NormalizedColumnLayout, + ColumnGeometry, DocumentBackground, HeaderFooterResolutionSection, } from '@superdoc/contracts'; @@ -35,6 +36,12 @@ import { buildLayoutSourceIdentityForFragment, normalizeColumnLayout, getFragmentZIndex, + getColumnGeometry, + getColumnWidth, + getColumnX, + columnRenderLayoutsEqual, + resolveColumnCount, + resolveColumnLayout, resolveAnchoredGraphicY, resolveEffectiveHeaderFooterRef, selectHeaderFooterVariantForPage, @@ -62,7 +69,7 @@ import { createPaginator, type PageState, type ConstraintBoundary } from './pagi import { formatPageNumber } from './pageNumbering.js'; import { shouldSuppressSpacingForEmpty, shouldSuppressOwnSpacing } from './layout-utils.js'; import { balanceSectionOnPage, type BalancingFragment, type MeasureData } from './column-balancing.js'; -import { cloneColumnLayout, widthsEqual } from './column-utils.js'; +import { cloneColumnLayout } from './column-utils.js'; type PageSize = { w: number; h: number }; type Margins = { @@ -1106,19 +1113,13 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if (block.pageSize) next.pendingPageSize = { w: block.pageSize.w, h: block.pageSize.h }; if (block.orientation) next.pendingOrientation = block.orientation; const sectionType = block.type ?? 'continuous'; - // Check if columns are changing: either explicitly to a different config, - // or implicitly resetting to single column (undefined = single column in OOXML). - // withSeparator must be compared because a sep-only toggle still needs a new - // column region so the renderer can draw (or stop drawing) the separator from - // the toggle point onward. + // Columns change when the block's resolved RENDER layout differs from the active one (render + // equality ignores raw equalWidth / surplus count that resolution discards), or when columns + // reset to single (undefined). withSeparator is part of render equality: a sep-only toggle still + // needs a new region so the renderer can start or stop the separator from the toggle point. const isColumnsChanging = - (block.columns && - (block.columns.count !== next.activeColumns.count || - block.columns.gap !== next.activeColumns.gap || - Boolean(block.columns.withSeparator) !== Boolean(next.activeColumns.withSeparator) || - block.columns.equalWidth !== next.activeColumns.equalWidth || - !widthsEqual(block.columns.widths, next.activeColumns.widths))) || - (!block.columns && (next.activeColumns.count > 1 || Boolean(next.activeColumns.withSeparator))); + (block.columns && !columnRenderLayoutsEqual(block.columns, next.activeColumns)) || + (!block.columns && (resolveColumnCount(next.activeColumns) > 1 || Boolean(next.activeColumns.withSeparator))); // Schedule section index change for next page (enables section-aware page numbering) const sectionIndexRaw = block.attrs?.sectionIndex; const metadataIndex = typeof sectionIndexRaw === 'number' ? sectionIndexRaw : Number(sectionIndexRaw ?? NaN); @@ -1185,8 +1186,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options page.orientation = activeOrientation; } - if (activeColumns.count > 1) { - page.columns = cloneColumnLayout(activeColumns); + if (resolveColumnCount(activeColumns) > 1) { + // Render-facing metadata: resolve so it never advertises more columns than render (SD-2629). + page.columns = resolveColumnLayout(activeColumns); } // Set vertical alignment from active section state @@ -1529,7 +1531,6 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options getActivePageSize: () => activePageSize, getDefaultPageSize: () => pageSize, getActiveColumns: () => activeColumns, - getCurrentColumns: () => getCurrentColumns(), createPage, onNewPage: (state?: PageState) => { // apply pending->active and invalidate columns cache (first callback) @@ -1769,10 +1770,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options cachedColumnsState.state === state && cachedColumnsState.constraintIndex === constraintIndex && cachedColumnsState.contentWidth === currentContentWidth && - cachedColumnsState.colsConfig?.count === colsConfig.count && - cachedColumnsState.colsConfig?.gap === colsConfig.gap && - cachedColumnsState.colsConfig?.equalWidth === colsConfig.equalWidth && - widthsEqual(cachedColumnsState.colsConfig?.widths, colsConfig.widths) && + columnRenderLayoutsEqual(cachedColumnsState.colsConfig ?? undefined, colsConfig) && cachedColumnsState.normalized ) { return cachedColumnsState.normalized; @@ -1789,15 +1787,40 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options return normalized; }; + // SD-2629: state-aware resolved geometry. Derives from the SAME state's columns + page size + + // margins (NOT the global latest-section values), so positioning an older page uses that page's + // own geometry. Behavior-identical to getCurrentColumns for the latest state and constant margins, + // and more correct for older pages once section margins/size vary. + const getColumnGeometryForState = (state: PageState): ColumnGeometry[] => { + // Columns for THIS page: the active mid-page region's config if one applies, else the page's own + // creation-time snapshot (page.columns, the resolved metadata set in createPage). NOT + // getActiveColumnsForState, which falls back to the global latest-section columns and would + // mis-position an older page once columns vary across sections. (SD-2629) + const cols = + state.activeConstraintIndex >= 0 && state.constraintBoundaries[state.activeConstraintIndex] + ? state.constraintBoundaries[state.activeConstraintIndex].columns + : (state.page.columns ?? { count: 1, gap: 0 }); + const pageWidth = state.page.size?.w ?? pageSize.w; + // page.margins is always set by createPage but optional in the type; fall back to the current + // active margins (the guard never fires at runtime). + const left = state.page.margins?.left ?? activeLeftMargin; + const right = state.page.margins?.right ?? activeRightMargin; + return getColumnGeometry(normalizeColumns(cols, pageWidth - (left + right))); + }; + + const columnWidthForState = (state: PageState, columnIndex: number = state.columnIndex): number => + getColumnWidth(getColumnGeometryForState(state), columnIndex); + + const columnXForState = (state: PageState, columnIndex: number = state.columnIndex): number => + getColumnX(getColumnGeometryForState(state), columnIndex, state.page.margins?.left ?? activeLeftMargin); + const getCurrentColumnWidth = (): number => { - const cols = getCurrentColumns(); const state = states[states.length - 1] ?? null; - const columnIndex = state?.columnIndex ?? 0; - return getColumnWidthAt(cols, columnIndex); + return state ? columnWidthForState(state) : getColumnWidthAt(getCurrentColumns(), 0); }; - // Helper to get column X position - const columnX = paginator.columnX; + // Helper to get column X position (state-aware; positions the passed page state, SD-2629). + const columnX = columnXForState; const advanceColumn = paginator.advanceColumn; @@ -2246,7 +2269,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const willBalance = endingSectionIndex !== null && !!endingSectionColumns && - endingSectionColumns.count > 1 && + resolveColumnCount(endingSectionColumns) > 1 && !sectionHasExplicitColumnBreak.has(endingSectionIndex); // Balance BEFORE any forced page break. After balancing, all of the @@ -2298,7 +2321,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options alreadyBalancedSections.add(endingSectionIndex!); } } - if (balanceResult === null && columnIndexBefore >= newColumns.count) { + if (balanceResult === null && columnIndexBefore >= resolveColumnCount(newColumns)) { // No balancing applied (either willBalance was false, or // balanceSectionOnPage skipped late). Reducing column count without // balancing means starting the new region at col 0 could overwrite @@ -2602,7 +2625,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const anchorY = anchorBaseY + offsetV; floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); - const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); + const anchorX = tableBlock.anchor?.offsetH ?? columnX(state); const tableFragment = createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY); state.page.fragments.push(tableFragment); @@ -2645,7 +2668,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } else if (relativeFrom === 'margin') { maxWidth = activePageSize.w - (activeLeftMargin + activeRightMargin); } else { - maxWidth = getColumnWidthAt(cols, state.columnIndex); + maxWidth = columnWidthForState(state); } const aspectRatio = imgMeasure.width > 0 && imgMeasure.height > 0 ? imgMeasure.width / imgMeasure.height : 1.0; @@ -2784,7 +2807,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options const state = paginator.ensurePage(); const activeCols = getActiveColumnsForState(state); - if (state.columnIndex < activeCols.count - 1) { + if (state.columnIndex < resolveColumnCount(activeCols) - 1) { // Not in last column: advance to next column advanceColumn(state); } else { @@ -2830,7 +2853,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } const anchorY = resolveParagraphlessAnchoredTableY(tableBlock, tableMeasure, state); - const anchorX = tableBlock.anchor?.offsetH ?? columnX(state.columnIndex); + const anchorX = tableBlock.anchor?.offsetH ?? columnX(state); floatManager.registerTable(tableBlock, tableMeasure, anchorY, state.columnIndex, state.page.number); state.page.fragments.push(createAnchoredTableFragment(tableBlock, tableMeasure, anchorX, anchorY)); @@ -2948,7 +2971,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options if ( sectionColumnsMap.size === 0 && !documentHasAnySectionBreak && - activeColumns.count > 1 && + resolveColumnCount(activeColumns) > 1 && !documentHasExplicitColumnBreak ) { sectionColumnsMap.set(FALLBACK_SECTION_IDX, cloneColumnLayout(activeColumns)); @@ -2958,7 +2981,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } for (const [sectionIdx, sectionCols] of sectionColumnsMap) { - if (sectionCols.count <= 1) continue; + if (resolveColumnCount(sectionCols) <= 1) continue; if (sectionHasExplicitColumnBreak.has(sectionIdx)) continue; if (alreadyBalancedSections.has(sectionIdx)) continue; @@ -3139,7 +3162,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options regions.push({ yStart: start.y, yEnd: end ? end.y : state.contentBottom, - columns: start.columns, + // Render-facing region metadata: resolve so a count>widths region does not advertise + // phantom columns to the separator renderer, which reads these configs raw (SD-2629). + columns: resolveColumnLayout(start.columns), }); } state.page.columnRegions = regions; @@ -3175,7 +3200,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options // after processing sections. Page/region-specific column changes are encoded // implicitly via fragment positions. Consumers should not assume this is // a static document-wide value. - columns: activeColumns.count > 1 ? cloneColumnLayout(activeColumns) : undefined, + columns: resolveColumnCount(activeColumns) > 1 ? resolveColumnLayout(activeColumns) : undefined, }; } diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts index d70415fad2..0ba5888187 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.test.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.test.ts @@ -98,7 +98,7 @@ describe('layoutDrawingBlock', () => { columnIndex: currentState.columnIndex + 1, cursorY: currentState.topMargin, }) as unknown as PageState, - columnX: (columnIndex: number) => columnIndex * (mockColumns.width + mockColumns.gap), + columnX: (state: PageState) => state.columnIndex * (mockColumns.width + mockColumns.gap), }; }; @@ -370,7 +370,7 @@ describe('layoutDrawingBlock', () => { }; return stateRef as unknown as PageState; }, - columnX: (columnIndex: number) => columnIndex * (mockColumns.width + mockColumns.gap), + columnX: (state: PageState) => state.columnIndex * (mockColumns.width + mockColumns.gap), }; layoutDrawingBlock(context); @@ -685,7 +685,7 @@ describe('layoutDrawingBlock', () => { it('should use correct columnX for multi-column layout', () => { const context = createMockContext({}, {}, { columnIndex: 2 }); - context.columnX = (index: number) => index * 620; // width(600) + gap(20) + context.columnX = (state: PageState) => state.columnIndex * 620; // width(600) + gap(20) const state = context.ensurePage(); layoutDrawingBlock(context); diff --git a/packages/layout-engine/layout-engine/src/layout-drawing.ts b/packages/layout-engine/layout-engine/src/layout-drawing.ts index bd2ef0b859..8d3fa42b52 100644 --- a/packages/layout-engine/layout-engine/src/layout-drawing.ts +++ b/packages/layout-engine/layout-engine/src/layout-drawing.ts @@ -22,8 +22,8 @@ export type DrawingLayoutContext = { ensurePage: () => PageState; /** Advances to the next column or page, returning the new page state */ advanceColumn: (state: PageState) => PageState; - /** Computes the X coordinate for a given column index */ - columnX: (columnIndex: number) => number; + /** Computes the X coordinate for a column in the given page state (SD-2629). */ + columnX: (state: PageState, columnIndex?: number) => number; }; /** @@ -113,7 +113,7 @@ export function layoutDrawingBlock({ } const pmRange = extractBlockPmRange(block); - let x = columnX(state.columnIndex) + marginLeft + indentLeft; + let x = columnX(state) + marginLeft + indentLeft; if (isInlineShapeGroup && inlineParagraphAlignment) { const pIndentLeft = typeof attrs?.paragraphIndentLeft === 'number' ? attrs.paragraphIndentLeft : 0; const pIndentRight = typeof attrs?.paragraphIndentRight === 'number' ? attrs.paragraphIndentRight : 0; diff --git a/packages/layout-engine/layout-engine/src/layout-image.ts b/packages/layout-engine/layout-engine/src/layout-image.ts index b2c48f1c7f..f4697438df 100644 --- a/packages/layout-engine/layout-engine/src/layout-image.ts +++ b/packages/layout-engine/layout-engine/src/layout-image.ts @@ -10,7 +10,7 @@ export type ImageLayoutContext = { columns: NormalizedColumns; ensurePage: () => PageState; advanceColumn: (state: PageState) => PageState; - columnX: (columnIndex: number) => number; + columnX: (state: PageState, columnIndex?: number) => number; }; export function layoutImageBlock({ @@ -75,7 +75,7 @@ export function layoutImageBlock({ const fragment: ImageFragment = { kind: 'image', blockId: block.id, - x: columnX(state.columnIndex) + marginLeft, + x: columnX(state) + marginLeft, y: state.cursorY + marginTop, width, height, diff --git a/packages/layout-engine/layout-engine/src/layout-paragraph.ts b/packages/layout-engine/layout-engine/src/layout-paragraph.ts index ac616e712f..ede5eab86e 100644 --- a/packages/layout-engine/layout-engine/src/layout-paragraph.ts +++ b/packages/layout-engine/layout-engine/src/layout-paragraph.ts @@ -298,7 +298,7 @@ export type ParagraphLayoutContext = { columnWidth: number; ensurePage: () => PageState; advanceColumn: (state: PageState) => PageState; - columnX: (columnIndex: number) => number; + columnX: (state: PageState, columnIndex?: number) => number; floatManager: FloatingObjectManager; remeasureParagraph?: (block: ParagraphBlock, maxWidth: number, firstLineIndent?: number) => ParagraphMeasure; /** @@ -495,7 +495,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para { left: anchors.pageMargins.left, right: anchors.pageMargins.right }, anchors.pageWidth, ) - : columnX(state.columnIndex); + : columnX(state); const pmRange = extractBlockPmRange(entry.block); if (entry.block.kind === 'image' && entry.measure.kind === 'image') { @@ -581,7 +581,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para const maxLineWidth = lines.reduce((max, line) => Math.max(max, line.width ?? 0), 0); const fragmentWidth = maxLineWidth || columnWidth; - let x = columnX(state.columnIndex); + let x = columnX(state); if (frame.xAlign === 'right') { x += columnWidth - fragmentWidth; } else if (frame.xAlign === 'center') { @@ -1058,11 +1058,11 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para // This matches Word's behavior where paragraphs with negative indents extend beyond the content area. // Adjust x position: negative indent shifts left (e.g., -48px moves fragment 48px left). // When text was remeasured around floats, do not pull lines back into exclusion zones. - const floatAdjustedX = columnX(state.columnIndex) + offsetX; + const floatAdjustedX = columnX(state) + offsetX; const adjustedX = didRemeasureForFloats ? floatAdjustedX + Math.max(negativeLeftIndent, 0) : floatAdjustedX + negativeLeftIndent; - const columnRight = columnX(state.columnIndex) + columnWidth; + const columnRight = columnX(state) + columnWidth; let adjustedWidth = didRemeasureForFloats ? effectiveColumnWidth : effectiveColumnWidth - negativeLeftIndent - negativeRightIndent; @@ -1117,9 +1117,9 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para } if (floatAlignment === 'right') { - fragment.x = columnX(state.columnIndex) + offsetX + (effectiveColumnWidth - maxLineWidth); + fragment.x = columnX(state) + offsetX + (effectiveColumnWidth - maxLineWidth); } else if (floatAlignment === 'center') { - fragment.x = columnX(state.columnIndex) + offsetX + (effectiveColumnWidth - maxLineWidth) / 2; + fragment.x = columnX(state) + offsetX + (effectiveColumnWidth - maxLineWidth) / 2; } } state.page.fragments.push(fragment); diff --git a/packages/layout-engine/layout-engine/src/layout-table.ts b/packages/layout-engine/layout-engine/src/layout-table.ts index 3f30d140cc..6ba98648f8 100644 --- a/packages/layout-engine/layout-engine/src/layout-table.ts +++ b/packages/layout-engine/layout-engine/src/layout-table.ts @@ -33,7 +33,7 @@ export type TableLayoutContext = { columnWidth: number; ensurePage: () => PageState; advanceColumn: (state: PageState) => PageState; - columnX: (columnIndex: number) => number; + columnX: (state: PageState, columnIndex?: number) => number; }; /** @@ -1252,7 +1252,7 @@ function layoutMonolithicTable(context: TableLayoutContext): void { state = context.ensurePage(); const height = Math.min(context.measure.totalHeight, state.contentBottom - state.cursorY); - const baseX = context.columnX(state.columnIndex); + const baseX = context.columnX(state); const baseWidth = Math.max(0, context.measure.totalWidth || context.columnWidth); const { x, width } = resolveTableFrame(baseX, context.columnWidth, baseWidth, context.block.attrs); const columnWidths = rescaleColumnWidths(context.measure.columnWidths, context.measure.totalWidth, width); @@ -1412,7 +1412,7 @@ export function layoutTableBlock({ if (block.rows.length === 0 && measure.totalHeight > 0) { const height = Math.min(measure.totalHeight, state.contentBottom - state.cursorY); - const baseX = columnX(state.columnIndex); + const baseX = columnX(state); const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const columnWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); @@ -1569,7 +1569,7 @@ export function layoutTableBlock({ // Only create a fragment if we made progress (rendered some lines) // Don't create empty fragments with just padding if (fragmentHeight > 0 && madeProgress) { - const baseX = columnX(state.columnIndex); + const baseX = columnX(state); const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); @@ -1686,7 +1686,7 @@ export function layoutTableBlock({ forcedPartialRow, ); - const baseX = columnX(state.columnIndex); + const baseX = columnX(state); const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); @@ -1738,7 +1738,7 @@ export function layoutTableBlock({ partialRow, ); - const baseX = columnX(state.columnIndex); + const baseX = columnX(state); const baseWidth = Math.max(0, measure.totalWidth || columnWidth); const { x, width } = resolveTableFrame(baseX, columnWidth, baseWidth, block.attrs); const scaledWidths = rescaleColumnWidths(measure.columnWidths, measure.totalWidth, width); diff --git a/packages/layout-engine/layout-engine/src/paginator.ts b/packages/layout-engine/layout-engine/src/paginator.ts index ffa75aad49..de8c53604c 100644 --- a/packages/layout-engine/layout-engine/src/paginator.ts +++ b/packages/layout-engine/layout-engine/src/paginator.ts @@ -1,7 +1,6 @@ +import { resolveColumnCount } from '@superdoc/contracts'; import type { ColumnLayout, Page, PageMargins } from '@superdoc/contracts'; -export type NormalizedColumns = ColumnLayout & { width: number }; - export type ConstraintBoundary = { y: number; columns: ColumnLayout; @@ -64,7 +63,6 @@ export type PaginatorOptions = { getActivePageSize(): { w: number; h: number }; getDefaultPageSize(): { w: number; h: number }; getActiveColumns(): ColumnLayout; - getCurrentColumns(): NormalizedColumns; createPage(number: number, pageMargins: PageMargins, pageSizeOverride?: { w: number; h: number }): Page; onNewPage?: (state: PageState) => void; /** @@ -93,19 +91,6 @@ export function createPaginator(opts: PaginatorOptions) { return opts.getActiveColumns(); }; - const columnX = (columnIndex: number): number => { - const cols = opts.getCurrentColumns(); - const widths = Array.isArray(cols.widths) && cols.widths.length > 0 ? cols.widths : null; - if (!widths) { - return opts.margins.left + columnIndex * (cols.width + cols.gap); - } - let x = opts.margins.left; - for (let index = 0; index < columnIndex; index += 1) { - x += (widths[index] ?? cols.width) + cols.gap; - } - return x; - }; - const startNewPage = (): PageState => { // Allow caller to update state (e.g., apply pending→active) before we snapshot margins/size // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -168,7 +153,10 @@ export function createPaginator(opts: PaginatorOptions) { const advanceColumn = (state: PageState): PageState => { const activeCols = getActiveColumnsForState(state); - if (state.columnIndex < activeCols.count - 1) { + // Use the RESOLVED count (clamped to usable explicit widths), not the raw w:num, so the fill + // loop and the width math (normalizeColumnLayout) agree on how many columns exist. Without this + // the loop advances into columns that have no width (the SD-2629 two-track count bug). + if (state.columnIndex < resolveColumnCount(activeCols) - 1) { // Snapshot max Y before resetting cursor for the next column state.maxCursorY = Math.max(state.maxCursorY, state.cursorY); state.columnIndex += 1; @@ -199,7 +187,6 @@ export function createPaginator(opts: PaginatorOptions) { startNewPage, ensurePage, advanceColumn, - columnX, getActiveColumnsForState, getPageByNumber, pruneTrailingEmptyPages, diff --git a/packages/layout-engine/layout-engine/src/section-breaks.test.ts b/packages/layout-engine/layout-engine/src/section-breaks.test.ts index 49f3143b1f..fe7b6084c6 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.test.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.test.ts @@ -141,6 +141,27 @@ describe('scheduleSectionBreak', () => { expect(result.state.pendingColumns).toEqual({ count: 2, gap: 48 }); }); + it('does not trigger mid-page region change for explicit gaps-only changes before geometry uses gaps', () => { + const state = createSectionState({ + activeColumns: { count: 3, gap: 48, widths: [100, 100, 300], gaps: [48, 48], equalWidth: false }, + }); + const block = createSectionBreak({ + type: 'continuous', + columns: { count: 3, gap: 48, widths: [100, 100, 300], gaps: [48, 96], equalWidth: false }, + }); + + const result = scheduleSectionBreak(block, state, BASE_MARGINS); + + expect(result.decision.forceMidPageRegion).toBe(false); + expect(result.state.pendingColumns).toEqual({ + count: 3, + gap: 48, + widths: [100, 100, 300], + gaps: [48, 96], + equalWidth: false, + }); + }); + it('detects column change when only withSeparator toggles on', () => { const state = createSectionState({ activeColumns: { count: 2, gap: 48, withSeparator: false } }); const block = createSectionBreak({ diff --git a/packages/layout-engine/layout-engine/src/section-breaks.ts b/packages/layout-engine/layout-engine/src/section-breaks.ts index 3fce475a66..c3ee970ec0 100644 --- a/packages/layout-engine/layout-engine/src/section-breaks.ts +++ b/packages/layout-engine/layout-engine/src/section-breaks.ts @@ -1,5 +1,6 @@ import type { ColumnLayout, SectionBreakBlock } from '@superdoc/contracts'; -import { cloneColumnLayout, widthsEqual } from './column-utils.js'; +import { columnRenderLayoutsEqual, resolveColumnCount } from '@superdoc/contracts'; +import { cloneColumnLayout } from './column-utils.js'; export type SectionState = { activeTopMargin: number; @@ -56,22 +57,14 @@ function getColumnConfig(blockColumns: ColumnLayout | undefined): ColumnLayout { */ function isColumnConfigChanging(blockColumns: ColumnLayout | undefined, activeColumns: ColumnLayout): boolean { if (blockColumns) { - // Explicit column change: any of count, gap, separator presence, equalWidth, - // or widths differs. withSeparator must be included because a sep-only toggle - // still needs a new column region so the renderer can draw (or stop drawing) - // the separator from the toggle point onward. - return ( - blockColumns.count !== activeColumns.count || - blockColumns.gap !== activeColumns.gap || - Boolean(blockColumns.withSeparator) !== Boolean(activeColumns.withSeparator) || - blockColumns.equalWidth !== activeColumns.equalWidth || - !widthsEqual(blockColumns.widths, activeColumns.widths) - ); + // Columns change when the block's resolved RENDER layout differs from the active one. Render + // equality includes withSeparator (a sep-only toggle needs a new region) and ignores raw + // equalWidth / surplus count that resolution discards. + return !columnRenderLayoutsEqual(blockColumns, activeColumns); } - // No columns specified = reset to single column (OOXML default). - // This is a change if currently in multi-column layout, or if the separator was on - // (the reset implicitly turns it off). - return activeColumns.count > 1 || Boolean(activeColumns.withSeparator); + // No columns specified = reset to single column (OOXML default). A change if the active layout + // renders as multi-column, or the separator was on (the reset implicitly turns it off). + return resolveColumnCount(activeColumns) > 1 || Boolean(activeColumns.withSeparator); } /** diff --git a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts index 699fd35afe..4d0c3bc7f7 100644 --- a/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-column-separators.test.ts @@ -157,6 +157,22 @@ describe('DomPainter renderColumnSeparators', () => { // contentWidth=90, columnWidth=(90-100)/2=-5 → guard fires. expect(querySeparators(mount)).toHaveLength(0); }); + + it('renders nothing for equal columns whose gap overflows the content area (SD-2629 legacy guard)', () => { + // count:3 with a gap so large the evenly-divided column width goes negative. normalize floors + // fabricated widths at the full content width, so the geometry width alone would not reveal the + // overflow; the pre-geometry equalWidth<=1 guard must still suppress the separators. The far + // fragment sits past where the phantom separators would land, so only the guard (not the + // content-past-separator gate) can suppress them. + const page = buildPage({ + columns: { count: 3, gap: 400, withSeparator: true }, + fragments: [fragAt(96), fragAt(2000)], + }); + paintOnce(buildLayout(page), mount); + + // contentWidth=624, equalWidth=(624-400*2)/3 < 0, so the guard fires. + expect(querySeparators(mount)).toHaveLength(0); + }); }); describe('region-aware path (page.columnRegions)', () => { diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index e995186b3b..20d87aad9f 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -49,7 +49,10 @@ import { formatPageNumber, formatSectionPageNumberText, getCellSpacingPx, + getColumnGeometry, + getColumnSeparatorPositions as getColumnSeparatorPositionsFromGeometry, normalizeColumnLayout, + resolveColumnMode, } from '@superdoc/contracts'; import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; @@ -1999,37 +2002,23 @@ export class DomPainter { } private getColumnSeparatorPositions(columns: ColumnLayout, leftMargin: number, contentWidth: number): number[] { - const hasExplicitWidths = Array.isArray(columns.widths) && columns.widths.length > 0; - - if (!hasExplicitWidths) { - const equalWidth = (contentWidth - columns.gap * (columns.count - 1)) / columns.count; + // SD-2629: separator positions come from the one resolved column geometry (the same source as + // fill count and column widths), not a re-derivation here. The caller has already gated on + // withSeparator and count > 1. + const normalized = normalizeColumnLayout(columns, contentWidth); + // Equal mode: skip when the evenly-divided column is too narrow for a 1px line. This must be + // checked PRE-geometry because normalize floors fabricated widths at 1 (and falls back to the + // full content width when the gap overflows the content area), so the geometry width alone would + // not reveal the overflow. Keyed on resolveColumnMode (not the presence of a widths array) so a + // raw equalWidth:true config carrying stray widths still takes the equal-mode guard. Legacy guard. + if (resolveColumnMode(columns) === 'equal') { + const equalWidth = (contentWidth - columns.gap * (normalized.count - 1)) / normalized.count; if (equalWidth <= 1) return []; - - const separatorPositions: number[] = []; - for (let index = 0; index < columns.count - 1; index += 1) { - separatorPositions.push(leftMargin + (index + 1) * equalWidth + index * columns.gap + columns.gap / 2); - } - return separatorPositions; } - - const normalizedColumns = normalizeColumnLayout(columns, contentWidth); - if (normalizedColumns.count <= 1) return []; - - const columnWidths = - normalizedColumns.widths ?? Array.from({ length: normalizedColumns.count }, () => normalizedColumns.width); - // A 1px separator only makes sense when every participating column is wider than the separator itself. - if (columnWidths.some((columnWidth) => columnWidth <= 1)) return []; - - const separatorPositions: number[] = []; - let cursorX = leftMargin; - - for (let index = 0; index < normalizedColumns.count - 1; index += 1) { - const currentColumnWidth = columnWidths[index] ?? normalizedColumns.width; - separatorPositions.push(cursorX + currentColumnWidth + normalizedColumns.gap / 2); - cursorX += currentColumnWidth + normalizedColumns.gap; - } - - return separatorPositions; + const geometry = getColumnGeometry(normalized); + if (geometry.length <= 1) return []; + if (geometry.some((column) => column.width <= 1)) return []; + return getColumnSeparatorPositionsFromGeometry(geometry, leftMargin); } private renderDecorationsForPage(pageEl: HTMLElement, page: ResolvedPage, pageIndex: number): void { if (this.isSemanticFlow) return; diff --git a/packages/sdk/langs/browser/src/intent-dispatch.ts b/packages/sdk/langs/browser/src/intent-dispatch.ts index 990e1a01d1..5a5a9d24e7 100644 --- a/packages/sdk/langs/browser/src/intent-dispatch.ts +++ b/packages/sdk/langs/browser/src/intent-dispatch.ts @@ -10,84 +10,132 @@ export function dispatchIntentTool( case 'superdoc_get_content': { const { action, ...rest } = args; switch (action) { - case 'text': return execute('doc.getText', rest); - case 'markdown': return execute('doc.getMarkdown', rest); - case 'html': return execute('doc.getHtml', rest); - case 'info': return execute('doc.info', rest); - case 'extract': return execute('doc.extract', rest); - case 'blocks': return execute('doc.blocks.list', rest); - default: throw new Error(`Unknown action for superdoc_get_content: ${action}`); + case 'text': + return execute('doc.getText', rest); + case 'markdown': + return execute('doc.getMarkdown', rest); + case 'html': + return execute('doc.getHtml', rest); + case 'info': + return execute('doc.info', rest); + case 'extract': + return execute('doc.extract', rest); + case 'blocks': + return execute('doc.blocks.list', rest); + default: + throw new Error(`Unknown action for superdoc_get_content: ${action}`); } } case 'superdoc_edit': { const { action, ...rest } = args; switch (action) { - case 'insert': return execute('doc.insert', rest); - case 'replace': return execute('doc.replace', rest); - case 'delete': return execute('doc.delete', rest); - case 'undo': return execute('doc.history.undo', rest); - case 'redo': return execute('doc.history.redo', rest); - default: throw new Error(`Unknown action for superdoc_edit: ${action}`); + case 'insert': + return execute('doc.insert', rest); + case 'replace': + return execute('doc.replace', rest); + case 'delete': + return execute('doc.delete', rest); + case 'undo': + return execute('doc.history.undo', rest); + case 'redo': + return execute('doc.history.redo', rest); + default: + throw new Error(`Unknown action for superdoc_edit: ${action}`); } } case 'superdoc_format': { const { action, ...rest } = args; switch (action) { - case 'inline': return execute('doc.format.apply', rest); - case 'set_style': return execute('doc.styles.paragraph.setStyle', rest); - case 'set_alignment': return execute('doc.format.paragraph.setAlignment', rest); - case 'set_indentation': return execute('doc.format.paragraph.setIndentation', rest); - case 'set_spacing': return execute('doc.format.paragraph.setSpacing', rest); - case 'set_flow_options': return execute('doc.format.paragraph.setFlowOptions', rest); - case 'set_direction': return execute('doc.format.paragraph.setDirection', rest); - default: throw new Error(`Unknown action for superdoc_format: ${action}`); + case 'inline': + return execute('doc.format.apply', rest); + case 'set_style': + return execute('doc.styles.paragraph.setStyle', rest); + case 'set_alignment': + return execute('doc.format.paragraph.setAlignment', rest); + case 'set_indentation': + return execute('doc.format.paragraph.setIndentation', rest); + case 'set_spacing': + return execute('doc.format.paragraph.setSpacing', rest); + case 'set_flow_options': + return execute('doc.format.paragraph.setFlowOptions', rest); + case 'set_direction': + return execute('doc.format.paragraph.setDirection', rest); + default: + throw new Error(`Unknown action for superdoc_format: ${action}`); } } case 'superdoc_create': { const { action, ...rest } = args; switch (action) { - case 'paragraph': return execute('doc.create.paragraph', rest); - case 'heading': return execute('doc.create.heading', rest); - case 'table': return execute('doc.create.table', rest); - default: throw new Error(`Unknown action for superdoc_create: ${action}`); + case 'paragraph': + return execute('doc.create.paragraph', rest); + case 'heading': + return execute('doc.create.heading', rest); + case 'table': + return execute('doc.create.table', rest); + default: + throw new Error(`Unknown action for superdoc_create: ${action}`); } } case 'superdoc_list': { const { action, ...rest } = args; switch (action) { - case 'insert': return execute('doc.lists.insert', rest); - case 'create': return execute('doc.lists.create', rest); - case 'attach': return execute('doc.lists.attach', rest); - case 'detach': return execute('doc.lists.detach', rest); - case 'delete': return execute('doc.lists.delete', rest); - case 'indent': return execute('doc.lists.indent', rest); - case 'outdent': return execute('doc.lists.outdent', rest); - case 'merge': return execute('doc.lists.merge', rest); - case 'split': return execute('doc.lists.split', rest); - case 'set_level': return execute('doc.lists.setLevel', rest); - case 'set_value': return execute('doc.lists.setValue', rest); - case 'continue_previous': return execute('doc.lists.continuePrevious', rest); - case 'set_type': return execute('doc.lists.setType', rest); - default: throw new Error(`Unknown action for superdoc_list: ${action}`); + case 'insert': + return execute('doc.lists.insert', rest); + case 'create': + return execute('doc.lists.create', rest); + case 'attach': + return execute('doc.lists.attach', rest); + case 'detach': + return execute('doc.lists.detach', rest); + case 'delete': + return execute('doc.lists.delete', rest); + case 'indent': + return execute('doc.lists.indent', rest); + case 'outdent': + return execute('doc.lists.outdent', rest); + case 'merge': + return execute('doc.lists.merge', rest); + case 'split': + return execute('doc.lists.split', rest); + case 'set_level': + return execute('doc.lists.setLevel', rest); + case 'set_value': + return execute('doc.lists.setValue', rest); + case 'continue_previous': + return execute('doc.lists.continuePrevious', rest); + case 'set_type': + return execute('doc.lists.setType', rest); + default: + throw new Error(`Unknown action for superdoc_list: ${action}`); } } case 'superdoc_comment': { const { action, ...rest } = args; switch (action) { - case 'create': return execute('doc.comments.create', rest); - case 'update': return execute('doc.comments.patch', rest); - case 'delete': return execute('doc.comments.delete', rest); - case 'get': return execute('doc.comments.get', rest); - case 'list': return execute('doc.comments.list', rest); - default: throw new Error(`Unknown action for superdoc_comment: ${action}`); + case 'create': + return execute('doc.comments.create', rest); + case 'update': + return execute('doc.comments.patch', rest); + case 'delete': + return execute('doc.comments.delete', rest); + case 'get': + return execute('doc.comments.get', rest); + case 'list': + return execute('doc.comments.list', rest); + default: + throw new Error(`Unknown action for superdoc_comment: ${action}`); } } case 'superdoc_track_changes': { const { action, ...rest } = args; switch (action) { - case 'list': return execute('doc.trackChanges.list', rest); - case 'decide': return execute('doc.trackChanges.decide', rest); - default: throw new Error(`Unknown action for superdoc_track_changes: ${action}`); + case 'list': + return execute('doc.trackChanges.list', rest); + case 'decide': + return execute('doc.trackChanges.decide', rest); + default: + throw new Error(`Unknown action for superdoc_track_changes: ${action}`); } } case 'superdoc_search': @@ -95,32 +143,53 @@ export function dispatchIntentTool( case 'superdoc_mutations': { const { action, ...rest } = args; switch (action) { - case 'preview': return execute('doc.mutations.preview', rest); - case 'apply': return execute('doc.mutations.apply', rest); - default: throw new Error(`Unknown action for superdoc_mutations: ${action}`); + case 'preview': + return execute('doc.mutations.preview', rest); + case 'apply': + return execute('doc.mutations.apply', rest); + default: + throw new Error(`Unknown action for superdoc_mutations: ${action}`); } } case 'superdoc_table': { const { action, ...rest } = args; switch (action) { - case 'delete': return execute('doc.tables.delete', rest); - case 'set_layout': return execute('doc.tables.setLayout', rest); - case 'insert_row': return execute('doc.tables.insertRow', rest); - case 'delete_row': return execute('doc.tables.deleteRow', rest); - case 'set_row': return execute('doc.tables.setRowHeight', rest); - case 'set_row_options': return execute('doc.tables.setRowOptions', rest); - case 'insert_column': return execute('doc.tables.insertColumn', rest); - case 'delete_column': return execute('doc.tables.deleteColumn', rest); - case 'set_column': return execute('doc.tables.setColumnWidth', rest); - case 'merge_cells': return execute('doc.tables.mergeCells', rest); - case 'unmerge_cells': return execute('doc.tables.unmergeCells', rest); - case 'set_cell': return execute('doc.tables.setCellProperties', rest); - case 'set_cell_text': return execute('doc.tables.setCellText', rest); - case 'set_shading': return execute('doc.tables.setShading', rest); - case 'set_style_options': return execute('doc.tables.applyStyle', rest); - case 'set_borders': return execute('doc.tables.setBorders', rest); - case 'set_options': return execute('doc.tables.setTableOptions', rest); - default: throw new Error(`Unknown action for superdoc_table: ${action}`); + case 'delete': + return execute('doc.tables.delete', rest); + case 'set_layout': + return execute('doc.tables.setLayout', rest); + case 'insert_row': + return execute('doc.tables.insertRow', rest); + case 'delete_row': + return execute('doc.tables.deleteRow', rest); + case 'set_row': + return execute('doc.tables.setRowHeight', rest); + case 'set_row_options': + return execute('doc.tables.setRowOptions', rest); + case 'insert_column': + return execute('doc.tables.insertColumn', rest); + case 'delete_column': + return execute('doc.tables.deleteColumn', rest); + case 'set_column': + return execute('doc.tables.setColumnWidth', rest); + case 'merge_cells': + return execute('doc.tables.mergeCells', rest); + case 'unmerge_cells': + return execute('doc.tables.unmergeCells', rest); + case 'set_cell': + return execute('doc.tables.setCellProperties', rest); + case 'set_cell_text': + return execute('doc.tables.setCellText', rest); + case 'set_shading': + return execute('doc.tables.setShading', rest); + case 'set_style_options': + return execute('doc.tables.applyStyle', rest); + case 'set_borders': + return execute('doc.tables.setBorders', rest); + case 'set_options': + return execute('doc.tables.setTableOptions', rest); + default: + throw new Error(`Unknown action for superdoc_table: ${action}`); } } default: diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts index 9bdcc3e5b6..17b3097d1e 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/index.test.ts @@ -930,6 +930,7 @@ describe('toFlowBlocks', () => { gap: 101.53333333333333, withSeparator: false, widths: [72, 497.26666666666665], + gaps: [101.53333333333333], equalWidth: false, }); }); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts index 4d710b20f3..b23e382e3a 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/breaks.ts @@ -90,7 +90,8 @@ export function signaturesEqual(a: SectionSignature, b: SectionSignature): boole a.columnsPx.count === b.columnsPx.count && a.columnsPx.gap === b.columnsPx.gap && a.columnsPx.equalWidth === b.columnsPx.equalWidth && - widthsEqual(a.columnsPx.widths, b.columnsPx.widths) + widthsEqual(a.columnsPx.widths, b.columnsPx.widths) && + widthsEqual(a.columnsPx.gaps, b.columnsPx.gaps) ); const numberingEq = diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts index 92664476d5..8105636a39 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.test.ts @@ -284,6 +284,7 @@ describe('extraction', () => { gap: 101.53333333333333, withSeparator: false, widths: [72, 497.26666666666665], + gaps: [101.53333333333333], equalWidth: false, }); }); @@ -324,6 +325,7 @@ describe('extraction', () => { gap: 0, withSeparator: false, widths: [156, 156, 156, 156], + gaps: [0, 0, 0], equalWidth: false, }); }); @@ -466,6 +468,7 @@ describe('extraction', () => { gap: 0, withSeparator: false, widths: [312, 312], + gaps: [0], equalWidth: false, }); }); @@ -536,6 +539,7 @@ describe('extraction', () => { gap: 48, withSeparator: false, widths: [192, 384], + gaps: [48], equalWidth: false, }); }); @@ -574,6 +578,85 @@ describe('extraction', () => { gap: 0, withSeparator: false, widths: [192, 384], + gaps: [0], + equalWidth: false, + }); + }); + + it('emits per-column gaps in explicit mode, dropping the last column space (SD-2629 F9)', () => { + // equalWidth="0", w:num="3" with child spaces [0, 720, 9999]: a gap is the space AFTER each + // non-last column, so gaps.length === count-1 === 2 and the third column's 9999-twip space is + // never a gap. Widths stay 192px each (2880 twips). This is the F9 geometry target. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '3', 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '0' } }, + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '720' } }, + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '9999' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 3, + gap: 0, + withSeparator: false, + widths: [192, 192, 192], + gaps: [0, 48], + equalWidth: false, + }); + }); + + it('derives the explicit scalar gap from the first valid column, ignoring a preceding invalid child (SD-2629)', () => { + // A leading with no usable w:w is dropped from the record model. The scalar gap (the + // single-gap fallback) must come from the first VALID column's own w:space (720tw -> 48px), + // NOT that dropped column's w:space (1440tw -> 96px). The scalar gap and gaps[0] agree. + const para: PMNode = { + type: 'paragraph', + attrs: { + paragraphProperties: { + sectPr: { + type: 'element', + name: 'w:sectPr', + elements: [ + { + name: 'w:cols', + attributes: { 'w:num': '2', 'w:equalWidth': '0' }, + elements: [ + { name: 'w:col', attributes: { 'w:space': '1440' } }, + { name: 'w:col', attributes: { 'w:w': '2880', 'w:space': '720' } }, + { name: 'w:col', attributes: { 'w:w': '2880' } }, + ], + }, + ], + }, + }, + }, + }; + + const result = extractSectionData(para); + + expect(result?.columnsPx).toEqual({ + count: 2, + gap: 48, + withSeparator: false, + widths: [192, 192], + gaps: [48], equalWidth: false, }); }); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts index 4426b06846..b175ee4b22 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/sections/extraction.ts @@ -286,40 +286,60 @@ function extractColumns(elements: SectionElement[]): ColumnLayout | undefined { // ECMA-376 §17.6.4 column mode, validated against Word (MS Word 16 oracle): // Explicit mode (`w:equalWidth="0"`): widths and inter-column spacing come from the child // `` elements (`w:w` + `w:space`, default 0); the section `w:cols/@w:space` is - // ignored. (Per-column distinct spacing is SD-2629; today the first child's space is - // projected as the single gap.) + // ignored. Per-column distinct spacing is emitted as `gaps` (length count-1; the last + // column's space is never a gap). The scalar `gap` stays the first-gap fallback for + // consumers not yet reading geometry. (SD-2629) // Equal mode (`w:equalWidth="1"` or omitted): Word ignores all child `` data. The // gap comes from `w:cols/@w:space` (default 720); a child `w:space` is NOT consulted, and // child widths are dropped so the columns divide evenly. Count comes from `w:num` // (default 1) in equal mode, and is capped to the valid child-width count in explicit // mode (Word renders min(num, count of with a usable w:w)). (SD-2324) const isExplicit = equalWidth === false; - const firstChildSpace = columnChildren.find((child) => child?.attributes?.['w:space'] != null)?.attributes?.[ - 'w:space' - ]; - const gapTwips = isExplicit ? (firstChildSpace ?? 0) : cols.attributes['w:space']; - const gapInches = parseColumnGap(gapTwips as string | number | undefined); - const widths = columnChildren - .map((child) => Number(child.attributes?.['w:w'])) - .filter((widthTwips) => Number.isFinite(widthTwips) && widthTwips > 0) - .map((widthTwips) => (widthTwips / 1440) * PX_PER_INCH); - - // Explicit mode: w:num is capped to the valid child-width count (widths.length), i.e. the - // number of that supplied a usable w:w. Word renders min(num, that count) (e.g. - // w:num="4" with two => 2 columns, verified vs Word). This is the authoritative - // count both the fill loop and width math read; the matching clamp in normalizeColumnLayout - // is a defensive net for any other producer. (SD-2324 F8) - if (isExplicit && widths.length > 0) { - count = Math.min(count, widths.length); + const toPx = (twips: number) => (twips / TWIPS_PER_INCH) * PX_PER_INCH; + + // Build valid records, preserving the (width, space) pairing so a dropped/zero-width + // column never desyncs the per-column spacing from its column. "Valid" = usable w:w (finite, + // > 0), matching the Word count rule min(num, valid-width count). w:col/@space is the space + // AFTER a column and defaults to 0 (ECMA-376 §17.6.3); it must NOT borrow parseColumnGap's + // section-gap default. (SD-2629) + const columnRecords = columnChildren + .map((child) => { + const widthTwips = Number(child.attributes?.['w:w']); + const spaceTwips = Number(child.attributes?.['w:space']); + return { widthTwips, spaceTwips: Number.isFinite(spaceTwips) && spaceTwips > 0 ? spaceTwips : 0 }; + }) + .filter((record) => Number.isFinite(record.widthTwips) && record.widthTwips > 0); + + // Explicit mode: cap w:num to the valid-width count (Word renders min(num, that count); e.g. + // w:num="4" with two => 2 columns, verified vs Word). This is the authoritative count + // both the fill loop and width math read; the matching clamp in normalizeColumnLayout is a + // defensive net for any other producer. (SD-2324 F8) + if (isExplicit && columnRecords.length > 0) { + count = Math.min(count, columnRecords.length); } + // Slice to the resolved count: widths[i] for i < count; gaps[i] = space after column i for + // i < count-1, so gaps.length === count-1 (the last column has no following gap). Slicing the + // VALID records (not raw children) keeps gaps capped at count-1, never the raw child count. + const widths = columnRecords.slice(0, count).map((record) => toPx(record.widthTwips)); + const gaps = columnRecords.slice(0, Math.max(0, count - 1)).map((record) => toPx(record.spaceTwips)); + + // Scalar gap is the single-gap fallback for consumers not yet reading geometry: in explicit mode + // the first VALID column's own space (=== gaps[0]), NOT the first raw child that declares a + // w:space; a dropped or zero-width column must never contribute a gap. Equal mode uses the + // section w:cols/@w:space (default 720). The flip to per-column geometry is step 4. (SD-2629) + const gapPx = isExplicit + ? toPx(columnRecords[0]?.spaceTwips ?? 0) + : parseColumnGap(cols.attributes['w:space'] as string | number | undefined) * PX_PER_INCH; + const result: ColumnLayout = { count, - gap: gapInches * PX_PER_INCH, + gap: gapPx, withSeparator, - // Only explicit columns carry per-column widths; equal mode divides evenly (Word ignores - // child `w:w` when equalWidth is "1" or omitted). + // Only explicit columns carry per-column widths/gaps; equal mode divides evenly (Word ignores + // child `w:w`/`w:space` when equalWidth is "1" or omitted). ...(isExplicit && widths.length > 0 ? { widths } : {}), + ...(isExplicit && gaps.length > 0 ? { gaps } : {}), ...(equalWidth !== undefined ? { equalWidth } : {}), }; diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts index 5c17c52220..eba3c7b4b9 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -1234,7 +1234,8 @@ function buildStoredZipBase64(parts: Record): string { const central: number[] = []; let offset = 0; const push16 = (arr: number[], v: number) => arr.push(v & 0xff, (v >>> 8) & 0xff); - const push32 = (arr: number[], v: number) => arr.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff); + const push32 = (arr: number[], v: number) => + arr.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff); for (const f of files) { const nameBytes = enc.encode(f.name); @@ -9834,8 +9835,9 @@ const dryRunVectors: Partial unknown>> = { 'templates.apply': async () => { const editor = makeTemplatesEditor(); initRevision(editor); - const cvt = (editor as unknown as { converter: { convertedXml: Record; documentModified: boolean } }) - .converter; + const cvt = ( + editor as unknown as { converter: { convertedXml: Record; documentModified: boolean } } + ).converter; const before = JSON.stringify(cvt.convertedXml['word/styles.xml']); // templates.apply is async: await the receipt before asserting no mutation // occurred (the async body resolves the source package before returning). diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-assets.ts b/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-assets.ts index 8d5f4cc8b5..d4319cc07d 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-assets.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-assets.ts @@ -135,9 +135,7 @@ export function importHeaderFooterAssets( // Detect every header/footer part present in the source (even if the page-1 // governing section does not reference it), then attach a representative // source rel id when one exists so the selected sectPr can be rewired. - const hfPartNames = [...byName.keys()] - .filter((n) => /^word\/(header|footer)\d+\.xml$/.test(n)) - .sort(); + const hfPartNames = [...byName.keys()].filter((n) => /^word\/(header|footer)\d+\.xml$/.test(n)).sort(); if (hfPartNames.length === 0) return result; result.detected = true; if (dryRun) { @@ -331,7 +329,13 @@ export function importHeaderFooterAssets( } // Content-type override. - if (ensureContentTypeOverride(converter, targetPartName, kind === 'header' ? HEADER_CONTENT_TYPE : FOOTER_CONTENT_TYPE)) { + if ( + ensureContentTypeOverride( + converter, + targetPartName, + kind === 'header' ? HEADER_CONTENT_TYPE : FOOTER_CONTENT_TYPE, + ) + ) { contentTypesChanged = true; } @@ -401,7 +405,13 @@ export function applyPageOneSectionDefaults( parseXml: (xml: string) => XmlElement, dryRun: boolean, ): SectionDefaultsResult { - const result: SectionDefaultsResult = { detected: false, applied: false, changed: false, changedParts: [], warnings: [] }; + const result: SectionDefaultsResult = { + detected: false, + applied: false, + changed: false, + changedParts: [], + warnings: [], + }; let parsedDoc: XmlElement; try { @@ -427,7 +437,8 @@ export function applyPageOneSectionDefaults( }); return result; } - const bodyProjection = [...projections].reverse().find((p) => p.target.kind === 'body') ?? projections[projections.length - 1]; + const bodyProjection = + [...projections].reverse().find((p) => p.target.kind === 'body') ?? projections[projections.length - 1]; if (!bodyProjection) { result.warnings.push({ code: 'SECTION_DEFAULTS_UNAVAILABLE', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-xml.ts b/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-xml.ts index 141e462d9b..e504cc8372 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-xml.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/templates/template-xml.ts @@ -41,7 +41,10 @@ export function clone(value: T): T { function mergeIgnorableValues(currentValue: string | undefined, sourceValue: string | undefined): string | undefined { const merged = [ - ...new Set([...(currentValue ?? '').split(/\s+/).filter(Boolean), ...(sourceValue ?? '').split(/\s+/).filter(Boolean)]), + ...new Set([ + ...(currentValue ?? '').split(/\s+/).filter(Boolean), + ...(sourceValue ?? '').split(/\s+/).filter(Boolean), + ]), ]; return merged.length ? merged.join(' ') : undefined; } @@ -128,8 +131,13 @@ function replaceSingleton(stylesEl: XmlElement, name: string, sourceNode: XmlEle if (!stylesEl.elements) stylesEl.elements = []; const existingSingletons = stylesEl.elements.filter((c) => localName(c) === name); const existingIndex = stylesEl.elements.findIndex((c) => localName(c) === name); - const desiredIndex = name === 'docDefaults' ? 0 : stylesEl.elements.some((c) => localName(c) === 'docDefaults') ? 1 : 0; - if (existingSingletons.length === 1 && existingIndex === desiredIndex && xmlDeepEqual(existingSingletons[0], sourceNode)) { + const desiredIndex = + name === 'docDefaults' ? 0 : stylesEl.elements.some((c) => localName(c) === 'docDefaults') ? 1 : 0; + if ( + existingSingletons.length === 1 && + existingIndex === desiredIndex && + xmlDeepEqual(existingSingletons[0], sourceNode) + ) { return false; } @@ -176,7 +184,11 @@ export function mergeStylesAuthoritative(currentRoot: XmlElement, sourceRoot: Xm if (!curStyles.elements) curStyles.elements = []; // Singletons. - result.docDefaultsAdopted = replaceSingleton(curStyles, 'docDefaults', firstChildByLocalName(srcStyles, 'docDefaults')); + result.docDefaultsAdopted = replaceSingleton( + curStyles, + 'docDefaults', + firstChildByLocalName(srcStyles, 'docDefaults'), + ); result.latentStylesAdopted = replaceSingleton( curStyles, 'latentStyles', diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.integration.test.ts b/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.integration.test.ts index a1a2e92a83..e860678672 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.integration.test.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.integration.test.ts @@ -124,10 +124,13 @@ describe('templates.apply adapter integration', () => { const beforeStyles = JSON.stringify(cvt.convertedXml['word/styles.xml']); const beforeTheme = cvt.convertedXml['word/theme/theme1.xml']; - const receipt = (await editor.doc.templates.apply({ - source: { kind: 'base64', data }, - bodyPolicy: 'preserve', - }, { dryRun: true })) as TemplatesApplyReceipt; + const receipt = (await editor.doc.templates.apply( + { + source: { kind: 'base64', data }, + bodyPolicy: 'preserve', + }, + { dryRun: true }, + )) as TemplatesApplyReceipt; expect(receipt.success).toBe(true); if (!receipt.success) return; @@ -166,8 +169,12 @@ describe('templates.apply adapter integration', () => { }); editor = newEditor(); - const cvt = (editor as unknown as { converter: { convertedXml: Record; schemaToXml: (d: unknown) => string } }).converter; - const bodyBeforeXml = cvt.schemaToXml((cvt.convertedXml['word/document.xml'] as { elements: unknown[] }).elements[0]); + const cvt = ( + editor as unknown as { converter: { convertedXml: Record; schemaToXml: (d: unknown) => string } } + ).converter; + const bodyBeforeXml = cvt.schemaToXml( + (cvt.convertedXml['word/document.xml'] as { elements: unknown[] }).elements[0], + ); const receipt = (await editor.doc.templates.apply({ source: { kind: 'base64', data } })) as TemplatesApplyReceipt; expect(receipt.success).toBe(true); @@ -184,7 +191,9 @@ describe('templates.apply adapter integration', () => { }); // Body preserved in-memory immediately after apply. - const bodyAfterXml = cvt.schemaToXml((cvt.convertedXml['word/document.xml'] as { elements: unknown[] }).elements[0]); + const bodyAfterXml = cvt.schemaToXml( + (cvt.convertedXml['word/document.xml'] as { elements: unknown[] }).elements[0], + ); expect(bodyAfterXml).toBe(bodyBeforeXml); // Export and re-unzip the real output. diff --git a/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.ts b/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.ts index a1887c30d8..df3db2ddb5 100644 --- a/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.ts +++ b/packages/super-editor/src/editors/v1/document-api-adapters/templates/templates-adapter.ts @@ -214,9 +214,11 @@ interface FsLike { } function getBuiltinModule(id: string): T | undefined { - const proc = (globalThis as unknown as { - process?: { getBuiltinModule?: (moduleId: string) => unknown }; - }).process; + const proc = ( + globalThis as unknown as { + process?: { getBuiltinModule?: (moduleId: string) => unknown }; + } + ).process; if (typeof proc?.getBuiltinModule !== 'function') { return undefined; } @@ -240,9 +242,7 @@ function getNodeRequire(): ((id: string) => unknown) | undefined { } try { - return Function('try { return require; } catch { return undefined; }')() as - | ((id: string) => unknown) - | undefined; + return Function('try { return require; } catch { return undefined; }')() as ((id: string) => unknown) | undefined; } catch { return undefined; } @@ -298,7 +298,9 @@ function resolveSourceBytes(input: TemplatesApplyInput): ByteResult { bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); } else { - return { failure: { code: 'CAPABILITY_UNAVAILABLE', message: 'templates.apply base64 source requires Buffer or atob.' } }; + return { + failure: { code: 'CAPABILITY_UNAVAILABLE', message: 'templates.apply base64 source requires Buffer or atob.' }, + }; } return { bytes }; } catch { diff --git a/shared/font-system/src/resolver.test.ts b/shared/font-system/src/resolver.test.ts index b2b60080f4..29490b8a41 100644 --- a/shared/font-system/src/resolver.test.ts +++ b/shared/font-system/src/resolver.test.ts @@ -462,7 +462,9 @@ describe('category_fallback (non-metric family fallback: Calibri Light -> Carlit physicalFamily: 'Calibri Light', reason: 'as_requested', }); - expect(r.resolvePhysicalFamilyForFace('Calibri Light, sans-serif', R400, noFaces)).toBe('Calibri Light, sans-serif'); + expect(r.resolvePhysicalFamilyForFace('Calibri Light, sans-serif', R400, noFaces)).toBe( + 'Calibri Light, sans-serif', + ); }); it('a customer fonts.map still overrides the category fallback (custom_mapping wins)', () => { diff --git a/shared/font-system/src/substitution-evidence.test.ts b/shared/font-system/src/substitution-evidence.test.ts index 5bc3da58f3..9a441faea0 100644 --- a/shared/font-system/src/substitution-evidence.test.ts +++ b/shared/font-system/src/substitution-evidence.test.ts @@ -24,9 +24,7 @@ describe('substitution evidence -> resolver derivation', () => { expect(resolver.resolvePrimaryPhysicalFamily(logical)).toBe(physical); } // The derivation input is exactly six rows: policyAction 'substitute' with a physical target. - const substituteRows = SUBSTITUTION_EVIDENCE.filter( - (r) => r.policyAction === 'substitute' && r.physicalFamily, - ); + const substituteRows = SUBSTITUTION_EVIDENCE.filter((r) => r.policyAction === 'substitute' && r.physicalFamily); expect(substituteRows).toHaveLength(EXPECTED_SUBSTITUTES.length); }); diff --git a/shared/font-system/src/substitution-evidence.ts b/shared/font-system/src/substitution-evidence.ts index 588740186b..449d524543 100644 --- a/shared/font-system/src/substitution-evidence.ts +++ b/shared/font-system/src/substitution-evidence.ts @@ -134,10 +134,7 @@ export const SUBSTITUTION_EVIDENCE: readonly SubstitutionEvidence[] = Object.fre advance: { meanDelta: 0, maxDelta: 0 }, gates: { static: 'pass', metric: 'pass', layout: 'pass', ship: 'pass' }, policyAction: 'substitute', - measurementRefs: [ - 'calibri__carlito#analytic_advance#2026-06-03', - 'calibri__carlito#face_aggregate#2026-06-03', - ], + measurementRefs: ['calibri__carlito#analytic_advance#2026-06-03', 'calibri__carlito#face_aggregate#2026-06-03'], candidateLicense: 'OFL-1.1', exportRule: 'preserve_original_name', },