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/layout-engine/AGENTS.md b/packages/layout-engine/AGENTS.md index 08b4f72446..3d6a3a73de 100644 --- a/packages/layout-engine/AGENTS.md +++ b/packages/layout-engine/AGENTS.md @@ -57,6 +57,8 @@ reads. | Change style resolution | `style-engine/` | | Change text measurement | `measuring-dom/` | +AIDEV-NOTE: `pm-adapter` must preserve shared `SdtMetadata` object identity for sibling blocks in one id-less SDT container; see `contracts/src/sdt-container.ts` before changing SDT imports. + ## Style Engine (`style-engine/`) Single source of truth for OOXML style cascade resolution. All property resolution flows through here. diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 72da33493a..08cb0815cc 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -101,6 +101,13 @@ export type { import type { LayoutSourceIdentity } from './layout-identity.js'; export { cloneColumnLayout, normalizeColumnLayout, widthsEqual } from './column-layout.js'; export type { NormalizedColumnLayout } from './column-layout.js'; +export { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, + isSdtContainerMetadata, +} from './sdt-container.js'; /** Inline field annotation metadata extracted from w:sdt nodes. */ export type FieldAnnotationMetadata = { type: 'fieldAnnotation'; diff --git a/packages/layout-engine/contracts/src/sdt-container.test.ts b/packages/layout-engine/contracts/src/sdt-container.test.ts new file mode 100644 index 0000000000..d0f8986479 --- /dev/null +++ b/packages/layout-engine/contracts/src/sdt-container.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import type { SdtMetadata } from './index.js'; +import { + getSdtContainerKey, + getSdtContainerKeyForBlock, + getSdtContainerMetadata, + hasExplicitSdtContainerKey, +} from './sdt-container.js'; + +describe('SDT container key helpers', () => { + it('uses the first renderable container metadata', () => { + const containerSdt: SdtMetadata = { type: 'documentSection', id: 'section-1' }; + + expect(getSdtContainerMetadata({ type: 'structuredContent', scope: 'inline', id: 'inline-1' }, containerSdt)).toBe( + containerSdt, + ); + }); + + it('derives explicit keys for block content controls and document sections', () => { + expect(getSdtContainerKey({ type: 'structuredContent', scope: 'block', id: 'sdt-1' })).toBe( + 'structuredContent:sdt-1', + ); + expect(getSdtContainerKey({ type: 'documentSection', sdBlockId: 'section-block-1' })).toBe( + 'documentSection:section-block-1', + ); + }); + + it('derives stable object keys for id-less containers', () => { + const sharedSdt: SdtMetadata = { type: 'structuredContent', scope: 'block', alias: 'Shared' }; + const firstKey = getSdtContainerKey(sharedSdt); + + expect(firstKey).toMatch(/^idlessSdt:/); + expect(getSdtContainerKey(sharedSdt)).toBe(firstKey); + expect(hasExplicitSdtContainerKey(sharedSdt)).toBe(false); + }); + + it('derives keys from any block-like object with SDT attrs', () => { + const sdt: SdtMetadata = { type: 'structuredContent', scope: 'block', id: 'media-sdt' }; + + expect(getSdtContainerKeyForBlock({ attrs: { sdt } })).toBe('structuredContent:media-sdt'); + }); +}); diff --git a/packages/layout-engine/contracts/src/sdt-container.ts b/packages/layout-engine/contracts/src/sdt-container.ts new file mode 100644 index 0000000000..8f650c197a --- /dev/null +++ b/packages/layout-engine/contracts/src/sdt-container.ts @@ -0,0 +1,78 @@ +import type { SdtMetadata } from './index.js'; + +type SdtBlockCandidate = { + attrs?: { + sdt?: SdtMetadata | null; + containerSdt?: SdtMetadata | null; + } | null; +}; + +const idlessSdtContainerKeys = new WeakMap(); +let nextIdlessSdtContainerKey = 0; + +function getIdlessSdtContainerKey(metadata: SdtMetadata): string { + const existingKey = idlessSdtContainerKeys.get(metadata); + if (existingKey) return existingKey; + + // AIDEV-NOTE: Id-less SDT grouping relies on pm-adapter sharing the same + // SdtMetadata object across sibling blocks in one container. Do not replace + // this with alias/title matching; separate controls can share display text. + const key = `idlessSdt:${++nextIdlessSdtContainerKey}`; + idlessSdtContainerKeys.set(metadata, key); + return key; +} + +export function isSdtContainerMetadata(sdt: SdtMetadata | null | undefined): boolean { + if (!sdt) return false; + if (sdt.type === 'documentSection') return true; + if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; + return false; +} + +export function getSdtContainerMetadata( + sdt?: SdtMetadata | null, + containerSdt?: SdtMetadata | null, +): SdtMetadata | null { + if (isSdtContainerMetadata(sdt)) return sdt ?? null; + if (isSdtContainerMetadata(containerSdt)) return containerSdt ?? null; + return null; +} + +export function getSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return null; + + if (metadata.type === 'structuredContent') { + if (metadata.scope !== 'block') return null; + if (metadata.id) return `structuredContent:${metadata.id}`; + return getIdlessSdtContainerKey(metadata); + } + + if (metadata.type === 'documentSection') { + const sectionId = metadata.id ?? metadata.sdBlockId; + if (sectionId) return `documentSection:${sectionId}`; + return getIdlessSdtContainerKey(metadata); + } + + return null; +} + +export function hasExplicitSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): boolean { + const metadata = getSdtContainerMetadata(sdt, containerSdt); + if (!metadata) return false; + + if (metadata.type === 'structuredContent') { + return metadata.scope === 'block' && Boolean(metadata.id); + } + + if (metadata.type === 'documentSection') { + return Boolean(metadata.id ?? metadata.sdBlockId); + } + + return false; +} + +export function getSdtContainerKeyForBlock(block?: SdtBlockCandidate | null): string | null { + if (!block) return null; + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); +} diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts index d817fd0d5f..0b5272d4e9 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.test.ts @@ -2793,7 +2793,7 @@ describe('resolveLayout', () => { expect(drItem.sdtContainerKey).toBeUndefined(); }); - it('returns null (omits key) for structuredContent block scope with no id', () => { + it('sets an object-stable key for structuredContent block scope with no id', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, pages: [ @@ -2815,10 +2815,10 @@ describe('resolveLayout', () => { const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; - expect(item.sdtContainerKey).toBeUndefined(); + expect(item.sdtContainerKey).toMatch(/^idlessSdt:/); }); - it('returns null (omits key) for documentSection with no id or sdBlockId', () => { + it('sets an object-stable key for documentSection with no id or sdBlockId', () => { const layout: Layout = { pageSize: { w: 612, h: 792 }, pages: [ @@ -2840,7 +2840,7 @@ describe('resolveLayout', () => { const result = resolveLayout({ layout, flowMode: 'paginated', blocks, measures }); const item = result.pages[0].items[0] as import('@superdoc/contracts').ResolvedFragmentItem; - expect(item.sdtContainerKey).toBeUndefined(); + expect(item.sdtContainerKey).toMatch(/^idlessSdt:/); }); }); diff --git a/packages/layout-engine/layout-resolved/src/resolveLayout.ts b/packages/layout-engine/layout-resolved/src/resolveLayout.ts index 900d38ae13..6e1a8dd4d5 100644 --- a/packages/layout-engine/layout-resolved/src/resolveLayout.ts +++ b/packages/layout-engine/layout-resolved/src/resolveLayout.ts @@ -22,12 +22,12 @@ import type { ParagraphMeasure, LayoutStoryLocator, } from '@superdoc/contracts'; +import { getSdtContainerKey } from '@superdoc/contracts'; import { resolveParagraphContent } from './resolveParagraph.js'; import { resolveTableItem } from './resolveTable.js'; import { resolveImageItem } from './resolveImage.js'; import { resolveDrawingItem } from './resolveDrawing.js'; import type { BlockMapEntry } from './resolvedBlockLookup.js'; -import { computeSdtContainerKey } from './sdtContainerKey.js'; import { hashParagraphBorders } from './paragraphBorderHash.js'; import { deriveBlockVersion, @@ -162,17 +162,17 @@ function resolveFragmentSdtContainerKey(fragment: Fragment, blockMap: Map listItem.id === fragment.itemId); - return computeSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); + return getSdtContainerKey(item?.paragraph.attrs?.sdt, item?.paragraph.attrs?.containerSdt); } if (fragment.kind === 'table' && block.kind === 'table') { - return computeSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); + return getSdtContainerKey(block.attrs?.sdt, block.attrs?.containerSdt); } // image, drawing — no SDT container keys diff --git a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts b/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts deleted file mode 100644 index 4cee08673f..0000000000 --- a/packages/layout-engine/layout-resolved/src/sdtContainerKey.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SdtMetadata } from '@superdoc/contracts'; - -/** - * Returns a stable key for grouping consecutive fragments in the same SDT container. - * - * This is a minimal duplicate of the logic in `painters/dom/src/utils/sdt-helpers.ts` - * (`getSdtContainerKey`), kept here to avoid a dependency on the painter package. - * Only the key derivation is needed; DOM styling helpers are not. - */ -export function computeSdtContainerKey(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): string | null { - const metadata = getSdtContainerMetadata(sdt, containerSdt); - if (!metadata) return null; - - if (metadata.type === 'structuredContent') { - if (metadata.scope !== 'block') return null; - if (!metadata.id) return null; - return `structuredContent:${metadata.id}`; - } - - if (metadata.type === 'documentSection') { - const sectionId = metadata.id ?? metadata.sdBlockId; - if (!sectionId) return null; - return `documentSection:${sectionId}`; - } - - return null; -} - -function isSdtContainer(sdt?: SdtMetadata | null): boolean { - if (!sdt) return false; - if (sdt.type === 'documentSection') return true; - if (sdt.type === 'structuredContent' && sdt.scope === 'block') return true; - return false; -} - -function getSdtContainerMetadata(sdt?: SdtMetadata | null, containerSdt?: SdtMetadata | null): SdtMetadata | null { - if (isSdtContainer(sdt)) return sdt ?? null; - if (isSdtContainer(containerSdt)) return containerSdt ?? null; - return null; -} diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts index 4385b9453d..485e126da2 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.test.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js'; -import type { FlowBlock, SourceAnchor, TextRun } from '@superdoc/contracts'; +import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TextRun } from '@superdoc/contracts'; describe('sourceAnchorSignature', () => { it('is stable for equivalent source anchors with different object key order', () => { @@ -66,3 +66,142 @@ describe('deriveBlockVersion - bidi', () => { expect(a).toBe(b); }); }); + +describe('deriveBlockVersion - table image content', () => { + const makeTableWithImage = (image: ImageBlock): TableBlock => ({ + kind: 'table', + id: 'table-with-image', + rows: [ + { + id: 'row-1', + cells: [ + { + id: 'cell-1', + blocks: [image], + }, + ], + }, + ], + }); + + const baseImage: ImageBlock = { + kind: 'image', + id: 'image-1', + src: 'data:image/png;base64,AAA', + width: 40, + height: 20, + }; + + it('changes when a table image filter changes', () => { + const plain = deriveBlockVersion(makeTableWithImage(baseImage)); + const filtered = deriveBlockVersion(makeTableWithImage({ ...baseImage, grayscale: true })); + + expect(filtered).not.toBe(plain); + }); + + it('changes when a table image hyperlink changes', () => { + const unlinked = deriveBlockVersion(makeTableWithImage(baseImage)); + const linked = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/image', tooltip: 'Open image' }, + }), + ); + + expect(linked).not.toBe(unlinked); + }); + + it('does not collide when image hyperlink URL and tooltip contain separators', () => { + const first = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/a', tooltip: 'b:c' }, + }), + ); + const second = deriveBlockVersion( + makeTableWithImage({ + ...baseImage, + hyperlink: { url: 'https://example.com/a:b', tooltip: 'c' }, + }), + ); + + expect(second).not.toBe(first); + }); +}); + +describe('deriveBlockVersion - inline image runs', () => { + const baseImageRun: ImageRun = { + kind: 'image', + src: 'data:image/png;base64,AAA', + width: 40, + height: 20, + }; + + const makeParagraphWithImageRun = (image: ImageRun): FlowBlock => ({ + kind: 'paragraph', + id: 'paragraph-with-image-run', + runs: [image], + }); + + const makeTableWithImageRun = (image: ImageRun): TableBlock => ({ + kind: 'table', + id: 'table-with-inline-image-run', + rows: [ + { + id: 'row-1', + cells: [ + { + id: 'cell-1', + blocks: [makeParagraphWithImageRun(image)], + }, + ], + }, + ], + }); + + it('changes when an inline image filter changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const filtered = deriveBlockVersion( + makeParagraphWithImageRun({ ...baseImageRun, grayscale: true, lum: { bright: 25000 } }), + ); + + expect(filtered).not.toBe(plain); + }); + + it('changes when an inline image transform changes', () => { + const plain = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const transformed = deriveBlockVersion(makeParagraphWithImageRun({ ...baseImageRun, rotation: 45, flipH: true })); + + expect(transformed).not.toBe(plain); + }); + + it('changes when an inline image hyperlink changes', () => { + const unlinked = deriveBlockVersion(makeParagraphWithImageRun(baseImageRun)); + const linked = deriveBlockVersion( + makeParagraphWithImageRun({ ...baseImageRun, hyperlink: { url: 'https://example.com/inline-image' } }), + ); + + expect(linked).not.toBe(unlinked); + }); + + it('changes when an inline image raw clip path changes', () => { + const clipA = { ...baseImageRun, clipPath: 'url(#clip-a)' }; + const clipB = { ...baseImageRun, clipPath: 'url(#clip-b)' }; + + expect(deriveBlockVersion(makeParagraphWithImageRun(clipA))).not.toBe( + deriveBlockVersion(makeParagraphWithImageRun(clipB)), + ); + expect(deriveBlockVersion(makeTableWithImageRun(clipA))).not.toBe(deriveBlockVersion(makeTableWithImageRun(clipB))); + }); + + it('changes when a table-cell inline image visual property changes', () => { + const plain = deriveBlockVersion(makeTableWithImageRun(baseImageRun)); + const filtered = deriveBlockVersion(makeTableWithImageRun({ ...baseImageRun, grayscale: true })); + const linked = deriveBlockVersion( + makeTableWithImageRun({ ...baseImageRun, hyperlink: { url: 'https://example.com/table-inline-image' } }), + ); + + expect(filtered).not.toBe(plain); + expect(linked).not.toBe(plain); + }); +}); diff --git a/packages/layout-engine/layout-resolved/src/versionSignature.ts b/packages/layout-engine/layout-resolved/src/versionSignature.ts index 7d1f223147..652b70c5b9 100644 --- a/packages/layout-engine/layout-resolved/src/versionSignature.ts +++ b/packages/layout-engine/layout-resolved/src/versionSignature.ts @@ -18,6 +18,7 @@ import { type TableAttrs, type TableBlock, type TableCellAttrs, + type TrackedChangeMeta, type TextRun, type VectorShapeDrawing, } from '@superdoc/contracts'; @@ -54,6 +55,32 @@ const getSdtMetadataVersion = (metadata: SdtMetadata | null | undefined): string return [metadata.type, getSdtMetadataLockMode(metadata), getSdtMetadataId(metadata)].join(':'); }; +const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [run.trackedChange] : []; +}; + +const trackedChangeVersion = (run: TextRun): string => + getTrackedChangeLayers(run) + .map((trackedChange) => + [ + trackedChange.kind ?? '', + trackedChange.id ?? '', + trackedChange.storyKey ?? '', + trackedChange.overlapParentId ?? '', + trackedChange.relationship ?? '', + trackedChange.author ?? '', + trackedChange.authorEmail ?? '', + trackedChange.authorImage ?? '', + trackedChange.date ?? '', + trackedChange.before ? JSON.stringify(trackedChange.before) : '', + trackedChange.after ? JSON.stringify(trackedChange.after) : '', + ].join(':'), + ) + .join('|'); + // --------------------------------------------------------------------------- // Clip path helpers // --------------------------------------------------------------------------- @@ -81,6 +108,60 @@ const resolveBlockClipPath = (block: unknown): string => { return readClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); }; +const imageHyperlinkVersion = (hyperlink: ImageBlock['hyperlink'] | undefined): string => { + if (!hyperlink) return ''; + return JSON.stringify([hyperlink.url ?? '', hyperlink.tooltip ?? '']); +}; + +const imageLuminanceVersion = (lum: ImageBlock['lum'] | undefined): string => { + if (!lum) return ''; + return [lum.bright ?? '', lum.contrast ?? ''].join(':'); +}; + +const renderedBlockImageVersion = (image: ImageBlock | ImageDrawing): string => + [ + image.src ?? '', + image.width ?? '', + image.height ?? '', + image.alt ?? '', + image.title ?? '', + image.objectFit ?? '', + image.display ?? '', + image.gain ?? '', + image.blacklevel ?? '', + image.grayscale ? 1 : 0, + imageLuminanceVersion(image.lum), + image.rotation ?? '', + image.flipH ? 1 : 0, + image.flipV ? 1 : 0, + imageHyperlinkVersion(image.hyperlink), + resolveBlockClipPath(image), + ].join('|'); + +const renderedInlineImageRunVersion = (image: ImageRun): string => + [ + 'img', + image.src ?? '', + image.width ?? '', + image.height ?? '', + image.alt ?? '', + image.title ?? '', + typeof image.clipPath === 'string' ? image.clipPath.trim() : '', + image.distTop ?? '', + image.distBottom ?? '', + image.distLeft ?? '', + image.distRight ?? '', + image.verticalAlign ?? '', + image.gain ?? '', + image.blacklevel ?? '', + image.grayscale ? 1 : 0, + imageLuminanceVersion(image.lum), + image.rotation ?? '', + image.flipH ? 1 : 0, + image.flipV ? 1 : 0, + imageHyperlinkVersion(image.hyperlink), + ].join('|'); + // --------------------------------------------------------------------------- // List marker validation // --------------------------------------------------------------------------- @@ -203,9 +284,8 @@ export const resolveFragmentLayoutIdentity = (fragment: Fragment, story?: Layout * This version string is used for cache invalidation. When any visual property of the block * changes, the version string changes, triggering a DOM rebuild instead of reusing cached elements. * - * Duplicated from painters/dom/src/renderer.ts to allow the resolved layout stage to - * pre-compute block versions without depending on painter-dom. Keep the two copies in sync - * until the painter fully migrates to resolved versions. + * Kept in layout-resolved so the resolved layout stage can pre-compute block + * versions without depending on painter-dom. */ export const deriveBlockVersion = (block: FlowBlock): string => { if (block.kind === 'paragraph') { @@ -216,21 +296,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { const runsVersion = block.runs .map((run) => { if (run.kind === 'image') { - const imgRun = run as ImageRun; - return [ - 'img', - imgRun.src, - imgRun.width, - imgRun.height, - imgRun.alt ?? '', - imgRun.title ?? '', - imgRun.clipPath ?? '', - imgRun.distTop ?? '', - imgRun.distBottom ?? '', - imgRun.distLeft ?? '', - imgRun.distRight ?? '', - readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), - ].join(','); + return renderedInlineImageRunVersion(run as ImageRun); } if (run.kind === 'lineBreak') { @@ -271,6 +337,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { } const textRun = run as TextRun; + const trackedVersion = trackedChangeVersion(textRun); return [ textRun.text ?? '', textRun.fontFamily, @@ -286,7 +353,7 @@ export const deriveBlockVersion = (block: FlowBlock): string => { textRun.vertAlign ?? '', textRun.baselineShift != null ? textRun.baselineShift : '', textRun.token ?? '', - textRun.trackedChange ? 1 : 0, + trackedVersion, textRun.comments?.length ?? 0, // SD-3098: DomPainter reads run.bidi to apply dir + RLM injection; signature must include it. textRun.bidi ? JSON.stringify(textRun.bidi) : '', @@ -329,28 +396,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => { if (block.kind === 'image') { const imgSdt = (block as ImageBlock).attrs?.sdt; const imgSdtVersion = getSdtMetadataVersion(imgSdt); - return [ - block.src ?? '', - block.width ?? '', - block.height ?? '', - block.alt ?? '', - block.title ?? '', - resolveBlockClipPath(block), - imgSdtVersion, - ].join('|'); + return [renderedBlockImageVersion(block), imgSdtVersion].join('|'); } if (block.kind === 'drawing') { if (block.drawingKind === 'image') { const imageLike = block as ImageDrawing; - return [ - 'drawing:image', - imageLike.src ?? '', - imageLike.width ?? '', - imageLike.height ?? '', - imageLike.alt ?? '', - resolveBlockClipPath(imageLike), - ].join('|'); + return ['drawing:image', renderedBlockImageVersion(imageLike)].join('|'); } if (block.drawingKind === 'vectorShape') { const vector = block as VectorShapeDrawing; @@ -461,6 +513,13 @@ export const deriveBlockVersion = (block: FlowBlock): string => { } for (const run of runs) { + if (run.kind === 'image') { + hash = hashString(hash, renderedInlineImageRunVersion(run as ImageRun)); + hash = hashNumber(hash, run.pmStart ?? -1); + hash = hashNumber(hash, run.pmEnd ?? -1); + continue; + } + if ('text' in run && typeof run.text === 'string') { hash = hashString(hash, run.text); } @@ -481,7 +540,10 @@ export const deriveBlockVersion = (block: FlowBlock): string => { // SD-3098: include run.bidi so rtl-only changes invalidate the cached block hash. const bidi = (run as { bidi?: unknown }).bidi; hash = hashString(hash, bidi ? JSON.stringify(bidi) : ''); + hash = hashString(hash, trackedChangeVersion(run as TextRun)); } + } else if (cellBlock?.kind) { + hash = hashString(hash, deriveBlockVersion(cellBlock as FlowBlock)); } } } diff --git a/packages/layout-engine/painters/dom/src/between-borders.test.ts b/packages/layout-engine/painters/dom/src/between-borders.test.ts index 9030fe8f47..12f33cf124 100644 --- a/packages/layout-engine/painters/dom/src/between-borders.test.ts +++ b/packages/layout-engine/painters/dom/src/between-borders.test.ts @@ -6,7 +6,7 @@ import { getParagraphBorderBox, computeBorderSpaceExpansion, type BetweenBorderInfo, -} from './features/paragraph-borders/index.js'; +} from './paragraph/borders/index.js'; import { hashParagraphBorders } from './paragraph-hash-utils.js'; /** Helper to create BetweenBorderInfo for tests that previously passed a boolean. */ @@ -27,13 +27,11 @@ import type { ParagraphBorders, ParagraphBorder, ParagraphBlock, - ListBlock, Fragment, FlowBlock, Layout, Measure, ParaFragment, - ListItemFragment, ImageFragment, ResolvedPaintItem, ResolvedFragmentItem, @@ -50,35 +48,19 @@ const makeParagraphBlock = (id: string, borders?: ParagraphBorders): ParagraphBl attrs: borders ? { borders } : undefined, }); -const makeListBlock = (id: string, items: { itemId: string; borders?: ParagraphBorders }[]): ListBlock => ({ - kind: 'list', - id, - listType: 'bullet', - items: items.map((item) => ({ - id: item.itemId, - marker: { text: '•' }, - paragraph: { - kind: 'paragraph', - id: `${id}-p-${item.itemId}`, - runs: [], - attrs: item.borders ? { borders: item.borders } : undefined, - }, - })), -}); - /** * Test surrogate for the old BlockLookup — a list of blocks keyed by id that * `buildResolvedItems` consumes to synthesize per-fragment ResolvedPaintItems. */ -type TestBlockList = ReadonlyArray; +type TestBlockList = ReadonlyArray; -const buildLookup = (entries: { block: ParagraphBlock | ListBlock; measure?: unknown }[]): TestBlockList => +const buildLookup = (entries: { block: ParagraphBlock; measure?: unknown }[]): TestBlockList => entries.map((e) => e.block); /** * Build resolved items aligned 1:1 with the given fragments. - * Looks up each fragment's block (+ list item) to extract paragraph borders, - * then produces a ResolvedFragmentItem carrying the borders and a border hash. + * Looks up each fragment's block to extract paragraph borders, then produces a + * ResolvedFragmentItem carrying the borders and a border hash. */ const buildResolvedItems = (fragments: readonly Fragment[], blocks: TestBlockList): ResolvedPaintItem[] => { const byId = new Map(blocks.map((b) => [b.id, b])); @@ -88,9 +70,6 @@ const buildResolvedItems = (fragments: readonly Fragment[], blocks: TestBlockLis if (fragment.kind === 'para' && block?.kind === 'paragraph') { borders = block.attrs?.borders; - } else if (fragment.kind === 'list-item' && block?.kind === 'list') { - const item = block.items.find((listItem) => listItem.id === fragment.itemId); - borders = item?.paragraph.attrs?.borders; } const item: ResolvedFragmentItem = { @@ -127,23 +106,6 @@ const paraFragment = (blockId: string, overrides?: Partial): ParaF ...overrides, }); -const listItemFragment = ( - blockId: string, - itemId: string, - overrides?: Partial, -): ListItemFragment => ({ - kind: 'list-item', - blockId, - itemId, - fromLine: 0, - toLine: 1, - x: 0, - y: 0, - width: 100, - markerWidth: 20, - ...overrides, -}); - const imageFragment = (blockId: string): ImageFragment => ({ kind: 'image', blockId, @@ -517,29 +479,6 @@ describe('computeBetweenBorderFlags', () => { expect(runFlags(fragments, lookup).size).toBe(0); }); - it('does not flag same blockId + same itemId list-item fragments', () => { - const block = makeListBlock('l1', [{ itemId: 'i1', borders: MATCHING_BORDERS }]); - const lookup = buildLookup([{ block }]); - const fragments: Fragment[] = [ - listItemFragment('l1', 'i1', { fromLine: 0, toLine: 2 }), - listItemFragment('l1', 'i1', { fromLine: 2, toLine: 4 }), - ]; - - expect(runFlags(fragments, lookup).size).toBe(0); - }); - - it('flags different itemIds in same list block', () => { - const block = makeListBlock('l1', [ - { itemId: 'i1', borders: MATCHING_BORDERS }, - { itemId: 'i2', borders: MATCHING_BORDERS }, - ]); - const lookup = buildLookup([{ block }]); - const fragments: Fragment[] = [listItemFragment('l1', 'i1'), listItemFragment('l1', 'i2')]; - - const flags = runFlags(fragments, lookup); - expect(flags.has(0)).toBe(true); - }); - // --- non-paragraph fragments --- it('skips image fragments', () => { const b1 = makeParagraphBlock('b1', MATCHING_BORDERS); @@ -555,25 +494,6 @@ describe('computeBetweenBorderFlags', () => { expect(flags.size).toBe(0); }); - // --- mixed para + list-item --- - it('flags para followed by list-item with matching borders', () => { - const b1 = makeParagraphBlock('b1', MATCHING_BORDERS); - const block = makeListBlock('l1', [{ itemId: 'i1', borders: MATCHING_BORDERS }]); - const lookup = buildLookup([{ block: b1 }, { block }]); - const fragments: Fragment[] = [paraFragment('b1'), listItemFragment('l1', 'i1')]; - - expect(runFlags(fragments, lookup).has(0)).toBe(true); - }); - - it('flags list-item followed by para with matching borders', () => { - const block = makeListBlock('l1', [{ itemId: 'i1', borders: MATCHING_BORDERS }]); - const b2 = makeParagraphBlock('b2', MATCHING_BORDERS); - const lookup = buildLookup([{ block }, { block: b2 }]); - const fragments: Fragment[] = [listItemFragment('l1', 'i1'), paraFragment('b2')]; - - expect(runFlags(fragments, lookup).has(0)).toBe(true); - }); - // --- multiple consecutive --- it('flags all boundaries in a chain of three matching paragraphs', () => { const b1 = makeParagraphBlock('b1', MATCHING_BORDERS); diff --git a/packages/layout-engine/painters/dom/src/features/feature-registry.ts b/packages/layout-engine/painters/dom/src/features/feature-registry.ts index 831c2e3eaf..f5fe64343f 100644 --- a/packages/layout-engine/painters/dom/src/features/feature-registry.ts +++ b/packages/layout-engine/painters/dom/src/features/feature-registry.ts @@ -17,8 +17,8 @@ export const RENDERING_FEATURES = { // ─── Paragraph Borders ─────────────────────────────────────────── // @spec ECMA-376 §17.3.1.24 (pBdr) 'w:pBdr': { - feature: 'paragraph-borders', - module: './paragraph-borders', + feature: 'paragraph/borders', + module: '../paragraph/borders', handles: ['w:pBdr/w:top', 'w:pBdr/w:bottom', 'w:pBdr/w:left', 'w:pBdr/w:right', 'w:pBdr/w:between', 'w:pBdr/w:bar'], spec: '§17.3.1.24', }, @@ -26,8 +26,8 @@ export const RENDERING_FEATURES = { // ─── Paragraph Shading ─────────────────────────────────────────── // @spec ECMA-376 §17.3.1.31 (shd) 'w:shd': { - feature: 'paragraph-borders', // shading shares the border layer module - module: './paragraph-borders', + feature: 'paragraph/borders', // shading shares the border layer module + module: '../paragraph/borders', handles: ['w:shd/@w:fill', 'w:shd/@w:val', 'w:shd/@w:color'], spec: '§17.3.1.31', }, diff --git a/packages/layout-engine/painters/dom/src/images/drawing-image.ts b/packages/layout-engine/painters/dom/src/images/drawing-image.ts new file mode 100644 index 0000000000..70f72c7429 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/drawing-image.ts @@ -0,0 +1,51 @@ +import type { + DrawingBlock, + ImageDrawing, + PositionedDrawingGeometry, + ShapeGroupChild, + TextPart, +} from '@superdoc/contracts'; +import { applyImageClipPath } from './image-clip-path.js'; +import { createBlockImageContent } from './image-block.js'; +import type { BuildImageHyperlinkAnchor } from './types.js'; + +export const createDrawingImageElement = ( + doc: Document, + block: DrawingBlock, + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor, +): HTMLElement => { + const drawing = block as ImageDrawing; + return createBlockImageContent({ + doc, + block: drawing, + className: 'superdoc-drawing-image', + imageDisplay: 'block', + buildImageHyperlinkAnchor, + }); +}; + +export const createShapeGroupImageElement = (doc: Document, child: ShapeGroupChild): HTMLElement => { + const attrs = child.attrs as PositionedDrawingGeometry & { + src: string; + alt?: string; + clipPath?: string; + }; + const img = doc.createElement('img'); + img.src = attrs.src; + img.alt = attrs.alt ?? ''; + img.style.objectFit = 'contain'; + img.style.display = 'block'; + applyImageClipPath(img, attrs.clipPath); + return img; +}; + +export const createShapeTextImageElement = (doc: Document, part: TextPart): HTMLElement => { + const img = doc.createElement('img'); + img.src = part.src!; + img.alt = part.alt ?? ''; + if (typeof part.width === 'number') img.style.width = `${part.width}px`; + if (typeof part.height === 'number') img.style.height = `${part.height}px`; + img.style.display = 'inline-block'; + img.style.verticalAlign = 'bottom'; + return img; +}; diff --git a/packages/layout-engine/painters/dom/src/images/hyperlink.ts b/packages/layout-engine/painters/dom/src/images/hyperlink.ts new file mode 100644 index 0000000000..2ebb26c4da --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/hyperlink.ts @@ -0,0 +1,49 @@ +import type { ImageHyperlink } from '@superdoc/contracts'; +import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; + +export const buildImageHyperlinkAnchor = ( + doc: Document, + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +): HTMLElement => { + if (!hyperlink?.url) return imageEl; + + const sanitized = sanitizeHref(hyperlink.url); + if (!sanitized?.href) return imageEl; + + const anchor = doc.createElement('a'); + anchor.href = sanitized.href; + anchor.classList.add('superdoc-link'); + + if (sanitized.protocol === 'http' || sanitized.protocol === 'https') { + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + + const tooltipSource = + typeof hyperlink.tooltip === 'string' && hyperlink.tooltip.trim().length > 0 ? hyperlink.tooltip : hyperlink.url; + const tooltipResult = encodeTooltip(tooltipSource); + if (tooltipResult?.text) { + anchor.title = tooltipResult.text; + } + + for (const titledElement of [imageEl, ...Array.from(imageEl.querySelectorAll('[title]'))]) { + titledElement.removeAttribute('title'); + } + + anchor.setAttribute('role', 'link'); + anchor.setAttribute('tabindex', '0'); + + if (display === 'block') { + anchor.style.cssText = 'display: block; width: 100%; height: 100%; cursor: pointer;'; + } else { + anchor.style.display = 'inline-block'; + anchor.style.lineHeight = '0'; + anchor.style.cursor = 'pointer'; + anchor.style.verticalAlign = imageEl.style.verticalAlign || 'bottom'; + } + + anchor.appendChild(imageEl); + return anchor; +}; diff --git a/packages/layout-engine/painters/dom/src/images/image-block.test.ts b/packages/layout-engine/painters/dom/src/images/image-block.test.ts new file mode 100644 index 0000000000..30f63e7da7 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-block.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import type { DrawingBlock } from '@superdoc/contracts'; +import { createDrawingImageElement } from './drawing-image.js'; +import { buildImageHyperlinkAnchor } from './hyperlink.js'; +import { resolveBlockImageClipPath } from './image-block.js'; + +describe('resolveBlockImageClipPath', () => { + it('prefers a top-level clipPath over attrs.clipPath', () => { + expect( + resolveBlockImageClipPath({ + clipPath: 'inset(1% 2% 3% 4%)', + attrs: { clipPath: 'inset(5% 6% 7% 8%)' }, + }), + ).toBe('inset(1% 2% 3% 4%)'); + }); + + it('falls back to attrs.clipPath when top-level clipPath is absent', () => { + expect(resolveBlockImageClipPath({ attrs: { clipPath: 'inset(5% 6% 7% 8%)' } })).toBe('inset(5% 6% 7% 8%)'); + }); + + it('ignores unsupported clip-path values', () => { + expect(resolveBlockImageClipPath({ clipPath: 'url(#clip)' })).toBe(''); + }); +}); + +describe('createDrawingImageElement', () => { + const createDoc = (): Document => document.implementation.createHTMLDocument('drawing-image'); + + it('applies unified image filters to drawing images', () => { + const doc = createDoc(); + const drawing = { + kind: 'drawing', + drawingKind: 'image', + id: 'drawing-image-filtered', + src: 'data:image/png;base64,AAA', + grayscale: true, + gain: 2, + } as DrawingBlock; + + const imgEl = createDrawingImageElement(doc, drawing, (imageEl) => imageEl) as HTMLImageElement; + + expect(imgEl.style.display).toBe('block'); + expect(imgEl.style.filter).toContain('grayscale(100%)'); + expect(imgEl.style.filter).toContain('contrast(2)'); + }); + + it('wraps drawing images with unified hyperlink anchors', () => { + const doc = createDoc(); + const drawing = { + kind: 'drawing', + drawingKind: 'image', + id: 'drawing-image-linked', + src: 'data:image/png;base64,AAA', + hyperlink: { url: 'https://example.com/drawing-image', tooltip: 'Open drawing image' }, + } as DrawingBlock; + + const anchor = createDrawingImageElement(doc, drawing, (imageEl, hyperlink, display) => + buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display), + ) as HTMLAnchorElement; + + expect(anchor.tagName).toBe('A'); + expect(anchor.classList.contains('superdoc-link')).toBe(true); + expect(anchor.href).toBe('https://example.com/drawing-image'); + expect(anchor.style.display).toBe('block'); + expect(anchor.querySelector('img.superdoc-drawing-image')).toBeTruthy(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/images/image-block.ts b/packages/layout-engine/painters/dom/src/images/image-block.ts new file mode 100644 index 0000000000..0541294831 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-block.ts @@ -0,0 +1,62 @@ +import type { ImageBlock, ImageDrawing } from '@superdoc/contracts'; +import { buildImageFilters } from '../runs/image-run.js'; +import { applyImageClipPath, readImageClipPathValue } from './image-clip-path.js'; +import type { BuildImageHyperlinkAnchor } from './types.js'; + +type BlockImageSource = ImageBlock | ImageDrawing; + +export type CreateBlockImageContentOptions = { + doc: Document; + block: BlockImageSource; + className?: string; + clipContainer?: HTMLElement; + imageDisplay?: 'block' | 'inline-block'; + hyperlinkDisplay?: 'block' | 'inline-block'; + buildImageHyperlinkAnchor?: BuildImageHyperlinkAnchor; +}; + +const resolveClipPathFromAttrs = (attrs: unknown): string => { + if (!attrs || typeof attrs !== 'object') return ''; + const record = attrs as Record; + return readImageClipPathValue(record.clipPath); +}; + +export const resolveBlockImageClipPath = (block: unknown): string => { + if (!block || typeof block !== 'object') return ''; + const record = block as Record; + return readImageClipPathValue(record.clipPath) || resolveClipPathFromAttrs(record.attrs); +}; + +export const createBlockImageContent = ({ + doc, + block, + className, + clipContainer, + imageDisplay, + hyperlinkDisplay = 'block', + buildImageHyperlinkAnchor, +}: CreateBlockImageContentOptions): HTMLElement => { + const img = doc.createElement('img'); + if (className) { + img.classList.add(className); + } + if (block.src) { + img.src = block.src; + } + img.alt = block.alt ?? ''; + img.style.width = '100%'; + img.style.height = '100%'; + img.style.objectFit = block.objectFit ?? 'contain'; + if (block.objectFit === 'cover') { + img.style.objectPosition = 'left top'; + } + applyImageClipPath(img, resolveBlockImageClipPath(block), clipContainer ? { clipContainer } : undefined); + img.style.display = imageDisplay ?? (block.display === 'inline' ? 'inline-block' : 'block'); + + const filters = buildImageFilters(block); + if (filters.length > 0) { + img.style.filter = filters.join(' '); + } + + return buildImageHyperlinkAnchor?.(img, block.hyperlink, hyperlinkDisplay) ?? img; +}; diff --git a/packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts b/packages/layout-engine/painters/dom/src/images/image-clip-path.test.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-clip-path.test.ts rename to packages/layout-engine/painters/dom/src/images/image-clip-path.test.ts diff --git a/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts b/packages/layout-engine/painters/dom/src/images/image-clip-path.ts similarity index 75% rename from packages/layout-engine/painters/dom/src/utils/image-clip-path.ts rename to packages/layout-engine/painters/dom/src/images/image-clip-path.ts index f8468cfcc3..59949205ee 100644 --- a/packages/layout-engine/painters/dom/src/utils/image-clip-path.ts +++ b/packages/layout-engine/painters/dom/src/images/image-clip-path.ts @@ -1,5 +1,16 @@ import { parseInsetClipPathForScale } from '@superdoc/contracts'; +const SUPPORTED_IMAGE_CLIP_PATH_PREFIXES = ['inset(', 'polygon(', 'circle(', 'ellipse(', 'path(', 'rect(']; + +export const readImageClipPathValue = (value: unknown): string => { + if (typeof value !== 'string') return ''; + const normalized = value.trim(); + if (normalized.length === 0) return ''; + const lower = normalized.toLowerCase(); + if (!SUPPORTED_IMAGE_CLIP_PATH_PREFIXES.some((prefix) => lower.startsWith(prefix))) return ''; + return normalized; +}; + /** * Resolves a clip-path value to a trimmed non-empty string, or undefined if invalid. */ diff --git a/packages/layout-engine/painters/dom/src/images/image-fragment.ts b/packages/layout-engine/painters/dom/src/images/image-fragment.ts new file mode 100644 index 0000000000..015c28fd7a --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/image-fragment.ts @@ -0,0 +1,150 @@ +import type { ImageBlock, ImageFragment, ResolvedImageItem, SdtMetadata } from '@superdoc/contracts'; +import { DOM_CLASS_NAMES } from '../constants.js'; +import type { FragmentRenderContext } from '../renderer.js'; +import { CLASS_NAMES, fragmentStyles } from '../styles.js'; +import { applyStyles } from '../utils/apply-styles.js'; +import { createBlockImageContent } from './image-block.js'; +import type { BuildImageHyperlinkAnchor } from './types.js'; + +type RenderImageFragmentOptions = { + doc: Document | null; + fragment: ImageFragment; + context: FragmentRenderContext; + resolvedItem?: ResolvedImageItem; + applyResolvedFragmentFrame: ( + el: HTMLElement, + item: ResolvedImageItem, + fragment: ImageFragment, + section?: 'body' | 'header' | 'footer', + ) => void; + applyFragmentFrame: (el: HTMLElement, fragment: ImageFragment, section?: 'body' | 'header' | 'footer') => void; + applyFragmentWrapperZIndex: (el: HTMLElement, fragment: ImageFragment) => void; + applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + applyContainerSdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + buildImageHyperlinkAnchor: BuildImageHyperlinkAnchor; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; +}; + +export const buildImageGeometryTransform = (attrs: { + width: number; + height: number; + rotation?: number; + flipH?: boolean; + flipV?: boolean; +}): string => { + const transforms: string[] = []; + if (attrs.rotation != null && attrs.rotation !== 0) { + const angleRad = (attrs.rotation * Math.PI) / 180; + const cosA = Math.cos(angleRad); + const sinA = Math.sin(angleRad); + const newTopLeftX = (attrs.width / 2) * (1 - cosA) + (attrs.height / 2) * sinA; + const newTopLeftY = (attrs.width / 2) * sinA + (attrs.height / 2) * (1 - cosA); + transforms.push(`translate(${-newTopLeftX}px, ${-newTopLeftY}px)`); + transforms.push(`rotate(${attrs.rotation}deg)`); + } + if (attrs.flipH) { + transforms.push('scaleX(-1)'); + } + if (attrs.flipV) { + transforms.push('scaleY(-1)'); + } + return transforms.join(' '); +}; + +export const applyImageGeometryTransform = ( + target: HTMLElement, + attrs: { + width: number; + height: number; + rotation?: number; + flipH?: boolean; + flipV?: boolean; + }, +): void => { + const transform = buildImageGeometryTransform(attrs); + if (!transform) { + return; + } + target.style.transform = transform; + target.style.transformOrigin = 'center'; +}; + +export const renderImageFragment = ({ + doc, + fragment, + context, + resolvedItem, + applyResolvedFragmentFrame, + applyFragmentFrame, + applyFragmentWrapperZIndex, + applySdtDataset, + applyContainerSdtDataset, + buildImageHyperlinkAnchor, + createErrorPlaceholder, +}: RenderImageFragmentOptions): HTMLElement => { + try { + if (resolvedItem?.block?.kind !== 'image') { + throw new Error(`DomPainter: missing resolved image block for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ImageBlock; + + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + const fragmentEl = doc.createElement('div'); + fragmentEl.classList.add(CLASS_NAMES.fragment, DOM_CLASS_NAMES.IMAGE_FRAGMENT); + applyStyles(fragmentEl, fragmentStyles); + if (resolvedItem) { + applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment, context.section); + } else { + applyFragmentFrame(fragmentEl, fragment, context.section); + fragmentEl.style.height = `${fragment.height}px`; + applyFragmentWrapperZIndex(fragmentEl, fragment); + } + applySdtDataset(fragmentEl, block.attrs?.sdt); + applyContainerSdtDataset(fragmentEl, block.attrs?.containerSdt); + + if (block.id) { + fragmentEl.setAttribute('data-sd-block-id', block.id); + } + + const imgPmStart = resolvedItem?.pmStart; + if (imgPmStart != null) { + fragmentEl.dataset.pmStart = String(imgPmStart); + } + const imgPmEnd = resolvedItem?.pmEnd; + if (imgPmEnd != null) { + fragmentEl.dataset.pmEnd = String(imgPmEnd); + } + + const imgMetadata = resolvedItem?.metadata; + if (imgMetadata && !block.attrs?.vmlWatermark) { + fragmentEl.setAttribute('data-image-metadata', JSON.stringify(imgMetadata)); + } + + // AIDEV-NOTE: Keep srcRect crop/zoom transforms on the image element via + // applyImageClipPath, and geometry transforms on the fragment wrapper. + // Putting both on the same element overwrites clip-path scaling. + applyImageGeometryTransform(fragmentEl, { + width: block.width ?? fragment.width, + height: block.height ?? fragment.height, + rotation: block.rotation, + flipH: block.flipH, + flipV: block.flipV, + }); + + const imageChild = createBlockImageContent({ + doc, + block, + clipContainer: fragmentEl, + buildImageHyperlinkAnchor, + }); + fragmentEl.appendChild(imageChild); + + return fragmentEl; + } catch (error) { + console.error('[DomPainter] Image fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; diff --git a/packages/layout-engine/painters/dom/src/utils/image-selectors.test.ts b/packages/layout-engine/painters/dom/src/images/image-selectors.test.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-selectors.test.ts rename to packages/layout-engine/painters/dom/src/images/image-selectors.test.ts diff --git a/packages/layout-engine/painters/dom/src/utils/image-selectors.ts b/packages/layout-engine/painters/dom/src/images/image-selectors.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/utils/image-selectors.ts rename to packages/layout-engine/painters/dom/src/images/image-selectors.ts diff --git a/packages/layout-engine/painters/dom/src/images/types.ts b/packages/layout-engine/painters/dom/src/images/types.ts new file mode 100644 index 0000000000..87ecda51b4 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/images/types.ts @@ -0,0 +1,7 @@ +import type { ImageHyperlink } from '@superdoc/contracts'; + +export type BuildImageHyperlinkAnchor = ( + imageEl: HTMLElement, + hyperlink: ImageHyperlink | undefined, + display: 'block' | 'inline-block', +) => HTMLElement; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 6a9fdcb253..e0406c63ce 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -285,24 +285,6 @@ const withFallbackFragment = ( const fromLine = 'fromLine' in item && typeof item.fromLine === 'number' ? item.fromLine : 0; const toLine = 'toLine' in item && typeof item.toLine === 'number' ? item.toLine : fromLine + 1; - if (item.fragmentKind === 'list-item') { - return { - ...item, - fragment: { - kind: 'list-item', - blockId: item.blockId, - itemId: item.itemId, - markerText: item.markerText ?? '', - markerWidth: item.markerWidth ?? 0, - fromLine, - toLine, - x: item.x, - y: item.y, - width: item.width, - }, - }; - } - return { ...item, fragment: { @@ -5756,224 +5738,6 @@ describe('DomPainter', () => { }); }); - it('renders list fragments with markers', () => { - const listBlock: FlowBlock = { - kind: 'list', - id: 'list-1', - listType: 'number', - items: [ - { - id: 'item-1', - marker: { kind: 'number', text: '1.', level: 0, order: 1 }, - paragraph: block, - }, - ], - }; - - const listMeasure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-1', - markerWidth: 30, - markerTextWidth: 18, - indentLeft: 0, - paragraph: measure as ParagraphMeasure, - }, - ], - totalHeight: measure.totalHeight, - }; - - const listLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-1', - itemId: 'item-1', - fromLine: 0, - toLine: 1, - x: 100, - y: 40, - width: 260, - markerWidth: 30, - }, - ], - }, - ], - }; - - const painter = createTestPainter({ blocks: [listBlock], measures: [listMeasure] }); - painter.paint(listLayout, mount); - - const marker = mount.querySelector('.superdoc-list-marker'); - expect(marker?.textContent).toBe('1.'); - }); - - it('preserves marker-adjusted list-item wrapper geometry during resolved incremental updates', () => { - const listBlock: FlowBlock = { - kind: 'list', - id: 'list-1', - listType: 'number', - items: [ - { - id: 'item-1', - marker: { kind: 'number', text: '1.', level: 0, order: 1 }, - paragraph: block, - }, - ], - }; - - const listMeasure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-1', - markerWidth: 30, - markerTextWidth: 18, - indentLeft: 0, - paragraph: measure as ParagraphMeasure, - }, - ], - totalHeight: measure.totalHeight, - }; - - const initialLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-1', - itemId: 'item-1', - fromLine: 0, - toLine: 1, - x: 100, - y: 40, - width: 260, - markerWidth: 30, - }, - ], - }, - ], - }; - - const updatedLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-1', - itemId: 'item-1', - fromLine: 0, - toLine: 1, - x: 120, - y: 55, - width: 280, - markerWidth: 30, - }, - ], - }, - ], - }; - - const initialResolvedLayout: ResolvedLayout = { - version: 1, - flowMode: 'paginated', - pageGap: 0, - pages: [ - { - id: 'page-0', - index: 0, - number: 1, - width: 400, - height: 500, - items: [ - { - kind: 'fragment', - id: 'list-item:list-1:item-1:0:1', - pageIndex: 0, - x: 100, - y: 40, - width: 260, - height: 20, - fragmentKind: 'list-item', - fragment: initialLayout.pages[0].fragments[0], - blockId: 'list-1', - fragmentIndex: 0, - markerWidth: 30, - block: listBlock as import('@superdoc/contracts').ListBlock, - measure: listMeasure as import('@superdoc/contracts').ListMeasure, - }, - ], - }, - ], - }; - - const updatedResolvedLayout: ResolvedLayout = { - version: 1, - flowMode: 'paginated', - pageGap: 0, - pages: [ - { - id: 'page-0', - index: 0, - number: 1, - width: 400, - height: 500, - items: [ - { - kind: 'fragment', - id: 'list-item:list-1:item-1:0:1', - pageIndex: 0, - x: 120, - y: 55, - width: 280, - height: 20, - fragmentKind: 'list-item', - fragment: updatedLayout.pages[0].fragments[0], - blockId: 'list-1', - fragmentIndex: 0, - markerWidth: 30, - block: listBlock as import('@superdoc/contracts').ListBlock, - measure: listMeasure as import('@superdoc/contracts').ListMeasure, - }, - ], - }, - ], - }; - - const painter = createTestPainter({ blocks: [listBlock], measures: [listMeasure] }); - - painter.setResolvedLayout(initialResolvedLayout); - painter.paint(initialLayout, mount); - - const initialWrapper = mount.querySelector('.superdoc-fragment-list-item') as HTMLElement; - expect(initialWrapper.style.left).toBe('70px'); - expect(initialWrapper.style.top).toBe('40px'); - expect(initialWrapper.style.width).toBe('290px'); - - painter.setResolvedLayout(updatedResolvedLayout); - painter.paint(updatedLayout, mount); - - const updatedWrapper = mount.querySelector('.superdoc-fragment-list-item') as HTMLElement; - const updatedLine = updatedWrapper.querySelector('.superdoc-line') as HTMLElement; - expect(updatedWrapper).not.toBe(initialWrapper); - expect(updatedWrapper.style.left).toBe('90px'); - expect(updatedWrapper.style.top).toBe('55px'); - expect(updatedWrapper.style.width).toBe('310px'); - expect(updatedWrapper.dataset.layoutEpoch).toBeTruthy(); - expect(updatedLine.dataset.layoutEpoch).toBe(updatedWrapper.dataset.layoutEpoch); - }); - it('applies resolved zIndex only to anchored media fragments', () => { const anchoredDrawingBlock: FlowBlock = { kind: 'drawing', @@ -7047,85 +6811,6 @@ describe('DomPainter', () => { expectCssColor(shadingLayer.style.backgroundColor, '#ffeeaa'); }); - it('strips indent padding when rendering list content', () => { - const listBlock: FlowBlock = { - kind: 'list', - id: 'list-indent', - listType: 'number', - items: [ - { - id: 'item-1', - marker: { kind: 'number', text: '1.', level: 1, order: 1 }, - paragraph: { - kind: 'paragraph', - id: 'paragraph-list', - runs: [{ text: 'Indented body', fontFamily: 'Arial', fontSize: 16 }], - attrs: { indent: { left: 36, hanging: 18 } }, - }, - }, - ], - }; - - const paragraphMeasure: ParagraphMeasure = { - kind: 'paragraph', - lines: [ - { - fromRun: 0, - fromChar: 0, - toRun: 0, - toChar: 13, - width: 140, - ascent: 12, - descent: 4, - lineHeight: 18, - }, - ], - totalHeight: 18, - }; - - const listMeasure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-1', - markerWidth: 30, - markerTextWidth: 14, - indentLeft: 36, - paragraph: paragraphMeasure, - }, - ], - totalHeight: 18, - }; - - const listLayout: Layout = { - pageSize: layout.pageSize, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-indent', - itemId: 'item-1', - fromLine: 0, - toLine: 1, - x: 80, - y: 40, - width: 180, - markerWidth: 30, - }, - ], - }, - ], - }; - - const painter = createTestPainter({ blocks: [listBlock], measures: [listMeasure] }); - painter.paint(listLayout, mount); - - const content = mount.querySelector('.superdoc-list-content') as HTMLElement; - expect(content.style.paddingLeft).toBe(''); - }); - describe('line-level paragraph indent handling', () => { it('applies paragraph left/right indent to each line element', () => { const indentBlock: FlowBlock = { diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index 5d91414acb..e649ce5f41 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -40,13 +40,14 @@ export type { PaintSnapshotImageEntity, PaintSnapshotEntities, } from './renderer.js'; -export type { DomPainterInput, PositionMapping, RenderedLineInfo } from './renderer.js'; +export type { DomPainterInput, PositionMapping } from './renderer.js'; +export type { RenderedLineInfo } from './runs/index.js'; // Re-export utility functions for testing -export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './renderer.js'; +export { sanitizeUrl, linkMetrics, applyRunDataAttributes } from './runs/index.js'; export { applySquareWrapExclusionsToLines } from './utils/anchor-helpers'; -export { buildImagePmSelector, buildInlineImagePmSelector } from './utils/image-selectors.js'; +export { buildImagePmSelector, buildInlineImagePmSelector } from './images/image-selectors.js'; // Re-export PM position validation utilities export { diff --git a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts index 55870ed7e9..57883ea6da 100644 --- a/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts +++ b/packages/layout-engine/painters/dom/src/paragraph-hash-utils.ts @@ -1,5 +1,4 @@ import type { - Run, ParagraphBorders, ParagraphBorder, TableBorders, @@ -82,108 +81,13 @@ export const hashCellBorders = (borders: CellBorders | undefined): string => { return parts.join(';'); }; -/** - * Type guard to check if a run has a string property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a string - */ -export const hasStringProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'string'; -}; - -/** - * Type guard to check if a run has a number property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a number - */ -export const hasNumberProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'number'; -}; - -/** - * Type guard to check if a run has a boolean property. - * - * @param run - The run to check - * @param prop - The property name to check - * @returns True if the run has the property and it's a boolean - */ -export const hasBooleanProp = (run: Run, prop: string): run is Run & Record => { - return prop in run && typeof (run as Record)[prop] === 'boolean'; -}; - -/** - * Safely gets a string property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The string value or empty string if not present - */ -export const getRunStringProp = (run: Run, prop: string): string => { - if (hasStringProp(run, prop)) { - return run[prop]; - } - return ''; -}; - -/** - * Safely gets a number property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The number value or 0 if not present - */ -export const getRunNumberProp = (run: Run, prop: string): number => { - if (hasNumberProp(run, prop)) { - return run[prop]; - } - return 0; -}; - -/** - * Safely gets a boolean property from a run, with type narrowing. - * - * @param run - The run to get the property from - * @param prop - The property name - * @returns The boolean value or false if not present - */ -export const getRunBooleanProp = (run: Run, prop: string): boolean => { - if (hasBooleanProp(run, prop)) { - return run[prop]; - } - return false; -}; - -/** - * Safely gets the underline style from a run. - * Handles the object-shaped underline property { style?, color? }. - * - * @param run - The run to get the underline style from - * @returns The underline style or empty string if not present - */ -export const getRunUnderlineStyle = (run: Run): string => { - if ('underline' in run && typeof run.underline === 'boolean') { - return run.underline ? 'single' : ''; - } - if ('underline' in run && run.underline && typeof run.underline === 'object') { - return (run.underline as { style?: string }).style ?? ''; - } - return ''; -}; - -/** - * Safely gets the underline color from a run. - * Handles the object-shaped underline property { style?, color? }. - * - * @param run - The run to get the underline color from - * @returns The underline color or empty string if not present - */ -export const getRunUnderlineColor = (run: Run): string => { - if ('underline' in run && run.underline && typeof run.underline === 'object') { - return (run.underline as { color?: string }).color ?? ''; - } - return ''; -}; +export { + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, + hasBooleanProp, + hasNumberProp, + hasStringProp, +} from './runs/hash.js'; diff --git a/packages/layout-engine/painters/dom/src/paragraph/block-version.ts b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts new file mode 100644 index 0000000000..212210b2a3 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/block-version.ts @@ -0,0 +1,245 @@ +import type { ImageRun, ParagraphAttrs, ParagraphBlock, TextRun, TrackedChangeMeta } from '@superdoc/contracts'; +import { getParagraphInlineDirection } from '@superdoc/contracts'; +import { hashParagraphBorders } from '../paragraph-hash-utils.js'; +import { + getRunBooleanProp, + getRunNumberProp, + getRunStringProp, + getRunUnderlineColor, + getRunUnderlineStyle, +} from '../runs/hash.js'; + +type ParagraphHashFns = { + hashString: (seed: number, value: string) => number; + hashNumber: (seed: number, value: number | undefined | null) => number; +}; + +const hasListMarkerProperties = ( + attrs: unknown, +): attrs is { + numberingProperties: { numId?: number | string; ilvl?: number }; + wordLayout?: { marker?: { markerText?: string } }; +} => { + if (!attrs || typeof attrs !== 'object') return false; + const obj = attrs as Record; + + if (!obj.numberingProperties || typeof obj.numberingProperties !== 'object') return false; + const numProps = obj.numberingProperties as Record; + + if ('numId' in numProps) { + const numId = numProps.numId; + if (typeof numId !== 'number' && typeof numId !== 'string') return false; + } + + if ('ilvl' in numProps) { + const ilvl = numProps.ilvl; + if (typeof ilvl !== 'number') return false; + } + + if ('wordLayout' in obj && obj.wordLayout !== undefined) { + if (typeof obj.wordLayout !== 'object' || obj.wordLayout === null) return false; + const wordLayout = obj.wordLayout as Record; + + if ('marker' in wordLayout && wordLayout.marker !== undefined) { + if (typeof wordLayout.marker !== 'object' || wordLayout.marker === null) return false; + const marker = wordLayout.marker as Record; + + if ('markerText' in marker && marker.markerText !== undefined) { + if (typeof marker.markerText !== 'string') return false; + } + } + } + + return true; +}; + +const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { + if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { + return run.trackedChanges; + } + return run.trackedChange ? [run.trackedChange] : []; +}; + +const trackedChangeVersion = (run: TextRun): string => + getTrackedChangeLayers(run) + .map((trackedChange) => + [ + trackedChange.kind ?? '', + trackedChange.id ?? '', + trackedChange.storyKey ?? '', + trackedChange.overlapParentId ?? '', + trackedChange.relationship ?? '', + trackedChange.author ?? '', + trackedChange.authorEmail ?? '', + trackedChange.authorImage ?? '', + trackedChange.date ?? '', + trackedChange.before ? JSON.stringify(trackedChange.before) : '', + trackedChange.after ? JSON.stringify(trackedChange.after) : '', + ].join(':'), + ) + .join('|'); + +export const deriveParagraphBlockVersion = ( + block: ParagraphBlock, + getSdtMetadataVersion: (metadata: ParagraphAttrs['sdt']) => string, + readClipPathValue: (value: unknown) => string, +): string => { + const markerVersion = hasListMarkerProperties(block.attrs) + ? `marker:${block.attrs.numberingProperties.numId ?? ''}:${block.attrs.numberingProperties.ilvl ?? 0}:${block.attrs.wordLayout?.marker?.markerText ?? ''}` + : ''; + + const runsVersion = block.runs + .map((run) => { + // Paragraph-level cache keys intentionally exclude run pmStart/pmEnd; position-only edits update datasets in place. + if (run.kind === 'image') { + const imgRun = run as ImageRun; + return [ + 'img', + imgRun.src, + imgRun.width, + imgRun.height, + imgRun.alt ?? '', + imgRun.title ?? '', + imgRun.clipPath ?? '', + imgRun.distTop ?? '', + imgRun.distBottom ?? '', + imgRun.distLeft ?? '', + imgRun.distRight ?? '', + readClipPathValue((imgRun as { clipPath?: unknown }).clipPath), + ].join(','); + } + + if (run.kind === 'lineBreak') { + return 'linebreak'; + } + + if (run.kind === 'tab') { + return [run.text ?? '', 'tab'].join(','); + } + + if (run.kind === 'fieldAnnotation') { + const size = run.size ? `${run.size.width ?? ''}x${run.size.height ?? ''}` : ''; + const highlighted = run.highlighted !== false ? 1 : 0; + return [ + 'field', + run.variant ?? '', + run.displayLabel ?? '', + run.fieldColor ?? '', + run.borderColor ?? '', + highlighted, + run.hidden ? 1 : 0, + run.visibility ?? '', + run.imageSrc ?? '', + run.linkUrl ?? '', + run.rawHtml ?? '', + size, + run.fontFamily ?? '', + run.fontSize ?? '', + run.textColor ?? '', + run.textHighlight ?? '', + run.bold ? 1 : 0, + run.italic ? 1 : 0, + run.underline ? 1 : 0, + run.fieldId ?? '', + run.fieldType ?? '', + ].join(','); + } + + const textRun = run as TextRun; + const trackedVersion = trackedChangeVersion(textRun); + return [ + textRun.text ?? '', + textRun.fontFamily, + textRun.fontSize, + textRun.bold ? 1 : 0, + textRun.italic ? 1 : 0, + textRun.color ?? '', + textRun.underline?.style ?? '', + textRun.underline?.color ?? '', + textRun.strike ? 1 : 0, + textRun.highlight ?? '', + textRun.letterSpacing != null ? textRun.letterSpacing : '', + textRun.vertAlign ?? '', + textRun.baselineShift != null ? textRun.baselineShift : '', + textRun.token ?? '', + trackedVersion, + textRun.comments?.length ?? 0, + ].join(','); + }) + .join('|'); + + const attrs = block.attrs as ParagraphAttrs | undefined; + const paragraphAttrsVersion = attrs + ? [ + attrs.alignment ?? '', + attrs.spacing?.before ?? '', + attrs.spacing?.after ?? '', + attrs.spacing?.line ?? '', + attrs.spacing?.lineRule ?? '', + attrs.indent?.left ?? '', + attrs.indent?.right ?? '', + attrs.indent?.firstLine ?? '', + attrs.indent?.hanging ?? '', + attrs.borders ? hashParagraphBorders(attrs.borders) : '', + attrs.shading?.fill ?? '', + attrs.shading?.color ?? '', + getParagraphInlineDirection(attrs) ?? '', + attrs.tabs?.length ? JSON.stringify(attrs.tabs) : '', + ].join(':') + : ''; + + const sdtVersion = getSdtMetadataVersion(attrs?.sdt); + const parts = [markerVersion, runsVersion, paragraphAttrsVersion, sdtVersion].filter(Boolean); + return parts.join('|'); +}; + +export const hashParagraphBlockForTableVersion = ( + seed: number, + paragraphBlock: ParagraphBlock, + hashFns: ParagraphHashFns, +): number => { + const { hashNumber, hashString } = hashFns; + const runs = paragraphBlock.runs ?? []; + let hash = hashNumber(seed, runs.length); + const attrs = paragraphBlock.attrs as ParagraphAttrs | undefined; + + if (attrs) { + hash = hashString(hash, attrs.alignment ?? ''); + hash = hashNumber(hash, attrs.spacing?.before ?? 0); + hash = hashNumber(hash, attrs.spacing?.after ?? 0); + hash = hashNumber(hash, attrs.spacing?.line ?? 0); + hash = hashString(hash, attrs.spacing?.lineRule ?? ''); + hash = hashNumber(hash, attrs.indent?.left ?? 0); + hash = hashNumber(hash, attrs.indent?.right ?? 0); + hash = hashNumber(hash, attrs.indent?.firstLine ?? 0); + hash = hashNumber(hash, attrs.indent?.hanging ?? 0); + hash = hashString(hash, attrs.shading?.fill ?? ''); + hash = hashString(hash, attrs.shading?.color ?? ''); + hash = hashString(hash, getParagraphInlineDirection(attrs) ?? ''); + if (attrs.borders) { + hash = hashString(hash, hashParagraphBorders(attrs.borders)); + } + } + + for (const run of runs) { + if ('text' in run && typeof run.text === 'string') { + hash = hashString(hash, run.text); + } + hash = hashNumber(hash, run.pmStart ?? -1); + hash = hashNumber(hash, run.pmEnd ?? -1); + hash = hashString(hash, getRunStringProp(run, 'color')); + hash = hashString(hash, getRunStringProp(run, 'highlight')); + hash = hashString(hash, getRunBooleanProp(run, 'bold') ? '1' : ''); + hash = hashString(hash, getRunBooleanProp(run, 'italic') ? '1' : ''); + hash = hashNumber(hash, getRunNumberProp(run, 'fontSize')); + hash = hashString(hash, getRunStringProp(run, 'fontFamily')); + hash = hashString(hash, getRunUnderlineStyle(run)); + hash = hashString(hash, getRunUnderlineColor(run)); + hash = hashString(hash, getRunBooleanProp(run, 'strike') ? '1' : ''); + hash = hashString(hash, getRunStringProp(run, 'vertAlign')); + hash = hashNumber(hash, getRunNumberProp(run, 'baselineShift')); + hash = hashString(hash, trackedChangeVersion(run as TextRun)); + } + + return hash; +}; diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/border-layer.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/border-layer.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/border-layer.ts diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts similarity index 90% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts index eaa1327e91..e6186becf0 100644 --- a/packages/layout-engine/painters/dom/src/features/paragraph-borders/group-analysis.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/borders/group-analysis.ts @@ -8,7 +8,7 @@ * @ooxml w:pPr/w:pBdr/w:between — between border for grouped paragraphs * @spec ECMA-376 §17.3.1.24 (pBdr) */ -import type { ListItemFragment, ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts'; +import type { ResolvedPaintItem, ResolvedFragmentItem } from '@superdoc/contracts'; import { hashParagraphBorders } from '../../paragraph-hash-utils.js'; /** @@ -34,8 +34,8 @@ const isBetweenBorderNone = (borders: ResolvedFragmentItem['paragraphBorders']): }; /** - * Helper: check whether a resolved item is a ResolvedFragmentItem (para/list-item) - * with pre-computed paragraph border data. + * Helper: check whether a resolved item is a ResolvedFragmentItem with + * pre-computed paragraph border data. */ function isResolvedFragmentWithBorders( item: ResolvedPaintItem | undefined, @@ -49,7 +49,7 @@ function isResolvedFragmentWithBorders( * Pre-computes per-fragment between-border rendering info for a page. * * Two fragments (i, i+1) form a border group pair when: - * 1. Both are para or list-item (not table/image/drawing) + * 1. Both are para fragments (not table/image/drawing) * 2. Neither is a page-split continuation * 3. They represent different logical paragraphs * 4. Both have border definitions @@ -79,7 +79,7 @@ export const computeBetweenBorderFlags = ( const resolvedCur = resolvedItems[i]; if (resolvedCur.kind !== 'fragment') continue; const frag = resolvedCur.fragment; - if (frag.kind !== 'para' && frag.kind !== 'list-item') continue; + if (frag.kind !== 'para') continue; if (frag.continuesOnNext) continue; if (!isResolvedFragmentWithBorders(resolvedCur)) continue; @@ -88,16 +88,9 @@ export const computeBetweenBorderFlags = ( const resolvedNext = resolvedItems[i + 1]; if (resolvedNext.kind !== 'fragment') continue; const next = resolvedNext.fragment; - if (next.kind !== 'para' && next.kind !== 'list-item') continue; + if (next.kind !== 'para') continue; if (next.continuesFromPrev) continue; - if (next.blockId === frag.blockId && next.kind === 'para') continue; - if ( - next.blockId === frag.blockId && - next.kind === 'list-item' && - frag.kind === 'list-item' && - (next as ListItemFragment).itemId === (frag as ListItemFragment).itemId - ) - continue; + if (next.blockId === frag.blockId) continue; if (!isResolvedFragmentWithBorders(resolvedNext)) continue; const nextBorders = resolvedNext.paragraphBorders; diff --git a/packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts b/packages/layout-engine/painters/dom/src/paragraph/borders/index.ts similarity index 100% rename from packages/layout-engine/painters/dom/src/features/paragraph-borders/index.ts rename to packages/layout-engine/painters/dom/src/paragraph/borders/index.ts diff --git a/packages/layout-engine/painters/dom/src/paragraph/frame.ts b/packages/layout-engine/painters/dom/src/paragraph/frame.ts new file mode 100644 index 0000000000..9e3b940543 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/frame.ts @@ -0,0 +1,43 @@ +import type { ParaFragment, ResolvedFragmentItem } from '@superdoc/contracts'; +import { assertFragmentPmPositions } from '../pm-position-validation.js'; + +export type FragmentFrameSection = 'body' | 'header' | 'footer'; + +export const applyParagraphFragmentPmAttributes = ( + el: HTMLElement, + fragment: ParaFragment, + section?: FragmentFrameSection, + resolvedItem?: ResolvedFragmentItem, +): void => { + if (section === 'body' || section === undefined) { + assertFragmentPmPositions(fragment, 'paragraph fragment'); + } + + const pmStart = resolvedItem ? resolvedItem.pmStart : fragment.pmStart; + if (pmStart != null) { + el.dataset.pmStart = String(pmStart); + } else { + delete el.dataset.pmStart; + } + + const pmEnd = resolvedItem ? resolvedItem.pmEnd : fragment.pmEnd; + if (pmEnd != null) { + el.dataset.pmEnd = String(pmEnd); + } else { + delete el.dataset.pmEnd; + } + + const continuesFromPrev = resolvedItem ? resolvedItem.continuesFromPrev : fragment.continuesFromPrev; + if (continuesFromPrev) { + el.dataset.continuesFromPrev = 'true'; + } else { + delete el.dataset.continuesFromPrev; + } + + const continuesOnNext = resolvedItem ? resolvedItem.continuesOnNext : fragment.continuesOnNext; + if (continuesOnNext) { + el.dataset.continuesOnNext = 'true'; + } else { + delete el.dataset.continuesOnNext; + } +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/indentation.ts b/packages/layout-engine/painters/dom/src/paragraph/indentation.ts new file mode 100644 index 0000000000..72b9368c11 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/indentation.ts @@ -0,0 +1,102 @@ +import type { Line, ParagraphIndent } from '@superdoc/contracts'; +import { adjustAvailableWidthForTextIndent } from '@superdoc/contracts'; + +export type ParagraphLineIndentationParams = { + lineEl: HTMLElement; + line: Line; + indent?: ParagraphIndent; + indentLeftPx: number; + hasListMarkerLayout: boolean; + lineIndex: number; + localStartLine: number; + continuesFromPrev?: boolean; + suppressFirstLineIndent: boolean; + resetContinuationTextIndent?: boolean; +}; + +export const hasExplicitSegmentPositioning = (line: Line): boolean => + line.segments?.some((segment) => segment.x !== undefined) === true; + +export const applyParagraphLineIndentation = (params: ParagraphLineIndentationParams): void => { + const { + lineEl, + line, + indent, + indentLeftPx, + hasListMarkerLayout, + lineIndex, + localStartLine, + continuesFromPrev, + suppressFirstLineIndent, + resetContinuationTextIndent, + } = params; + const paraIndentLeft = indent?.left ?? 0; + const paraIndentRight = indent?.right ?? 0; + const firstLineOffset = suppressFirstLineIndent ? 0 : (indent?.firstLine ?? 0) - (indent?.hanging ?? 0); + const isFirstLine = lineIndex === 0 && localStartLine === 0 && !continuesFromPrev; + const explicitSegmentPositioning = hasExplicitSegmentPositioning(line); + + if (hasListMarkerLayout && indentLeftPx) { + if (!explicitSegmentPositioning) { + lineEl.style.paddingLeft = `${indentLeftPx}px`; + } + } else if (explicitSegmentPositioning) { + if (isFirstLine && firstLineOffset !== 0) { + const effectiveLeftIndent = paraIndentLeft < 0 ? 0 : paraIndentLeft; + const adjustedPadding = effectiveLeftIndent + firstLineOffset; + if (adjustedPadding > 0) { + lineEl.style.paddingLeft = `${adjustedPadding}px`; + } + } + } else if (paraIndentLeft && paraIndentLeft > 0) { + lineEl.style.paddingLeft = `${paraIndentLeft}px`; + } else if (!isFirstLine && indent?.hanging && indent.hanging > 0 && (paraIndentLeft == null || paraIndentLeft >= 0)) { + lineEl.style.paddingLeft = `${indent.hanging}px`; + } + + if (paraIndentRight && paraIndentRight > 0) { + lineEl.style.paddingRight = `${paraIndentRight}px`; + } + if (isFirstLine && firstLineOffset && !explicitSegmentPositioning) { + lineEl.style.textIndent = `${firstLineOffset}px`; + } else if (firstLineOffset && explicitSegmentPositioning) { + lineEl.style.textIndent = '0px'; + } else if (firstLineOffset && !hasListMarkerLayout && resetContinuationTextIndent) { + lineEl.style.textIndent = '0px'; + } +}; + +export const resolveAvailableWidthForLine = (params: { + containerWidth: number; + line: Line; + indentLeftPx: number; + indentRightPx: number; + firstLineOffset: number; + isFirstLine: boolean; + isListFirstLine: boolean; + resolvedListTextStartPx?: number; +}): number => { + const { + containerWidth, + line, + indentLeftPx, + indentRightPx, + firstLineOffset, + isFirstLine, + isListFirstLine, + resolvedListTextStartPx, + } = params; + const positiveIndentReduction = Math.max(0, indentLeftPx) + Math.max(0, indentRightPx); + const fallbackAvailableWidth = Math.max(0, containerWidth - positiveIndentReduction); + let availableWidth = line.maxWidth != null ? Math.min(line.maxWidth, fallbackAvailableWidth) : fallbackAvailableWidth; + + if (resolvedListTextStartPx != null) { + availableWidth = containerWidth - resolvedListTextStartPx - Math.max(0, indentRightPx); + } + + if (isFirstLine && !isListFirstLine && line.hasExplicitTabStops !== true) { + availableWidth = adjustAvailableWidthForTextIndent(availableWidth, firstLineOffset, line.maxWidth); + } + + return availableWidth; +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts new file mode 100644 index 0000000000..221550e8e8 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/list-marker.ts @@ -0,0 +1,303 @@ +import { DOM_CLASS_NAMES } from '@superdoc/dom-contract'; +import { toCssFontFamily } from '@superdoc/font-utils'; +import type { ParagraphMeasure, ResolvedListMarkerItem, SourceAnchor } from '@superdoc/contracts'; +import { + computeTabWidth, + resolveListMarkerGeometry, + resolveListTextStartPx, + type MinimalMarker, + type MinimalWordLayout, + type ResolvedListMarkerGeometry, +} from '@superdoc/common/list-marker-utils'; +import { applySourceAnchorDataset } from '../utils/source-anchor.js'; + +type PainterListTextStartParams = { + wordLayout: MinimalWordLayout | undefined; + indentLeftPx: number; + hangingIndentPx: number; + firstLineIndentPx: number; + markerTextWidthPx?: number; +}; + +const getFiniteNonNegativeNumber = (value: unknown): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + return undefined; + } + return value; +}; + +const resolvePainterMarkerTextWidth = ( + markerTextWidthPx: number | undefined, + marker: { glyphWidthPx?: number; markerBoxWidthPx?: number }, +): number => + getFiniteNonNegativeNumber(markerTextWidthPx) ?? + getFiniteNonNegativeNumber(marker.glyphWidthPx) ?? + getFiniteNonNegativeNumber(marker.markerBoxWidthPx) ?? + 0; + +export const resolvePainterListMarkerGeometry = ({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx, +}: PainterListTextStartParams): ResolvedListMarkerGeometry | undefined => + resolveListMarkerGeometry( + wordLayout, + indentLeftPx, + firstLineIndentPx, + hangingIndentPx, + (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), + ); + +export const resolvePainterListTextStartPx = ({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx, +}: PainterListTextStartParams): number | undefined => + resolveListTextStartPx( + wordLayout, + indentLeftPx, + firstLineIndentPx, + hangingIndentPx, + (_markerText: string, marker: MinimalMarker) => resolvePainterMarkerTextWidth(markerTextWidthPx, marker), + ); + +type MarkerRunStyle = { + fontFamily?: string | null; + fontSize?: number | null; + bold?: boolean | null; + italic?: boolean | null; + color?: string | null; + letterSpacing?: number | null; + vanish?: boolean | null; +}; + +const isMarkerSuffix = (suffix: unknown): suffix is 'tab' | 'space' | 'nothing' => + suffix === 'tab' || suffix === 'space' || suffix === 'nothing'; + +export const createListMarkerElement = ( + doc: Document, + markerText: string, + run: MarkerRunStyle, + sourceAnchor?: SourceAnchor, +): HTMLElement => { + const markerContainer = doc.createElement('span'); + markerContainer.classList.add(DOM_CLASS_NAMES.LIST_MARKER); + markerContainer.style.display = 'inline-block'; + markerContainer.style.wordSpacing = '0px'; + + const markerEl = doc.createElement('span'); + markerEl.classList.add('superdoc-paragraph-marker'); + markerEl.textContent = markerText; + markerEl.style.pointerEvents = 'none'; + markerEl.style.fontFamily = toCssFontFamily(run.fontFamily) ?? run.fontFamily ?? ''; + + if (run.fontSize != null) { + markerEl.style.fontSize = `${run.fontSize}px`; + } + markerEl.style.fontWeight = run.bold ? 'bold' : ''; + markerEl.style.fontStyle = run.italic ? 'italic' : ''; + + if (run.color) { + markerEl.style.color = run.color; + } + if (run.letterSpacing != null) { + markerEl.style.letterSpacing = `${run.letterSpacing}px`; + } + + markerContainer.appendChild(markerEl); + if (sourceAnchor) { + applySourceAnchorDataset(markerEl, sourceAnchor); + } + return markerContainer; +}; + +export const renderLegacyListMarker = (params: { + doc: Document; + lineEl: HTMLElement; + wordLayout?: MinimalWordLayout; + markerLayout: MinimalMarker; + markerMeasure: ParagraphMeasure['marker']; + markerTextWidthPx?: number; + indentLeftPx: number; + hangingIndentPx: number; + firstLineIndentPx: number; + isRtl?: boolean; + sourceAnchor?: SourceAnchor; +}): void => { + const { + doc, + lineEl, + wordLayout, + markerLayout, + markerMeasure, + markerTextWidthPx, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + isRtl, + sourceAnchor, + } = params; + const markerTextWidth = markerTextWidthPx ?? markerMeasure?.markerTextWidth ?? 0; + const shouldUseSharedInlinePrefixGeometry = + markerLayout?.justification === 'left' && + wordLayout?.firstLineIndentMode !== true && + typeof markerTextWidth === 'number' && + Number.isFinite(markerTextWidth) && + markerTextWidth >= 0; + const markerGeometry = shouldUseSharedInlinePrefixGeometry + ? resolvePainterListMarkerGeometry({ + wordLayout, + indentLeftPx, + hangingIndentPx, + firstLineIndentPx, + markerTextWidthPx: markerTextWidth, + }) + : undefined; + + const anchorPoint = indentLeftPx - hangingIndentPx + firstLineIndentPx; + const markerJustification = markerLayout?.justification ?? 'left'; + let markerStartPos: number; + let currentPos: number; + if (markerJustification === 'left') { + markerStartPos = anchorPoint; + currentPos = markerStartPos + markerTextWidth; + } else if (markerJustification === 'right') { + markerStartPos = anchorPoint - markerTextWidth; + currentPos = anchorPoint; + } else { + markerStartPos = anchorPoint - markerTextWidth / 2; + currentPos = markerStartPos + markerTextWidth; + } + + const suffix = markerLayout?.suffix ?? 'tab'; + let suffixWidthPx = 0; + if (markerGeometry && (suffix === 'tab' || suffix === 'space')) { + suffixWidthPx = markerGeometry.suffixWidthPx; + } else if (suffix === 'tab') { + suffixWidthPx = computeTabWidth( + currentPos, + markerJustification, + wordLayout?.tabsPx, + hangingIndentPx, + firstLineIndentPx, + indentLeftPx, + ); + } else if (suffix === 'space') { + suffixWidthPx = 4; + } + + if (isRtl) { + lineEl.style.paddingRight = `${anchorPoint}px`; + } else { + lineEl.style.paddingLeft = `${anchorPoint}px`; + } + + if ((markerLayout?.run as MarkerRunStyle | undefined)?.vanish) { + return; + } + + const markerContainer = createListMarkerElement( + doc, + markerLayout?.markerText ?? '', + markerLayout?.run ?? {}, + sourceAnchor, + ); + markerContainer.style.position = 'relative'; + if (markerJustification === 'right') { + markerContainer.style.position = 'absolute'; + if (isRtl) { + markerContainer.style.right = `${markerStartPos}px`; + } else { + markerContainer.style.left = `${markerStartPos}px`; + } + } else if (markerJustification === 'center') { + markerContainer.style.position = 'absolute'; + if (isRtl) { + markerContainer.style.right = `${markerStartPos - markerTextWidth / 2}px`; + lineEl.style.paddingRight = `${parseFloat(lineEl.style.paddingRight || '0') + markerTextWidth / 2}px`; + } else { + markerContainer.style.left = `${markerStartPos - markerTextWidth / 2}px`; + lineEl.style.paddingLeft = `${parseFloat(lineEl.style.paddingLeft || '0') + markerTextWidth / 2}px`; + } + } + + prependMarkerSuffix(doc, lineEl, isMarkerSuffix(suffix) ? suffix : undefined, suffixWidthPx, markerLayout?.run?.fontSize); + lineEl.prepend(markerContainer); +}; + +export const renderResolvedListMarker = (params: { + doc: Document; + lineEl: HTMLElement; + marker: ResolvedListMarkerItem; + isRtl?: boolean; + sourceAnchor?: SourceAnchor; +}): void => { + const { doc, lineEl, marker, isRtl, sourceAnchor } = params; + if (isRtl) { + lineEl.style.paddingRight = `${marker.firstLinePaddingLeftPx}px`; + } else { + lineEl.style.paddingLeft = `${marker.firstLinePaddingLeftPx}px`; + } + + if (marker.vanish) { + return; + } + + const markerContainer = createListMarkerElement(doc, marker.text, marker.run, marker.sourceAnchor ?? sourceAnchor); + markerContainer.style.position = 'relative'; + if (marker.justification === 'right') { + markerContainer.style.position = 'absolute'; + if (isRtl) { + markerContainer.style.right = `${marker.markerStartPx}px`; + } else { + markerContainer.style.left = `${marker.markerStartPx}px`; + } + } else if (marker.justification === 'center') { + markerContainer.style.position = 'absolute'; + const paddingAdjust = marker.centerPaddingAdjustPx ?? 0; + if (isRtl) { + markerContainer.style.right = `${marker.markerStartPx - paddingAdjust}px`; + lineEl.style.paddingRight = `${parseFloat(lineEl.style.paddingRight || '0') + paddingAdjust}px`; + } else { + markerContainer.style.left = `${marker.markerStartPx - paddingAdjust}px`; + lineEl.style.paddingLeft = `${parseFloat(lineEl.style.paddingLeft || '0') + paddingAdjust}px`; + } + } + + prependMarkerSuffix(doc, lineEl, marker.suffix, marker.suffixWidthPx, marker.run.fontSize); + lineEl.prepend(markerContainer); +}; + +const prependMarkerSuffix = ( + doc: Document, + lineEl: HTMLElement, + suffix: 'tab' | 'space' | 'nothing' | undefined, + suffixWidthPx: number, + fontSize?: number, +): void => { + if (suffix === 'tab') { + const tabEl = doc.createElement('span'); + tabEl.classList.add('superdoc-tab', 'superdoc-marker-suffix-tab'); + tabEl.innerHTML = ' '; + tabEl.style.display = 'inline-block'; + if (fontSize != null) { + tabEl.style.fontSize = `${fontSize}px`; + } + tabEl.style.wordSpacing = '0px'; + tabEl.style.width = `${suffixWidthPx}px`; + lineEl.prepend(tabEl); + } else if (suffix === 'space') { + const spaceEl = doc.createElement('span'); + spaceEl.classList.add('superdoc-marker-suffix-space'); + if (fontSize != null) { + spaceEl.style.fontSize = `${fontSize}px`; + } + spaceEl.style.wordSpacing = '0px'; + spaceEl.textContent = '\u00A0'; + lineEl.prepend(spaceEl); + } +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts new file mode 100644 index 0000000000..92f0d6e1d2 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; +import { renderParagraphContent } from './renderParagraphContent.js'; +import type { Line, ParagraphBlock, ParagraphMeasure, ResolvedParagraphContent } from '@superdoc/contracts'; + +describe('renderParagraphContent', () => { + const line = (index: number): Line => ({ + fromRun: 0, + fromChar: index, + toRun: 0, + toChar: index + 1, + width: 10, + ascent: 12, + descent: 4, + lineHeight: 20, + }); + + it('keeps partial body fragments at their rendered line height', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'split-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0), line(1), line(2)], + totalHeight: 60, + }; + + const result = renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + lineIndexOffset: 0, + linesOverride: measure.lines.slice(0, 1), + continuesOnNext: true, + applySdtDataset: () => {}, + renderLine: () => doc.createElement('div'), + }); + + expect(result.renderedHeight).toBe(20); + expect(result.totalHeight).toBe(20); + expect(frameEl.style.height).toBe('20px'); + }); + + it('marks the final remeasured override line as the paragraph final line', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'remeasured-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0)], + totalHeight: 20, + }; + const renderedLines: Array<{ lineIndex: number; isLastLine: boolean; skipJustify?: boolean }> = []; + + renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 2, + lineIndexOffset: 0, + linesOverride: [line(0), line(1)], + applySdtDataset: () => {}, + renderLine: ({ lineIndex, isLastLine, skipJustify }) => { + renderedLines.push({ lineIndex, isLastLine, skipJustify }); + return doc.createElement('div'); + }, + }); + + expect(renderedLines).toEqual([ + { lineIndex: 0, isLastLine: false, skipJustify: false }, + { lineIndex: 1, isLastLine: true, skipJustify: true }, + ]); + }); + + it('preserves paragraph right indent on list marker lines', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'list-paragraph', + attrs: { + indent: { left: 24, hanging: 12, right: 18 }, + wordLayout: { + marker: { + markerText: '1.', + suffix: 'space', + run: { fontFamily: 'Arial', fontSize: 16 }, + }, + }, + }, + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const measure: ParagraphMeasure = { + kind: 'paragraph', + lines: [line(0)], + marker: { + markerWidth: 10, + markerTextWidth: 8, + }, + totalHeight: 20, + }; + let lineEl: HTMLElement | undefined; + + renderParagraphContent({ + doc, + frameEl, + block, + measure, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + markerWidth: 10, + markerTextWidth: 8, + applySdtDataset: () => {}, + renderLine: () => { + lineEl = doc.createElement('div'); + return lineEl; + }, + }); + + expect(lineEl?.style.cssText).toContain('padding-right: 18px'); + }); + + it('renders resolved RTL list markers on the right side', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'resolved-list-paragraph', + attrs: { directionContext: { inlineDirection: 'rtl', writingMode: 'horizontal-tb' } }, + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const resolvedContent: ResolvedParagraphContent = { + lines: [ + { + line: line(0), + lineIndex: 0, + availableWidth: 160, + skipJustify: true, + paddingLeftPx: 0, + paddingRightPx: 0, + textIndentPx: 0, + isListFirstLine: true, + hasExplicitSegmentPositioning: false, + indentOffset: 30, + }, + ], + marker: { + text: '1.', + justification: 'right', + suffix: 'space', + markerStartPx: 6, + suffixWidthPx: 4, + firstLinePaddingLeftPx: 30, + run: { fontFamily: 'Arial', fontSize: 16 }, + }, + }; + + renderParagraphContent({ + doc, + frameEl, + block, + measure: { kind: 'paragraph', lines: [line(0)], totalHeight: 20 }, + containerKind: 'body-fragment', + width: 200, + localStartLine: 0, + localEndLine: 1, + resolvedContent, + applySdtDataset: () => {}, + renderLine: () => doc.createElement('div'), + }); + + const lineEl = frameEl.lastElementChild as HTMLElement; + const markerEl = lineEl.querySelector('.superdoc-list-marker'); + expect(lineEl.style.paddingRight).toBe('30px'); + expect(markerEl?.style.right).toBe('6px'); + }); + + it('converts the final paragraph mark for resolved content', () => { + const doc = document.implementation.createHTMLDocument('paragraph-content'); + const frameEl = doc.createElement('div'); + const block: ParagraphBlock = { + kind: 'paragraph', + id: 'resolved-cell-paragraph', + runs: [{ text: 'abc', fontFamily: 'Arial', fontSize: 16 }], + }; + const resolvedContent: ResolvedParagraphContent = { + lines: [ + { + line: line(0), + lineIndex: 0, + availableWidth: 160, + skipJustify: true, + paddingLeftPx: 0, + paddingRightPx: 0, + textIndentPx: 0, + isListFirstLine: false, + hasExplicitSegmentPositioning: false, + indentOffset: 0, + }, + ], + }; + + renderParagraphContent({ + doc, + frameEl, + block, + measure: { kind: 'paragraph', lines: [line(0)], totalHeight: 20 }, + containerKind: 'table-cell', + width: 200, + localStartLine: 0, + localEndLine: 1, + resolvedContent, + convertFinalParagraphMark: true, + applySdtDataset: () => {}, + renderLine: () => { + const lineEl = doc.createElement('div'); + const mark = doc.createElement('span'); + mark.classList.add('superdoc-formatting-paragraph-mark'); + mark.textContent = '¶'; + lineEl.appendChild(mark); + return lineEl; + }, + }); + + const mark = frameEl.querySelector('.superdoc-formatting-paragraph-mark'); + expect(mark?.classList.contains('superdoc-formatting-cell-mark')).toBe(true); + expect(mark?.textContent).toBe('¤'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts new file mode 100644 index 0000000000..71ca0b8ba2 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -0,0 +1,543 @@ +import type { + DropCapDescriptor, + Line, + ParagraphBlock, + ParagraphMeasure, + ResolvedParagraphContent, + Run, + SdtMetadata, + SourceAnchor, +} from '@superdoc/contracts'; +import { + effectiveTableCellSpacing, + expandRunsForInlineNewlines, + getParagraphInlineDirection, +} from '@superdoc/contracts'; +import { resolveMarkerIndent, type MinimalWordLayout } from '@superdoc/common/list-marker-utils'; +import { + applySdtContainerChrome, + shouldRenderSdtContainerChrome, + type SdtAncestorOptions, + type SdtBoundaryOptions, +} from '../sdt/container.js'; +import { createParagraphDecorationLayers, stampBetweenBorderDataset, type BetweenBorderInfo } from './borders/index.js'; +import { + applyParagraphLineIndentation, + hasExplicitSegmentPositioning, + resolveAvailableWidthForLine, +} from './indentation.js'; +import { renderLegacyListMarker, renderResolvedListMarker, resolvePainterListTextStartPx } from './list-marker.js'; +import { applyParagraphBlockStyles, clearParagraphFrameIndentStyles } from './styles.js'; + +export type RenderedParagraphLineInfo = { + el: HTMLElement; + top: number; + height: number; +}; + +export type ParagraphRenderLineInput = { + block: ParagraphBlock; + line: Line; + lineIndex: number; + isLastLine: boolean; + availableWidth?: number; + skipJustify?: boolean; + preExpandedRuns?: Run[]; + resolvedListTextStartPx?: number; + indentOffsetOverride?: number; + paragraphMarkLeftOffsetOverride?: number; +}; + +export type ParagraphRenderLine = (input: ParagraphRenderLineInput) => HTMLElement; + +export type ParagraphRenderDropCap = ( + descriptor: DropCapDescriptor, + measure?: { width: number; height: number; lines: number; mode: 'drop' | 'margin' }, +) => HTMLElement; + +export type ParagraphContainerKind = 'body-fragment' | 'table-cell'; + +type ParagraphSpacingPolicy = { + isFirstBlock: boolean; + isLastBlock: boolean; + paddingTop: number; +}; + +export type RenderParagraphContentParams = { + doc: Document; + frameEl: HTMLElement; + block: ParagraphBlock; + measure: ParagraphMeasure; + containerKind: ParagraphContainerKind; + width: number; + localStartLine: number; + localEndLine: number; + linesOverride?: Line[]; + lineIndexOffset?: number; + continuesFromPrev?: boolean; + continuesOnNext?: boolean; + markerWidth?: number; + markerTextWidth?: number; + wordLayout?: MinimalWordLayout; + resolvedContent?: ResolvedParagraphContent; + betweenInfo?: BetweenBorderInfo; + sdtBoundary?: SdtBoundaryOptions; + spacingPolicy?: ParagraphSpacingPolicy; + ancestorContainerKey?: string | null; + ancestorContainerSdt?: SdtMetadata | null; + ancestorContainerKeys?: SdtAncestorOptions['ancestorContainerKeys']; + ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; + onSdtContainerChrome?: () => void; + applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + applyContainerSdtDataset?: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + renderLine: ParagraphRenderLine; + renderDropCap?: ParagraphRenderDropCap; + captureLineSnapshot?: ( + lineEl: HTMLElement, + options?: { inTableParagraph?: boolean; wrapperEl?: HTMLElement; sourceAnchor?: SourceAnchor }, + ) => void; + convertFinalParagraphMark?: boolean; + lineTopOffset?: number; + sourceAnchor?: SourceAnchor; +}; + +export type RenderParagraphContentResult = { + renderedHeight: number; + totalHeight: number; + renderedLines: RenderedParagraphLineInfo[]; +}; + +export const renderParagraphContent = (params: RenderParagraphContentParams): RenderParagraphContentResult => { + const { + doc, + frameEl, + block, + measure, + linesOverride, + width, + localStartLine, + localEndLine, + lineIndexOffset = 0, + continuesFromPrev, + continuesOnNext, + resolvedContent, + betweenInfo, + sdtBoundary, + spacingPolicy, + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + onSdtContainerChrome, + applySdtDataset, + applyContainerSdtDataset, + renderDropCap, + lineTopOffset = 0, + } = params; + + applyParagraphBlockStyles(frameEl, block.attrs); + const { shadingLayer, borderLayer } = createParagraphDecorationLayers(doc, width, block.attrs, betweenInfo); + if (shadingLayer) frameEl.appendChild(shadingLayer); + if (borderLayer) frameEl.appendChild(borderLayer); + stampBetweenBorderDataset(frameEl, betweenInfo); + + if (block.attrs?.styleId) { + frameEl.dataset.styleId = block.attrs.styleId; + frameEl.setAttribute('styleid', block.attrs.styleId); + } + applySdtDataset(frameEl, block.attrs?.sdt); + applyContainerSdtDataset?.(frameEl, block.attrs?.containerSdt); + + const applySdtChrome = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt, { + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + }); + if (applySdtChrome) { + if (applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary)) { + onSdtContainerChrome?.(); + } + } + + renderParagraphDropCap({ + frameEl, + block, + measure, + resolvedContent, + continuesFromPrev, + renderDropCap, + }); + + clearParagraphFrameIndentStyles(frameEl); + + const spacingBefore = block.attrs?.spacing?.before; + let beforeHeight = 0; + if (spacingPolicy && localStartLine === 0) { + beforeHeight = effectiveTableCellSpacing(spacingBefore, spacingPolicy.isFirstBlock, spacingPolicy.paddingTop); + if (beforeHeight > 0) { + frameEl.style.marginTop = `${beforeHeight}px`; + } + } + + const renderResult = + resolvedContent != null + ? renderResolvedLines({ + ...params, + resolvedContent, + lineTopOffset: lineTopOffset + beforeHeight, + }) + : renderMeasuredLines({ + ...params, + lineTopOffset: lineTopOffset + beforeHeight, + }); + + let renderedHeight = renderResult.renderedHeight; + const originalLineCount = measure.lines?.length ?? linesOverride?.length ?? 0; + const renderedStartLine = lineIndexOffset + localStartLine; + const renderedEndLine = lineIndexOffset + localEndLine; + const renderedEntireBlock = + !continuesFromPrev && !continuesOnNext && renderedStartLine === 0 && renderedEndLine >= originalLineCount; + if (renderedEntireBlock && measure.totalHeight && measure.totalHeight > renderedHeight) { + renderedHeight = measure.totalHeight; + } + + let afterHeight = 0; + if (spacingPolicy && renderedEntireBlock && !spacingPolicy.isLastBlock) { + const spacingAfter = block.attrs?.spacing?.after; + if (typeof spacingAfter === 'number' && spacingAfter > 0) { + frameEl.style.marginBottom = `${spacingAfter}px`; + afterHeight = spacingAfter; + } + } + + if (renderedHeight > 0) { + frameEl.style.height = `${renderedHeight}px`; + } + + return { + renderedHeight, + totalHeight: beforeHeight + renderedHeight + afterHeight, + renderedLines: renderResult.renderedLines, + }; +}; + +const renderResolvedLines = ( + params: RenderParagraphContentParams & { resolvedContent: ResolvedParagraphContent }, +): { renderedHeight: number; renderedLines: RenderedParagraphLineInfo[] } => { + const { + frameEl, + block, + resolvedContent: content, + markerTextWidth, + renderLine, + captureLineSnapshot, + convertFinalParagraphMark, + lineTopOffset = 0, + sourceAnchor, + } = params; + const renderedLines: RenderedParagraphLineInfo[] = []; + const resolvedMarker = content.marker; + const expandedRunsForBlock = expandRunsForInlineNewlines(block.runs); + const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; + let renderedHeight = 0; + + content.lines.forEach((resolvedLine, index) => { + const paragraphMarkLeftOffset = resolveResolvedListParagraphMarkOffset( + resolvedLine.isListFirstLine ? resolvedMarker : undefined, + markerTextWidth, + resolvedLine.indentOffset, + ); + const lineEl = renderLine({ + block, + line: resolvedLine.line, + lineIndex: resolvedLine.lineIndex, + isLastLine: index === content.lines.length - 1 && !content.continuesOnNext, + availableWidth: resolvedLine.availableWidth, + skipJustify: resolvedLine.skipJustify, + preExpandedRuns: expandedRunsForBlock, + resolvedListTextStartPx: resolvedLine.resolvedListTextStartPx, + indentOffsetOverride: resolvedLine.indentOffset, + paragraphMarkLeftOffsetOverride: paragraphMarkLeftOffset, + }); + + if (!resolvedLine.isListFirstLine) { + applyResolvedLineIndentation(lineEl, block, content, resolvedLine); + } + if (resolvedLine.paddingRightPx > 0) { + lineEl.style.paddingRight = `${resolvedLine.paddingRightPx}px`; + } + if (resolvedLine.isListFirstLine && resolvedMarker) { + renderResolvedListMarker({ doc: params.doc, lineEl, marker: resolvedMarker, isRtl, sourceAnchor }); + } + if (convertFinalParagraphMark && index === content.lines.length - 1 && !content.continuesOnNext) { + convertParagraphMarkToCellMark(lineEl); + } + captureLineSnapshot?.(lineEl, { + inTableParagraph: params.containerKind === 'table-cell', + wrapperEl: frameEl, + sourceAnchor, + }); + frameEl.appendChild(lineEl); + const height = resolvedLine.line.lineHeight; + renderedLines.push({ el: lineEl, top: lineTopOffset + renderedHeight, height }); + renderedHeight += height; + }); + + return { renderedHeight, renderedLines }; +}; + +const renderMeasuredLines = ( + params: RenderParagraphContentParams, +): { renderedHeight: number; renderedLines: RenderedParagraphLineInfo[] } => { + const { + doc, + frameEl, + block, + measure, + containerKind, + width, + localStartLine, + localEndLine, + linesOverride, + lineIndexOffset = 0, + continuesFromPrev, + continuesOnNext, + markerWidth, + markerTextWidth, + wordLayout, + renderLine, + captureLineSnapshot, + convertFinalParagraphMark, + lineTopOffset = 0, + sourceAnchor, + } = params; + const lines = linesOverride ?? measure.lines ?? []; + const paraIndent = block.attrs?.indent; + const paraIndentLeft = paraIndent?.left ?? 0; + const paraIndentRight = paraIndent?.right ?? 0; + const isRtl = getParagraphInlineDirection(block.attrs) === 'rtl'; + const { + anchorIndentPx: paraMarkerAnchorIndent, + firstLinePx: markerFirstLine, + hangingPx: markerHanging, + } = resolveMarkerIndent(paraIndent, isRtl); + const wordLayoutIndentLeft = (wordLayout as { indentLeftPx?: number } | undefined)?.indentLeftPx; + const tableMarkerIndentLeft = + measure.marker?.indentLeft ?? + wordLayoutIndentLeft ?? + (typeof paraIndent?.left === 'number' ? paraIndent.left : 0); + const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); + const expandedRunsForBlock = containerKind === 'body-fragment' ? expandRunsForInlineNewlines(block.runs) : undefined; + const lastRun = block.runs.length > 0 ? block.runs[block.runs.length - 1] : null; + const paragraphEndsWithLineBreak = lastRun?.kind === 'lineBreak'; + const markerLayout = wordLayout?.marker; + const markerMeasure = measure.marker; + + const legacyMarkerWidth = markerWidth ?? markerMeasure?.markerWidth; + const legacyMarkerTextWidth = markerTextWidth ?? markerMeasure?.markerTextWidth; + const listFirstLineTextStartPx = + !continuesFromPrev && legacyMarkerWidth && markerLayout && markerMeasure + ? resolvePainterListTextStartPx({ + wordLayout, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hangingIndentPx: markerHanging, + firstLineIndentPx: markerFirstLine, + markerTextWidthPx: legacyMarkerTextWidth, + }) + : undefined; + + let renderedHeight = 0; + const renderedLines: RenderedParagraphLineInfo[] = []; + const renderedLocalEndLine = Math.min(localEndLine, lines.length); + + for (let lineIdx = localStartLine; lineIdx < localEndLine && lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]; + const explicitSegmentPositioning = hasExplicitSegmentPositioning(line); + const isFirstLine = lineIdx === 0 && !continuesFromPrev; + const isListFirstLine = Boolean(lineIdx === 0 && !continuesFromPrev && legacyMarkerWidth && markerLayout); + const shouldUseResolvedListTextStart = + isListFirstLine && explicitSegmentPositioning && listFirstLineTextStartPx != null; + const globalLineIndex = lineIndexOffset + lineIdx; + const isLastLineOfParagraph = + (linesOverride + ? lineIdx === renderedLocalEndLine - 1 + : globalLineIndex === (measure.lines?.length ?? lines.length) - 1) && !continuesOnNext; + const shouldSkipJustifyForLastLine = isLastLineOfParagraph && !paragraphEndsWithLineBreak; + const availableWidth = + containerKind === 'body-fragment' + ? resolveAvailableWidthForLine({ + containerWidth: width, + line, + indentLeftPx: paraIndentLeft, + indentRightPx: paraIndentRight, + firstLineOffset, + isFirstLine, + isListFirstLine, + resolvedListTextStartPx: shouldUseResolvedListTextStart ? listFirstLineTextStartPx : undefined, + }) + : undefined; + const lineEl = renderLine({ + block, + line, + lineIndex: globalLineIndex, + isLastLine: isLastLineOfParagraph, + availableWidth, + skipJustify: shouldSkipJustifyForLastLine, + preExpandedRuns: expandedRunsForBlock, + resolvedListTextStartPx: shouldUseResolvedListTextStart ? listFirstLineTextStartPx : undefined, + }); + lineEl.style.paddingLeft = ''; + lineEl.style.paddingRight = ''; + lineEl.style.textIndent = ''; + + if (convertFinalParagraphMark && isLastLineOfParagraph) { + convertParagraphMarkToCellMark(lineEl); + } + + if (isListFirstLine && markerLayout && markerMeasure) { + if (paraIndentRight > 0) { + lineEl.style.paddingRight = `${paraIndentRight}px`; + } + renderLegacyListMarker({ + doc, + lineEl, + wordLayout, + markerLayout, + markerMeasure, + markerTextWidthPx: legacyMarkerTextWidth, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hangingIndentPx: markerHanging, + firstLineIndentPx: markerFirstLine, + isRtl, + sourceAnchor, + }); + } else { + applyParagraphLineIndentation({ + lineEl, + line, + indent: paraIndent, + indentLeftPx: containerKind === 'table-cell' ? tableMarkerIndentLeft : paraMarkerAnchorIndent, + hasListMarkerLayout: Boolean(markerLayout), + lineIndex: lineIdx, + localStartLine, + continuesFromPrev, + suppressFirstLineIndent, + resetContinuationTextIndent: containerKind === 'body-fragment', + }); + } + + captureLineSnapshot?.(lineEl, { + inTableParagraph: containerKind === 'table-cell', + wrapperEl: frameEl, + sourceAnchor, + }); + frameEl.appendChild(lineEl); + const height = line.lineHeight; + renderedLines.push({ el: lineEl, top: lineTopOffset + renderedHeight, height }); + renderedHeight += height; + } + + return { renderedHeight, renderedLines }; +}; + +const renderParagraphDropCap = (params: { + frameEl: HTMLElement; + block: ParagraphBlock; + measure: ParagraphMeasure; + resolvedContent?: ResolvedParagraphContent; + continuesFromPrev?: boolean; + renderDropCap?: ParagraphRenderDropCap; +}): void => { + const { frameEl, block, measure, resolvedContent, continuesFromPrev, renderDropCap } = params; + if (!renderDropCap) return; + if (resolvedContent?.dropCap) { + const dc = resolvedContent.dropCap; + const dropCapEl = renderDropCap( + { + mode: dc.mode, + run: { + text: dc.text, + fontFamily: dc.fontFamily, + fontSize: dc.fontSize, + bold: dc.bold, + italic: dc.italic, + color: dc.color, + position: dc.position, + }, + lines: 0, + }, + dc.width != null && dc.height != null + ? { width: dc.width, height: dc.height, lines: 0, mode: dc.mode } + : undefined, + ); + frameEl.appendChild(dropCapEl); + return; + } + const dropCapDescriptor = block.attrs?.dropCapDescriptor; + const dropCapMeasure = measure.dropCap; + if (dropCapDescriptor && dropCapMeasure && !continuesFromPrev) { + frameEl.appendChild(renderDropCap(dropCapDescriptor, dropCapMeasure)); + } +}; + +const applyResolvedLineIndentation = ( + lineEl: HTMLElement, + block: ParagraphBlock, + content: ResolvedParagraphContent, + resolvedLine: ResolvedParagraphContent['lines'][number], +): void => { + if (resolvedLine.paddingLeftPx > 0) { + lineEl.style.paddingLeft = `${resolvedLine.paddingLeftPx}px`; + } + if (resolvedLine.textIndentPx !== 0) { + lineEl.style.textIndent = `${resolvedLine.textIndentPx}px`; + } else if (resolvedLine.lineIndex > 0 || content.continuesFromPrev) { + const paraIndent = block.attrs?.indent; + const suppressFirstLineIndent = block.attrs?.suppressFirstLineIndent === true; + const firstLineOffset = suppressFirstLineIndent ? 0 : (paraIndent?.firstLine ?? 0) - (paraIndent?.hanging ?? 0); + if (firstLineOffset && !resolvedLine.isListFirstLine) { + lineEl.style.textIndent = '0px'; + } + } +}; + +const resolveResolvedListParagraphMarkOffset = ( + marker: ResolvedParagraphContent['marker'] | undefined, + markerTextWidth: number | undefined, + indentOffset: number, +): number | undefined => { + if (!marker) return undefined; + if (typeof indentOffset === 'number' && Number.isFinite(indentOffset) && indentOffset > 0) { + return indentOffset; + } + if (marker.vanish) { + return indentOffset; + } + + const paddingLeft = Number.isFinite(marker.firstLinePaddingLeftPx) ? marker.firstLinePaddingLeftPx : 0; + const suffixWidth = marker.suffix !== 'nothing' && Number.isFinite(marker.suffixWidthPx) ? marker.suffixWidthPx : 0; + + if (marker.justification === 'left') { + const markerWidth = + typeof markerTextWidth === 'number' && Number.isFinite(markerTextWidth) && markerTextWidth > 0 + ? markerTextWidth + : 0; + return paddingLeft + markerWidth + suffixWidth; + } + + const centerPadding = + marker.justification === 'center' && Number.isFinite(marker.centerPaddingAdjustPx) + ? (marker.centerPaddingAdjustPx ?? 0) + : 0; + return paddingLeft + centerPadding + suffixWidth; +}; + +const convertParagraphMarkToCellMark = (lineEl: HTMLElement): void => { + const mark = lineEl.querySelector('.superdoc-formatting-paragraph-mark'); + if (!mark) return; + + mark.classList.add('superdoc-formatting-cell-mark'); + mark.textContent = '¤'; +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts new file mode 100644 index 0000000000..3b89dcce38 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -0,0 +1,188 @@ +import type { + DropCapDescriptor, + ParaFragment, + ParagraphBlock, + ParagraphMeasure, + ResolvedFragmentItem, + SdtMetadata, +} from '@superdoc/contracts'; +import { isMinimalWordLayout as isMinimalWordLayoutShared } from '@superdoc/common/list-marker-utils'; +import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; +import { CLASS_NAMES, fragmentStyles } from '../styles.js'; +import { shouldRenderSdtContainerChrome, type SdtBoundaryOptions } from '../sdt/container.js'; +import type { BetweenBorderInfo } from './borders/index.js'; +import { renderParagraphContent, type ParagraphRenderLineInput } from './renderParagraphContent.js'; + +type ApplyStyles = (el: HTMLElement, styles: Partial) => void; + +type RenderParagraphFragmentParams = { + doc: Document | null; + fragment: ParaFragment; + sdtBoundary?: SdtBoundaryOptions; + betweenInfo?: BetweenBorderInfo; + resolvedItem?: ResolvedFragmentItem; + applyStyles: ApplyStyles; + applyResolvedFragmentFrame: (el: HTMLElement, item: ResolvedFragmentItem, fragment: ParaFragment) => void; + applyFragmentFrame: (el: HTMLElement, fragment: ParaFragment) => void; + applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + applyContainerSdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + renderLine: (input: ParagraphRenderLineInput) => HTMLElement; + captureLineSnapshot: ( + lineEl: HTMLElement, + options?: { sourceAnchor?: ResolvedFragmentItem['sourceAnchor']; wrapperEl?: HTMLElement }, + ) => void; + createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; +}; + +const isMinimalWordLayout = (value: unknown): value is MinimalWordLayout => isMinimalWordLayoutShared(value); + +export const renderParagraphFragment = (params: RenderParagraphFragmentParams): HTMLElement => { + const { + doc, + fragment, + sdtBoundary, + betweenInfo, + resolvedItem, + applyStyles, + applyResolvedFragmentFrame, + applyFragmentFrame, + applySdtDataset, + applyContainerSdtDataset, + renderLine, + captureLineSnapshot, + createErrorPlaceholder, + } = params; + + try { + if (!doc) { + throw new Error('DomPainter: document is not available'); + } + + if (resolvedItem?.block?.kind !== 'paragraph' || resolvedItem?.measure?.kind !== 'paragraph') { + throw new Error(`DomPainter: missing resolved paragraph block/measure for fragment ${fragment.blockId}`); + } + const block = resolvedItem.block as ParagraphBlock; + const measure = resolvedItem.measure as ParagraphMeasure; + const wordLayout = isMinimalWordLayout(block.attrs?.wordLayout) ? block.attrs.wordLayout : undefined; + const content = resolvedItem?.content; + + const paraContinuesFromPrev = resolvedItem?.continuesFromPrev; + const paraContinuesOnNext = resolvedItem?.continuesOnNext; + const paraMarkerWidth = resolvedItem?.markerWidth; + + const fragmentEl = doc.createElement('div'); + fragmentEl.classList.add(CLASS_NAMES.fragment); + + const isTocEntry = block.attrs?.isTocEntry; + const hasMarker = !paraContinuesFromPrev && paraMarkerWidth && wordLayout?.marker; + const hasSdtContainer = shouldRenderSdtContainerChrome(block.attrs?.sdt, block.attrs?.containerSdt); + const paraIndentForOverflow = block.attrs?.indent; + const hasNegativeIndent = (paraIndentForOverflow?.left ?? 0) < 0 || (paraIndentForOverflow?.right ?? 0) < 0; + const styles = isTocEntry + ? { ...fragmentStyles, whiteSpace: 'nowrap' } + : hasMarker || hasSdtContainer || hasNegativeIndent + ? { ...fragmentStyles, overflow: 'visible' } + : fragmentStyles; + applyStyles(fragmentEl, styles); + if (resolvedItem) { + applyResolvedFragmentFrame(fragmentEl, resolvedItem, fragment); + } else { + applyFragmentFrame(fragmentEl, fragment); + } + + if (isTocEntry) { + fragmentEl.classList.add('superdoc-toc-entry'); + } + + if (paraContinuesFromPrev) { + fragmentEl.dataset.continuesFromPrev = 'true'; + } + if (paraContinuesOnNext) { + fragmentEl.dataset.continuesOnNext = 'true'; + } + + const lines = fragment.lines ?? measure.lines.slice(fragment.fromLine, fragment.toLine); + renderParagraphContent({ + doc, + frameEl: fragmentEl, + block, + measure, + containerKind: 'body-fragment', + width: fragment.width, + localStartLine: 0, + localEndLine: lines.length, + lineIndexOffset: fragment.fromLine, + linesOverride: lines, + continuesFromPrev: paraContinuesFromPrev, + continuesOnNext: paraContinuesOnNext, + markerWidth: paraMarkerWidth, + markerTextWidth: fragment.markerTextWidth, + wordLayout, + resolvedContent: content, + betweenInfo, + sdtBoundary, + applySdtDataset, + applyContainerSdtDataset, + renderDropCap: (descriptor, dropCapMeasure) => renderDropCap(doc, descriptor, dropCapMeasure), + renderLine, + captureLineSnapshot: (lineEl, options) => { + captureLineSnapshot(lineEl, { + sourceAnchor: options?.sourceAnchor, + wrapperEl: fragmentEl, + }); + }, + sourceAnchor: resolvedItem?.sourceAnchor, + }); + + return fragmentEl; + } catch (error) { + console.error('[DomPainter] Fragment rendering failed:', { fragment, error }); + return createErrorPlaceholder(fragment.blockId, error); + } +}; + +const renderDropCap = ( + doc: Document, + descriptor: DropCapDescriptor, + measure: ParagraphMeasure['dropCap'], +): HTMLElement => { + const { run, mode } = descriptor; + + const dropCapEl = doc.createElement('span'); + dropCapEl.classList.add('superdoc-drop-cap'); + dropCapEl.textContent = run.text; + + dropCapEl.style.fontFamily = run.fontFamily; + dropCapEl.style.fontSize = `${run.fontSize}px`; + if (run.bold) { + dropCapEl.style.fontWeight = 'bold'; + } + if (run.italic) { + dropCapEl.style.fontStyle = 'italic'; + } + if (run.color) { + dropCapEl.style.color = run.color; + } + + if (mode === 'drop') { + dropCapEl.style.float = 'left'; + dropCapEl.style.marginRight = '4px'; + dropCapEl.style.lineHeight = '1'; + } else if (mode === 'margin') { + dropCapEl.style.position = 'absolute'; + dropCapEl.style.left = '0'; + dropCapEl.style.lineHeight = '1'; + } + + if (run.position && run.position !== 0) { + dropCapEl.style.position = dropCapEl.style.position || 'relative'; + dropCapEl.style.top = `${run.position}px`; + } + + if (measure) { + dropCapEl.style.width = `${measure.width}px`; + dropCapEl.style.height = `${measure.height}px`; + } + + return dropCapEl; +}; diff --git a/packages/layout-engine/painters/dom/src/paragraph/styles.ts b/packages/layout-engine/painters/dom/src/paragraph/styles.ts new file mode 100644 index 0000000000..bd8e041554 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/paragraph/styles.ts @@ -0,0 +1,37 @@ +import type { ParagraphAttrs } from '@superdoc/contracts'; +import { applyRtlStyles } from '../features/inline-direction/index.js'; + +export const applyParagraphBlockStyles = (element: HTMLElement, attrs?: ParagraphAttrs): void => { + if (!attrs) return; + if (attrs.styleId) { + element.setAttribute('styleid', attrs.styleId); + } + applyRtlStyles(element, attrs); + if ((attrs as Record).dropCap) { + element.classList.add('sd-editor-dropcap'); + } + const indent = attrs.indent; + if (indent) { + if (indent.left && indent.left > 0) { + element.style.paddingLeft = `${indent.left}px`; + } + if (indent.right && indent.right > 0) { + element.style.paddingRight = `${indent.right}px`; + } + const hasNegativeLeftIndent = indent.left != null && indent.left < 0; + if (!hasNegativeLeftIndent) { + const textIndent = (indent.firstLine ?? 0) - (indent.hanging ?? 0); + if (textIndent) { + element.style.textIndent = `${textIndent}px`; + } + } + } +}; + +export const clearParagraphFrameIndentStyles = (element: HTMLElement): void => { + if (element.style.paddingLeft) element.style.removeProperty('padding-left'); + if (element.style.paddingRight) element.style.removeProperty('padding-right'); + if (element.style.marginLeft) element.style.removeProperty('margin-left'); + if (element.style.marginRight) element.style.removeProperty('margin-right'); + if (element.style.textIndent) element.style.removeProperty('text-indent'); +}; diff --git a/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts b/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts index ea641ee9d4..eeea052fc7 100644 --- a/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-dispatch.test.ts @@ -50,64 +50,6 @@ function paragraphFixtures() { return { blocks: [block], measures: [measure], layout }; } -function listItemFixtures() { - const block: FlowBlock = { - kind: 'list', - id: 'list-dispatch', - listType: 'bullet', - items: [ - { - id: 'item-dispatch', - marker: { kind: 'bullet', text: '•', level: 0 }, - paragraph: { - kind: 'paragraph', - id: 'list-para-dispatch', - runs: [{ text: 'Item', fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 5 }], - }, - }, - ], - }; - const measure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-dispatch', - markerWidth: 20, - markerTextWidth: 10, - indentLeft: 36, - paragraph: { - kind: 'paragraph', - lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 4, width: 40, ascent: 10, descent: 4, lineHeight: 16 }], - totalHeight: 16, - }, - }, - ], - totalHeight: 16, - }; - const layout: Layout = { - pageSize: { w: 400, h: 500 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-dispatch', - itemId: 'item-dispatch', - fromLine: 0, - toLine: 1, - x: 50, - y: 0, - width: 250, - markerWidth: 20, - }, - ], - }, - ], - }; - return { blocks: [block], measures: [measure], layout }; -} - function imageFixtures() { const block: FlowBlock = { kind: 'image', @@ -337,16 +279,6 @@ describe('renderFragment dispatch', () => { expect(spy.mock.calls[0]![0].kind).toBe('para'); }); - it('routes list-item fragment to renderListItemFragment', () => { - const dummyDiv = document.createElement('div'); - const spy = vi.spyOn(DomPainter.prototype as any, 'renderListItemFragment').mockReturnValue(dummyDiv); - const { blocks, measures, layout } = listItemFixtures(); - const painter = createDomPainter({ blocks, measures }); - painter.paint(layout, container); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0]![0].kind).toBe('list-item'); - }); - it('routes image fragment to renderImageFragment', () => { const dummyDiv = document.createElement('div'); const spy = vi.spyOn(DomPainter.prototype as any, 'renderImageFragment').mockReturnValue(dummyDiv); diff --git a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts index eb79122ec9..e668ed19eb 100644 --- a/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts +++ b/packages/layout-engine/painters/dom/src/renderer-hanging-indent.test.ts @@ -692,7 +692,7 @@ describe('DomPainter hanging indent with tabs', () => { // paddingLeft should be: left (100) + firstLine (500) = 600px expect(lineEl.style.paddingLeft).toBe('600px'); - // Tab element should exist and have width equal to LIST_MARKER_GAP (8px) + // Tab element should preserve the computed marker-to-text gap. const tabEl = lineEl.querySelector('.superdoc-tab') as HTMLElement; expect(tabEl).toBeTruthy(); expect(tabEl.style.width).toBe('10px'); diff --git a/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts b/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts deleted file mode 100644 index 92706dc9c5..0000000000 --- a/packages/layout-engine/painters/dom/src/renderer-known-divergences.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * Known divergence lock tests. - * - * Rule: This file contains ONLY intentional mismatches between rendering - * paths. Every test has a Resolution target comment. When a future PR - * resolves the divergence, it deletes the corresponding test(s). - * - * If a new divergence is discovered during PR 0 implementation, it is added - * here with a resolution target — not fixed in production code. - */ - -import { describe, it, expect } from 'vitest'; -import { createTestPainter as createDomPainter } from './_test-utils.js'; -import type { FlowBlock, Measure, Layout, Line } from '@superdoc/contracts'; -import { normalizeLines } from './test-utils/normalize-line.js'; - -// --------------------------------------------------------------------------- -// Shared constants and helpers -// --------------------------------------------------------------------------- - -const JUSTIFY_TEXT = 'The quick brown fox jumps over'; -const JUSTIFY_TEXT_LEN = JUSTIFY_TEXT.length; -const LINE_WIDTH = 150; -const FRAGMENT_WIDTH = 300; - -function makeLine(fromChar: number, toChar: number, overrides?: Partial): Line { - return { - fromRun: 0, - fromChar, - toRun: 0, - toChar, - width: LINE_WIDTH, - maxWidth: FRAGMENT_WIDTH, - ascent: 10, - descent: 4, - lineHeight: 16, - ...overrides, - }; -} - -// --------------------------------------------------------------------------- -// List-item fixtures -// --------------------------------------------------------------------------- - -function listItemFixtures(opts: { alignment?: string; lines?: Line[] }) { - const halfLen = Math.floor(JUSTIFY_TEXT_LEN / 2); - const lines = opts.lines ?? [makeLine(0, halfLen), makeLine(halfLen, JUSTIFY_TEXT_LEN)]; - - const block: FlowBlock = { - kind: 'list', - id: 'list-div', - listType: 'number', - items: [ - { - id: 'item-div', - marker: { kind: 'number', text: '1.', level: 0 }, - paragraph: { - kind: 'paragraph', - id: 'list-para-div', - runs: [{ text: JUSTIFY_TEXT, fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 1 + JUSTIFY_TEXT_LEN }], - ...(opts.alignment ? { attrs: { alignment: opts.alignment } } : {}), - }, - }, - ], - }; - - const measure: Measure = { - kind: 'list', - items: [ - { - itemId: 'item-div', - markerWidth: 24, - markerTextWidth: 12, - indentLeft: 36, - paragraph: { - kind: 'paragraph', - lines, - totalHeight: lines.reduce((h, l) => h + l.lineHeight, 0), - }, - }, - ], - totalHeight: lines.reduce((h, l) => h + l.lineHeight, 0), - }; - - const layout: Layout = { - pageSize: { w: 600, h: 800 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'list-item', - blockId: 'list-div', - itemId: 'item-div', - fromLine: 0, - toLine: lines.length, - x: 60, - y: 40, - width: FRAGMENT_WIDTH, - markerWidth: 24, - }, - ], - }, - ], - }; - - return { blocks: [block], measures: [measure], layout }; -} - -// --------------------------------------------------------------------------- -// Body/table-cell fixtures (for three-way comparison) -// --------------------------------------------------------------------------- - -function bodyFixtures(alignment: string) { - const halfLen = Math.floor(JUSTIFY_TEXT_LEN / 2); - const lines = [makeLine(0, halfLen), makeLine(halfLen, JUSTIFY_TEXT_LEN)]; - - const block: FlowBlock = { - kind: 'paragraph', - id: 'body-para-div', - runs: [{ text: JUSTIFY_TEXT, fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 1 + JUSTIFY_TEXT_LEN }], - attrs: { alignment }, - }; - - const measure: Measure = { - kind: 'paragraph', - lines, - totalHeight: lines.reduce((h, l) => h + l.lineHeight, 0), - }; - - const layout: Layout = { - pageSize: { w: 600, h: 800 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'para', - blockId: 'body-para-div', - fromLine: 0, - toLine: lines.length, - x: 30, - y: 40, - width: FRAGMENT_WIDTH, - pmStart: 1, - pmEnd: 1 + JUSTIFY_TEXT_LEN, - }, - ], - }, - ], - }; - - return { blocks: [block], measures: [measure], layout }; -} - -function tableCellFixtures(alignment: string) { - const halfLen = Math.floor(JUSTIFY_TEXT_LEN / 2); - const lines = [makeLine(0, halfLen), makeLine(halfLen, JUSTIFY_TEXT_LEN)]; - - const block: FlowBlock = { - kind: 'table', - id: 'table-div', - rows: [ - { - id: 'row-0', - cells: [ - { - id: 'cell-0', - blocks: [ - { - kind: 'paragraph', - id: 'table-para-div', - runs: [ - { text: JUSTIFY_TEXT, fontFamily: 'Arial', fontSize: 12, pmStart: 1, pmEnd: 1 + JUSTIFY_TEXT_LEN }, - ], - attrs: { alignment }, - }, - ], - attrs: {}, - }, - ], - }, - ], - }; - - const measure: Measure = { - kind: 'table', - rows: [ - { - height: 40, - cells: [ - { - width: FRAGMENT_WIDTH, - height: 40, - gridColumnStart: 0, - blocks: [ - { - kind: 'paragraph', - lines, - totalHeight: lines.reduce((h, l) => h + l.lineHeight, 0), - }, - ], - }, - ], - }, - ], - columnWidths: [FRAGMENT_WIDTH], - totalWidth: FRAGMENT_WIDTH, - totalHeight: 40, - }; - - const layout: Layout = { - pageSize: { w: 600, h: 800 }, - pages: [ - { - number: 1, - fragments: [ - { - kind: 'table', - blockId: 'table-div', - fromRow: 0, - toRow: 1, - x: 30, - y: 40, - width: FRAGMENT_WIDTH, - height: 40, - }, - ], - }, - ], - }; - - return { blocks: [block], measures: [measure], layout }; -} - -function renderAndNormalize(fixtures: { blocks: FlowBlock[]; measures: Measure[]; layout: Layout }) { - const container = document.createElement('div'); - const painter = createDomPainter({ blocks: fixtures.blocks, measures: fixtures.measures }); - painter.paint(fixtures.layout, container); - return normalizeLines(container); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('known divergences (frozen — delete when resolved)', () => { - // ----------------------------------------------------------------------- - // Resolution target: PR 7 (collapse list-item parallel paint model) - // ----------------------------------------------------------------------- - - describe('list-item force-left alignment — Resolution target: PR 7 (collapse list-item parallel paint model)', () => { - it('list-item fragment forces textAlign to left even when paragraph alignment is justify', () => { - const container = document.createElement('div'); - const fix = listItemFixtures({ alignment: 'justify' }); - const painter = createDomPainter({ blocks: fix.blocks, measures: fix.measures }); - painter.paint(fix.layout, container); - - // The list-item content div overrides textAlign to 'left' - const contentEl = container.querySelector('.superdoc-list-content') as HTMLElement | null; - expect(contentEl).not.toBeNull(); - expect(contentEl!.style.textAlign).toBe('left'); - }); - - it('list-item fragment forces textAlign to left even when paragraph alignment is center', () => { - const container = document.createElement('div'); - const fix = listItemFixtures({ alignment: 'center' }); - const painter = createDomPainter({ blocks: fix.blocks, measures: fix.measures }); - painter.paint(fix.layout, container); - - const contentEl = container.querySelector('.superdoc-list-content') as HTMLElement | null; - expect(contentEl).not.toBeNull(); - expect(contentEl!.style.textAlign).toBe('left'); - }); - }); - - // ----------------------------------------------------------------------- - // Resolution target: PR 7 (collapse list-item parallel paint model) - // ----------------------------------------------------------------------- - - describe('list-item skip-justify on all lines — Resolution target: PR 7 (collapse list-item parallel paint model)', () => { - it('multi-line list-item produces zero wordSpacing on every line including non-last', () => { - // 2-line list item with alignment: 'justify' and multi-word text - const listLines = renderAndNormalize(listItemFixtures({ alignment: 'justify' })); - - expect(listLines.length).toBe(2); - // ALL lines have no word-spacing — body/table-cell would justify non-last lines - for (const line of listLines) { - expect(line.wordSpacing).toBe(''); - } - }); - }); - - // ----------------------------------------------------------------------- - // Resolution target: PR 5–7 (shared flow + list-item collapse) - // ----------------------------------------------------------------------- - - describe('three-way justify snapshot — Resolution target: PR 5–7 (shared flow + list-item collapse)', () => { - it('body and table-cell justify non-last lines; list-item does not', () => { - // Same logical paragraph (alignment: 'justify', 2 lines, multi-word text) - const bodyLines = renderAndNormalize(bodyFixtures('justify')); - const tableLines = renderAndNormalize(tableCellFixtures('justify')); - const listLines = renderAndNormalize(listItemFixtures({ alignment: 'justify' })); - - expect(bodyLines.length).toBe(2); - expect(tableLines.length).toBe(2); - expect(listLines.length).toBe(2); - - // Parity: body and table-cell line 0 wordSpacing should match and be > 0 - expect(bodyLines[0]!.wordSpacing).not.toBe(''); - expect(bodyLines[0]!.wordSpacing).toBe(tableLines[0]!.wordSpacing); - - // Divergence: list-item line 0 wordSpacing is empty (no justify) - expect(listLines[0]!.wordSpacing).toBe(''); - }); - }); -}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 655f235f36..09b0348e3c 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -5,46 +5,28 @@ import type { DrawingBlock, DrawingFragment, DrawingGeometry, - DropCapDescriptor, - FieldAnnotationRun, FlowBlock, FlowMode, - FlowRunLink, Fragment, GradientFill, ImageBlock, - ImageDrawing, ImageFragment, ImageHyperlink, - ImageRun, Line, LineSegment, - ListBlock, - ListItemFragment, - ListMeasure, PageMargins, ParaFragment, - ParagraphAttrs, ParagraphBlock, - ParagraphMeasure, PositionedDrawingGeometry, Run, - SdtMetadata, ShapeGroupChild, ShapeGroupDrawing, ShapeTextContent, SolidFillWithAlpha, SourceAnchor, - TableAttrs, TableBlock, - TableCellAttrs, TableFragment, TableMeasure, - MathRun, - TextRun, - TrackedChangeKind, - TrackedChangeMeta, - TrackedChangesMode, VectorShapeDrawing, VectorShapeStyle, ResolvedLayout, @@ -54,46 +36,22 @@ import type { ResolvedTableItem, ResolvedImageItem, ResolvedDrawingItem, - ResolvedListMarkerItem, LayoutSourceIdentity, LayoutStoryLocator, } from '@superdoc/contracts'; import { LAYOUT_BOUNDARY_SCHEMA, - adjustAvailableWidthForTextIndent, buildLayoutSourceIdentityForFragment, - calculateJustifySpacing, - computeLinePmRange, expandRunsForInlineNewlines, getCellSpacingPx, - getParagraphInlineDirection, normalizeColumnLayout, - normalizeBaselineShift, - resolveBaseFontSizeForVerticalText, - shouldApplyJustify, - sliceRunsForLine, - SPACE_CHARS, } from '@superdoc/contracts'; import { DATASET_KEYS, decodeLayoutStoryDataset, encodeLayoutStoryDataset } from '@superdoc/dom-contract'; -import { toCssFontFamily } from '@superdoc/font-utils'; import { getPresetShapeSvg } from '@superdoc/preset-geometry'; -import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation'; import { DOM_CLASS_NAMES } from './constants.js'; import { createChartElement as renderChartToElement } from './chart-renderer.js'; -import { - getRunBooleanProp, - getRunNumberProp, - getRunStringProp, - getRunUnderlineColor, - getRunUnderlineStyle, - hashCellBorders, - hashParagraphBorders, - hashTableBorders, -} from './paragraph-hash-utils.js'; -import { assertFragmentPmPositions, assertPmPositions } from './pm-position-validation.js'; import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js'; import { - BROWSER_DEFAULT_FONT_SIZE, CLASS_NAMES, containerStyles, containerStylesHorizontal, @@ -106,97 +64,50 @@ import { ensureSdtContainerStyles, ensureTrackChangeStyles, fragmentStyles, - lineStyles, pageStyles, spreadStyles, type PageStyles, } from './styles.js'; import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js'; import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js'; -import { applyImageClipPath } from './utils/image-clip-path.js'; -import { - isMinimalWordLayout as isMinimalWordLayoutShared, - resolveMarkerIndent, -} from '@superdoc/common/list-marker-utils'; +import { computeSdtBoundaries } from './sdt/boundaries.js'; +import { shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './sdt/container.js'; +import { applyContainerSdtDataset, applySdtDataset } from './sdt/dataset.js'; import { - computeTabWidth, - createListMarkerElement, - resolvePainterListMarkerGeometry, - resolvePainterListTextStartPx, -} from './utils/marker-helpers.js'; -import { applySdtContainerStyling, shouldRebuildForSdtBoundary, type SdtBoundaryOptions } from './utils/sdt-helpers.js'; + createInlineSdtWrapper, + expandSdtWrapperPmRange, + resolveRunSdtId, + syncInlineSdtWrapperTypography, +} from './sdt/inline.js'; import { - computeBetweenBorderFlags, - createParagraphDecorationLayers, - stampBetweenBorderDataset, - type BetweenBorderInfo, -} from './features/paragraph-borders/index.js'; + collectSdtSnapshotEntitiesFromDomRoot, + type PaintSnapshotStructuredContentBlockEntity, + type PaintSnapshotStructuredContentInlineEntity, +} from './sdt/snapshot.js'; +import { computeBetweenBorderFlags, type BetweenBorderInfo } from './paragraph/borders/index.js'; +import { applyParagraphFragmentPmAttributes } from './paragraph/frame.js'; +import { renderParagraphFragment as renderParagraphFragmentElement } from './paragraph/renderParagraphFragment.js'; +import { renderLine as renderRunLine } from './runs/render-line.js'; +import type { RunRenderContext } from './runs/types.js'; import { - applyRtlStyles, - shouldUseSegmentPositioning, - resolveRunDirectionAttribute, - normalizeRtlDateTokenForWordParity, -} from './features/inline-direction/index.js'; -import { convertOmmlToMathml } from './features/math/index.js'; + createDrawingImageElement, + createShapeGroupImageElement, + createShapeTextImageElement, +} from './images/drawing-image.js'; +import { renderImageFragment as renderImageFragmentElement } from './images/image-fragment.js'; +import { buildImageHyperlinkAnchor as buildSharedImageHyperlinkAnchor } from './images/hyperlink.js'; +import { applyStyles } from './utils/apply-styles.js'; +import { applyTrackedChangeDecorations, resolveTrackedChangesConfig } from './runs/tracked-changes.js'; +import { applySourceAnchorDataset } from './utils/source-anchor.js'; + +export type { + PaintSnapshotStructuredContentBlockEntity, + PaintSnapshotStructuredContentInlineEntity, +} from './sdt/snapshot.js'; const ACTIVE_HEADER_FOOTER_WATERMARK_PREVIEW_OPACITY = '1'; const INACTIVE_HEADER_FOOTER_WATERMARK_PREVIEW_OPACITY = '0.5'; -/** - * Minimal type for WordParagraphLayoutOutput marker data used in rendering. - * Extracted to avoid dependency on @superdoc/word-layout package. - */ -type WordLayoutMarker = { - markerText?: string; - justification?: 'left' | 'right' | 'center'; - gutterWidthPx?: number; - markerBoxWidthPx?: number; - suffix?: 'tab' | 'space' | 'nothing'; - /** Pre-calculated X position where the marker should be placed (used in firstLineIndentMode). */ - markerX?: number; - /** Pre-calculated X position where paragraph text should begin after the marker (used in firstLineIndentMode). */ - textStartX?: number; - run: { - fontFamily: string; - fontSize: number; - bold?: boolean; - italic?: boolean; - color?: string; - letterSpacing?: number; - vanish?: boolean; - }; -}; - -/** - * Minimal type for wordLayout property used in this renderer. - * - * This is a subset of the full WordParagraphLayoutOutput type from @superdoc/word-layout. - * We extract only the fields needed for rendering to avoid a direct dependency on the - * word-layout package from the renderer. This allows the renderer to work with any object - * that provides these properties, maintaining loose coupling between packages. - * - * The wordLayout property is attached to ParagraphBlock.attrs during block processing - * and contains layout metadata needed for proper list marker and indent rendering. - * - * @property marker - Optional list marker layout containing text, styling, and positioning info - * @property indentLeftPx - Left indent in pixels (used for marker positioning calculations) - * @property firstLineIndentMode - When true, indicates the paragraph uses firstLine indent - * pattern (marker at left+firstLine) instead of standard hanging indent (marker at left-hanging). - * This flag changes how markers are positioned and how tab spacing is calculated. - * @property textStartPx - X position where paragraph text should begin (used for tab width calculation) - * @property tabsPx - Array of explicit tab stop positions in pixels - */ -type MinimalWordLayout = { - marker?: WordLayoutMarker; - indentLeftPx?: number; - /** True for firstLine indent pattern (marker at left+firstLine vs left-hanging). */ - firstLineIndentMode?: boolean; - /** X position where paragraph text should begin. */ - textStartPx?: number; - /** Array of explicit tab stop positions in pixels. */ - tabsPx?: number[]; -}; - type LineEnd = { type?: string; width?: string; @@ -220,14 +131,6 @@ type VectorShapeDrawingWithEffects = VectorShapeDrawing & { effectExtent?: EffectExtent; }; -/** - * Type guard narrowing to the renderer-local MinimalWordLayout type. - * Delegates structural validation to the shared isMinimalWordLayout guard. - */ -function isMinimalWordLayout(value: unknown): value is MinimalWordLayout { - return isMinimalWordLayoutShared(value); -} - /** * Layout mode for document rendering. * @typedef {('vertical'|'horizontal'|'book')} LayoutMode @@ -420,24 +323,6 @@ export type PaintSnapshotAnnotationEntity = { layoutSourceIdentity?: LayoutSourceIdentity; }; -export type PaintSnapshotStructuredContentBlockEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; - layoutSourceIdentity?: LayoutSourceIdentity; -}; - -export type PaintSnapshotStructuredContentInlineEntity = { - element: HTMLElement; - pageIndex: number; - sdtId: string; - pmStart?: number; - pmEnd?: number; - layoutSourceIdentity?: LayoutSourceIdentity; -}; - export type PaintSnapshotImageEntity = { element: HTMLElement; pageIndex: number; @@ -600,31 +485,6 @@ const resolveDecorationStory = (kind: 'header' | 'footer', data: PageDecorationP return typeof id === 'string' && id.length > 0 ? { kind, id } : { kind }; }; -export function applySourceAnchorDataset(element: HTMLElement, sourceAnchor?: SourceAnchor): void { - if (!sourceAnchor) { - delete element.dataset.sourceAnchor; - delete element.dataset.sourceNodeId; - delete element.dataset.sourceOccurrenceId; - return; - } - - try { - element.dataset.sourceAnchor = JSON.stringify(sourceAnchor); - } catch { - delete element.dataset.sourceAnchor; - } - if (sourceAnchor.sourceNodeId) { - element.dataset.sourceNodeId = sourceAnchor.sourceNodeId; - } else { - delete element.dataset.sourceNodeId; - } - if (sourceAnchor.occurrenceId) { - element.dataset.sourceOccurrenceId = sourceAnchor.occurrenceId; - } else { - delete element.dataset.sourceOccurrenceId; - } -} - function readSourceAnchorDataset(element: HTMLElement | null | undefined): SourceAnchor | undefined { if (!element) return undefined; const encoded = element.dataset?.sourceAnchor; @@ -714,45 +574,14 @@ function collectPaintSnapshotEntitiesFromDomRoot(rootEl: HTMLElement): PaintSnap ); } - const blockSdtElements = Array.from( - rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.BLOCK_SDT}[data-sdt-id]`), - ); - for (const element of blockSdtElements) { - const pageIndex = resolveSnapshotPageIndex(element); - const sdtId = element.dataset.sdtId; - if (pageIndex == null || !sdtId) continue; - - entities.structuredContentBlocks.push( - compactSnapshotObject({ - element, - pageIndex, - sdtId, - pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), - pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), - layoutSourceIdentity: readNearestLayoutSourceIdentity(element), - }) as PaintSnapshotStructuredContentBlockEntity, - ); - } - - const inlineSdtElements = Array.from( - rootEl.querySelectorAll(`.${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}[data-sdt-id]`), - ); - for (const element of inlineSdtElements) { - const pageIndex = resolveSnapshotPageIndex(element); - const sdtId = element.dataset.sdtId; - if (pageIndex == null || !sdtId) continue; - - entities.structuredContentInlines.push( - compactSnapshotObject({ - element, - pageIndex, - sdtId, - pmStart: readSnapshotDatasetNumber(element.dataset.pmStart), - pmEnd: readSnapshotDatasetNumber(element.dataset.pmEnd), - layoutSourceIdentity: readNearestLayoutSourceIdentity(element), - }) as PaintSnapshotStructuredContentInlineEntity, - ); - } + const sdtEntities = collectSdtSnapshotEntitiesFromDomRoot(rootEl, { + resolvePageIndex: resolveSnapshotPageIndex, + readDatasetNumber: readSnapshotDatasetNumber, + readLayoutSourceIdentity: readNearestLayoutSourceIdentity, + compactObject: compactSnapshotObject, + }); + entities.structuredContentBlocks.push(...sdtEntities.structuredContentBlocks); + entities.structuredContentInlines.push(...sdtEntities.structuredContentInlines); const inlineImageElements = Array.from( rootEl.querySelectorAll( @@ -896,7 +725,6 @@ function collectLineTabsForSnapshot(lineEl: HTMLElement): PaintSnapshotTabStyle[ return tabs; } -const LIST_MARKER_GAP = 8; /** * Default page height in pixels (11 inches at 96 DPI). * Used as a fallback when page size information is not available for ruler rendering. @@ -904,475 +732,9 @@ const LIST_MARKER_GAP = 8; const DEFAULT_PAGE_HEIGHT_PX = 1056; /** Default gap used when virtualization is enabled (kept in sync with PresentationEditor layout defaults). */ const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; -// Comment highlight color tokens moved to CommentHighlightDecorator (super-editor). - -type LinkRenderData = { - href?: string; - target?: string; - rel?: string; - tooltip?: string | null; - dataset?: Record; - blocked: boolean; -}; - -const LINK_DATASET_KEYS = { - blocked: 'linkBlocked', - docLocation: 'linkDocLocation', - history: 'linkHistory', - rId: 'linkRid', - truncated: 'linkTooltipTruncated', -} as const; - -const MAX_HREF_LENGTH = 2048; - -const SAFE_ANCHOR_PATTERN = /^[A-Za-z0-9._-]+$/; - -/** - * Maximum allowed length for data URLs (10MB). - * Prevents denial of service attacks from extremely large embedded images. - */ -const MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; // 10MB - -/** - * Regular expression to validate data URL format for images. - * Only allows common, safe image MIME types with base64 encoding. - * Prevents XSS and malformed data URL attacks. - */ -const VALID_IMAGE_DATA_URL = /^data:image\/(png|jpeg|jpg|gif|svg\+xml|webp|bmp|ico|tiff?);base64,/i; const SVG_NS = 'http://www.w3.org/2000/svg'; const WORDART_LINE_FILL_RATIO = 0.9; - -/** - * Maximum resize multiplier for image metadata. - * Images can be resized up to 3x their original dimensions. - */ -const MAX_RESIZE_MULTIPLIER = 3; - -/** - * Fallback maximum dimension for image resizing when original size is small. - * Ensures images can be resized to at least 1000px even if original is smaller. - */ -const FALLBACK_MAX_DIMENSION = 1000; - -/** - * Minimum image dimension in pixels. - * Ensures images remain visible and interactive during resizing. - */ -const MIN_IMAGE_DIMENSION = 20; - -/** - * Pattern to detect ambiguous link text that doesn't convey destination (WCAG 2.4.4). - * Matches common generic phrases like "click here", "read more", etc. - */ -const AMBIGUOUS_LINK_PATTERNS = /^(click here|read more|more|link|here|this|download|view)$/i; - -/** - * Hyperlink rendering metrics for observability. - * Tracks sanitization, blocking, and security-related events. - */ -const linkMetrics = { - sanitized: 0, - blocked: 0, - invalidProtocol: 0, - homographWarnings: 0, - - reset() { - this.sanitized = 0; - this.blocked = 0; - this.invalidProtocol = 0; - this.homographWarnings = 0; - }, - - getMetrics() { - return { - 'hyperlink.sanitized.count': this.sanitized, - 'hyperlink.blocked.count': this.blocked, - 'hyperlink.invalid_protocol.count': this.invalidProtocol, - 'hyperlink.homograph_warnings.count': this.homographWarnings, - }; - }, -}; - -// Export for testing/monitoring -export { linkMetrics }; - -const TRACK_CHANGE_BASE_CLASS: Record = { - insert: 'track-insert-dec', - delete: 'track-delete-dec', - format: 'track-format-dec', -}; -const TRACK_CHANGE_OVERLAP_INSERT_DELETE_CLASS = 'track-overlap-insert-delete-dec'; -// TRACK_CHANGE_FOCUSED_CLASS moved to CommentHighlightDecorator (super-editor). - -const TRACK_CHANGE_MODIFIER_CLASS: Record> = { - insert: { - review: 'highlighted', - original: 'hidden', - final: 'normal', - off: undefined, - }, - delete: { - review: 'highlighted', - original: 'normal', - final: 'hidden', - off: undefined, - }, - format: { - review: 'highlighted', - original: 'before', - final: 'normal', - off: undefined, - }, -}; - -type TrackedChangesRenderConfig = { - mode: TrackedChangesMode; - enabled: boolean; -}; - -type InsertDeleteOverlap = { - parentInsert: TrackedChangeMeta; - childDelete: TrackedChangeMeta; -}; - -const getTrackedChangeLayers = (run: TextRun): TrackedChangeMeta[] => { - if (Array.isArray(run.trackedChanges) && run.trackedChanges.length > 0) { - return run.trackedChanges; - } - return run.trackedChange ? [run.trackedChange] : []; -}; - -const resolveInsertDeleteOverlap = (layers: TrackedChangeMeta[]): InsertDeleteOverlap | undefined => { - for (const parentInsert of layers) { - if (parentInsert.kind !== 'insert') { - continue; - } - const childDelete = layers.find((layer) => layer.kind === 'delete' && layer.overlapParentId === parentInsert.id); - if (childDelete) { - return { parentInsert, childDelete }; - } - } - return undefined; -}; - -/** - * Sanitize a URL to prevent XSS attacks. - * Only allows http, https, mailto, tel, and internal anchors. - * - * @param href - The URL to sanitize - * @returns Sanitized URL or null if blocked - */ -export function sanitizeUrl(href: string): string | null { - if (typeof href !== 'string') return null; - const sanitized = sanitizeHref(href); - return sanitized?.href ?? null; -} - -const LINK_TARGET_SET = new Set(['_blank', '_self', '_parent', '_top']); - -/** - * Normalize and validate an anchor fragment identifier for use in hyperlinks. - * Strips leading '#' if present and validates against safe character pattern. - * - * @param value - Raw anchor string (with or without leading '#') - * @returns Normalized anchor with leading '#' (e.g., '#section-1'), or null if invalid - * - * @remarks - * SECURITY: Only allows safe characters (A-Z, a-z, 0-9, ., _, -) to prevent HTML attribute injection. - * Rejects characters like quotes, angle brackets, colons, and spaces that could break HTML structure - * or enable XSS attacks when used in href attributes. - * - * @example - * normalizeAnchor('section-1') // Returns: '#section-1' - * normalizeAnchor('#bookmark') // Returns: '#bookmark' - * normalizeAnchor('unsafe