mcp-word-bridge 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,7 +66,7 @@ mcp-word-bridge/
66
66
  └── README.md
67
67
  ```
68
68
 
69
- ## Tools (84)
69
+ ## Tools (85)
70
70
 
71
71
  ### Document
72
72
  | Tool | Description |
@@ -220,6 +220,11 @@ mcp-word-bridge/
220
220
  | `word_insert_table_of_contents` | Insert a TOC based on headings |
221
221
  | `word_get_fields` | Get all fields (hyperlinks, TOC, page numbers) |
222
222
 
223
+ ### Equations
224
+ | Tool | Description |
225
+ |------|-------------|
226
+ | `word_insert_equation` | Insert a LaTeX equation as a native editable Word equation. Supports display (centered block) and inline modes. |
227
+
223
228
  ## How it works
224
229
 
225
230
  1. The MCP client spawns `index.js` via stdio
@@ -229,6 +234,24 @@ mcp-word-bridge/
229
234
  5. Changes go through Word's editing pipeline — cursor follows edits like a human typing
230
235
  6. When the MCP client terminates the process, the bridge server stops automatically
231
236
 
237
+ ## Resources
238
+
239
+ The server exposes an MCP resource at `word-bridge://usage-guide` containing patterns, workflows, and pitfalls for LLMs operating on Word documents. MCP clients that support resources can read this for context on how to use the tools effectively.
240
+
241
+ ## Equations
242
+
243
+ `word_insert_equation` converts LaTeX math to native Word equations via:
244
+
245
+ ```
246
+ LaTeX → temml → MathML → mathml2omml → OMML → OOXML → Word
247
+ ```
248
+
249
+ - **Display mode** (`displayMode: true`, default): centered block equation on its own line
250
+ - **Inline mode** (`displayMode: false`): inserted at the current cursor position within a paragraph
251
+ - Supports: fractions, roots, integrals, sums, products, matrices, Greek letters, piecewise functions, aligned systems, and all standard LaTeX math
252
+ - Equations are fully editable in Word's built-in equation editor
253
+ - Invalid LaTeX returns a descriptive error message
254
+
232
255
  ## TLS Certificate
233
256
 
234
257
  A self-signed localhost certificate is **auto-generated on first run** if `certs/cert.pem` and `certs/key.pem` don't exist. The server prints the exact trust command with the resolved path:
package/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Single entry point: starts HTTPS bridge + MCP server in one process.
5
5
  * The MCP client spawns this; everything starts and stops together.
6
6
  *
7
- * v3.3.0 — 84 tools
7
+ * v3.4.0 — 85 tools
8
8
  */
9
9
  const https = require('https');
10
10
  const fs = require('fs');
@@ -14,6 +14,101 @@ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
14
14
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
15
15
  const { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
16
16
 
17
+ const temml = require('temml/dist/temml.cjs');
18
+ let mml2ommlFn = null;
19
+ const getMml2omml = async () => {
20
+ if (!mml2ommlFn) {
21
+ const mod = await import('mathml2omml');
22
+ mml2ommlFn = mod.mml2omml;
23
+ }
24
+ return mml2ommlFn;
25
+ };
26
+
27
+ // Post-process OMML: convert literal delimiter characters into proper <m:d> elements
28
+ // so Word renders them as stretchy (auto-sizing) delimiters.
29
+ const DELIM_PAIRS = { '(': ')', '[': ']', '{': '}', '|': '|', '\u2016': '\u2016', '\u2308': '\u2309', '\u230A': '\u230B', '\u27E8': '\u27E9' };
30
+ const OPEN_CHARS = new Set(Object.keys(DELIM_PAIRS));
31
+
32
+ function fixDelimiters(omml) {
33
+ // First, split text runs that contain delimiters mixed with other chars
34
+ // so each delimiter ends up in its own <m:r><m:t>X</m:t></m:r>
35
+ const DELIM_CHARS_STR = '()[]{}|\u2016\u2308\u2309\u230A\u230B\u27E8\u27E9';
36
+ omml = omml.replace(/<m:r>(<m:rPr>[^]*?<\/m:rPr>)?<m:t([^>]*)>([^<]+)<\/m:t><\/m:r>/g, (match, rPr, attrs, text) => {
37
+ if (text.length <= 1) return match;
38
+ const hasDelim = [...text].some(c => DELIM_CHARS_STR.includes(c));
39
+ if (!hasDelim) return match;
40
+ const rPrStr = rPr || '';
41
+ const segments = [];
42
+ let current = '';
43
+ for (const ch of text) {
44
+ if (DELIM_CHARS_STR.includes(ch)) {
45
+ if (current) segments.push(current);
46
+ segments.push(ch);
47
+ current = '';
48
+ } else {
49
+ current += ch;
50
+ }
51
+ }
52
+ if (current) segments.push(current);
53
+ return segments.map(s => '<m:r>' + rPrStr + '<m:t' + attrs + '>' + s + '</m:t></m:r>').join('');
54
+ });
55
+
56
+ const delimRun = /<m:r><m:t xml:space="preserve">([\(\)\[\]\{\}\|\u2016\u2308\u2309\u230A\u230B\u27E8\u27E9]|)<\/m:t><\/m:r>/g;
57
+ const delims = [];
58
+ let m;
59
+ while ((m = delimRun.exec(omml)) !== null) {
60
+ delims.push({ index: m.index, length: m[0].length, char: m[1] });
61
+ }
62
+ if (delims.length < 2) return omml;
63
+
64
+ const pairs = [];
65
+ const stack = [];
66
+ for (const d of delims) {
67
+ if (d.char === '|' || d.char === '\u2016') {
68
+ const stackIdx = stack.findIndex(s => s.char === d.char);
69
+ if (stackIdx >= 0) {
70
+ pairs.push({ open: stack[stackIdx], close: d });
71
+ stack.splice(stackIdx, 1);
72
+ continue;
73
+ }
74
+ }
75
+ if (OPEN_CHARS.has(d.char)) {
76
+ stack.push(d);
77
+ } else {
78
+ const expectedOpen = d.char === ')' ? '(' : d.char === ']' ? '[' : d.char === '}' ? '{' : d.char === '' ? '{' : d.char === '\u2309' ? '\u2308' : d.char === '\u230B' ? '\u230A' : d.char === '\u27E9' ? '\u27E8' : null;
79
+ for (let i = stack.length - 1; i >= 0; i--) {
80
+ if (stack[i].char === expectedOpen) {
81
+ pairs.push({ open: stack[i], close: d });
82
+ stack.splice(i, 1);
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ pairs.sort((a, b) => b.open.index - a.open.index);
90
+ for (const pair of pairs) {
91
+ const { open, close } = pair;
92
+ // Re-find the delimiter runs at current positions (indices may have shifted)
93
+ const openMatch = omml.indexOf('<m:r><m:t xml:space="preserve">' + open.char + '</m:t></m:r>', open.index > 10 ? open.index - 10 : 0);
94
+ const closeSearch = open.char === close.char ? openMatch + 50 : 0; // for | pairs, search after open
95
+ const closeMatch = omml.indexOf('<m:r><m:t xml:space="preserve">' + close.char + '</m:t></m:r>', closeSearch > openMatch ? closeSearch : openMatch + 1);
96
+ if (openMatch < 0 || closeMatch < 0 || closeMatch <= openMatch) continue;
97
+
98
+ const openLen = ('<m:r><m:t xml:space="preserve">' + open.char + '</m:t></m:r>').length;
99
+ const closeLen = ('<m:r><m:t xml:space="preserve">' + close.char + '</m:t></m:r>').length;
100
+ const content = omml.substring(openMatch + openLen, closeMatch);
101
+
102
+ let dPrContent = '';
103
+ if (open.char !== '(') dPrContent += '<m:begChr m:val="' + open.char + '"/>';
104
+ if (close.char !== ')') dPrContent += '<m:endChr m:val="' + close.char + '"/>';
105
+ const dPr = dPrContent ? '<m:dPr>' + dPrContent + '</m:dPr>' : '';
106
+ const replacement = '<m:d>' + dPr + '<m:e>' + content + '</m:e></m:d>';
107
+ omml = omml.substring(0, openMatch) + replacement + omml.substring(closeMatch + closeLen);
108
+ }
109
+ return omml;
110
+ }
111
+
17
112
  const PORT = parseInt(process.env.MCP_WORD_BRIDGE_PORT || '3000', 10);
18
113
  const CERTS_DIR = path.join(__dirname, 'certs');
19
114
 
@@ -237,6 +332,8 @@ const tools = [
237
332
  { name: 'word_insert_ooxml', description: 'Insert raw Office Open XML for precise formatting control when HTML is insufficient.', inputSchema: { type: 'object', properties: { ooxml: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'] } }, required: ['ooxml'] } },
238
333
  { name: 'word_insert_table_of_contents', description: 'Insert a table of contents based on heading styles.', inputSchema: { type: 'object', properties: { location: { type: 'string', enum: ['Start', 'End'] }, switches: { type: 'string' } } } },
239
334
  { name: 'word_get_fields', description: 'Get all fields in the document (hyperlinks, TOC entries, page numbers, etc).', inputSchema: { type: 'object', properties: {} } },
335
+ // 18. EQUATIONS
336
+ { name: 'word_insert_equation', description: 'Insert a LaTeX math equation as a native Word equation (editable in Word equation editor). Supports fractions, roots, integrals, matrices, Greek letters, and all standard LaTeX math. Use displayMode:true for centered block equations, false for inline.', inputSchema: { type: 'object', properties: { latex: { type: 'string', description: 'LaTeX math expression (e.g. "\\\\frac{a}{b}", "\\\\int_0^\\\\infty e^{-x} dx")' }, displayMode: { type: 'boolean', description: 'true = block/centered equation (default), false = inline equation' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Where to insert. Default: End' } }, required: ['latex'] } },
240
337
  ];
241
338
 
242
339
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -291,7 +388,7 @@ const toolActionMap = {
291
388
  };
292
389
 
293
390
  const mcpServer = new Server(
294
- { name: 'mcp-word-bridge', version: '3.3.0' },
391
+ { name: 'mcp-word-bridge', version: '3.4.0' },
295
392
  { capabilities: { tools: {}, resources: {} } }
296
393
  );
297
394
 
@@ -299,6 +396,149 @@ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
299
396
 
300
397
  mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
301
398
  const { name, arguments: args } = request.params;
399
+
400
+ // Special handling for word_insert_equation (server-side LaTeX→OMML conversion)
401
+ if (name === 'word_insert_equation') {
402
+ try {
403
+ const latex = args.latex;
404
+ if (!latex || typeof latex !== 'string') {
405
+ return { content: [{ type: 'text', text: 'Error: "latex" parameter is required and must be a string.' }], isError: true };
406
+ }
407
+
408
+ // Step 1: LaTeX → MathML via temml
409
+ let mathml;
410
+ try {
411
+ mathml = temml.renderToString(latex);
412
+ } catch (e) {
413
+ return { content: [{ type: 'text', text: 'LaTeX parse error: ' + e.message + '\n\nCheck your LaTeX syntax. Common issues: unmatched braces, unknown commands, missing backslashes.' }], isError: true };
414
+ }
415
+ // temml returns a <span class="temml-error"> instead of throwing on parse errors
416
+ if (mathml.includes('temml-error') || !mathml.startsWith('<math')) {
417
+ const errMatch = mathml.match(/ParseError:\s*(.+?)(<|$)/);
418
+ const errMsg = errMatch ? errMatch[1].trim() : 'Invalid LaTeX expression';
419
+ return { content: [{ type: 'text', text: 'LaTeX parse error: ' + errMsg + '\n\nCheck your LaTeX syntax. Common issues: unmatched braces, unknown commands, missing backslashes.' }], isError: true };
420
+ }
421
+
422
+ // Step 2: MathML → OMML via mathml2omml
423
+ const mml2omml = await getMml2omml();
424
+ const displayMode = args.displayMode !== false; // default: true (block)
425
+ let omml;
426
+ try {
427
+ // Preprocess: strip temml-specific attributes and mspace elements that cause conversion artifacts
428
+ let cleanMml = mathml;
429
+ cleanMml = cleanMml.replace(/<mspace[^>]*\/>/g, '');
430
+ cleanMml = cleanMml.replace(/<mspace[^>]*><\/mspace>/g, '');
431
+ cleanMml = cleanMml.replace(/ class="[^"]*"/g, '');
432
+ // In display mode, convert limit-like operators from <msub> to <munder>
433
+ // so they render with the subscript below (not to the side).
434
+ // Operators: lim, limsup, liminf, min, max, inf, sup
435
+ if (displayMode) {
436
+ cleanMml = cleanMml.replace(
437
+ /<msub><mi>(lim|lim sup|lim inf|min|max|inf|sup)<\/mi>/g,
438
+ '<munder><mi>$1</mi>'
439
+ );
440
+ cleanMml = cleanMml.replace(
441
+ /(<munder><mi>(?:lim|lim sup|lim inf|min|max|inf|sup)<\/mi><[^]*?)<\/msub>/g,
442
+ '$1</munder>'
443
+ );
444
+ }
445
+ // Fix nary operator handling: mathml2omml expects the integrand/summand to be wrapped
446
+ // in <mrow> as the next sibling of the nary <msubsup> or <msub>. Temml doesn't do this.
447
+ // We wrap all siblings after a nary operator (up to a top-level <mo>=</mo>) in <mrow>.
448
+ // Handle both <msubsup> (e.g. \int_a^b) and <msub> (e.g. \sum_i)
449
+ cleanMml = cleanMml.replace(
450
+ /(<msub(?:sup)?><mo[^>]*>[\s\S]*?<\/msub(?:sup)?>)([\s\S]*?)(<mo>[=<]<\/mo>)/g,
451
+ '$1<mrow>$2</mrow>$3'
452
+ );
453
+ // Also handle case where there's no = sign (nary at end of mrow or math)
454
+ cleanMml = cleanMml.replace(
455
+ /(<msub(?:sup)?><mo[^>]*>[\s\S]*?<\/msub(?:sup)?>)((?:<m(?:sup|sub|Sub|i|n|r|o|frac|sqrt|row)[^]*?)?)(<\/mrow>)/g,
456
+ '$1<mrow>$2</mrow>$3'
457
+ );
458
+ omml = mml2omml(cleanMml);
459
+ } catch (e) {
460
+ return { content: [{ type: 'text', text: 'MathML→OMML conversion error: ' + e.message }], isError: true };
461
+ }
462
+
463
+ // Strip redundant namespace declarations (will be declared at document level)
464
+ let cleanOmml = omml.replace(/ xmlns:m="[^"]*"/g, '').replace(/ xmlns:w="[^"]*"/g, '');
465
+ // Safety net: remove any remaining empty <m:e/> elements (renders as squares in Word)
466
+ cleanOmml = cleanOmml.replace(/<m:e\/>/g, '');
467
+ // Fix unescaped < and & in text nodes (mathml2omml doesn't escape special chars in <m:t>)
468
+ // Use split/rejoin to safely process only text content between <m:t...> and </m:t>
469
+ // Note: regex must not match <m:type> or other tags starting with "m:t"
470
+ const parts = cleanOmml.split(/(<m:t>|<m:t\s[^>]*>|<\/m:t>)/);
471
+ let inText = false;
472
+ for (let i = 0; i < parts.length; i++) {
473
+ if (parts[i] === '<m:t>' || (parts[i].startsWith('<m:t ') && parts[i].endsWith('>'))) { inText = true; continue; }
474
+ if (parts[i] === '</m:t>') { inText = false; continue; }
475
+ if (inText && (parts[i].includes('<') || parts[i].includes('&'))) {
476
+ parts[i] = parts[i].replace(/&(?!amp;|lt;|gt;|quot;|apos;)/g, '&amp;').replace(/</g, '&lt;');
477
+ }
478
+ }
479
+ cleanOmml = parts.join('');
480
+ // Fix lost trailing spaces in \text{} runs (mathml2omml trims trailing spaces from mtext)
481
+ cleanOmml = cleanOmml.replace(/(<m:r><m:rPr><m:nor\/><\/m:rPr><m:t[^>]*>)([^<]+)(<\/m:t><\/m:r><m:r>)/g, (match, prefix, text, suffix) => {
482
+ // Add a space after the text if it doesn't already end with one
483
+ if (!text.endsWith(' ')) return prefix + text + ' ' + suffix;
484
+ return match;
485
+ });
486
+ // Fix delimiters: convert literal paren/bracket/brace chars into <m:d> elements
487
+ cleanOmml = fixDelimiters(cleanOmml);
488
+
489
+ // Step 3: Wrap in OOXML flat OPC package
490
+
491
+ // Fix nary limit placement: in display mode, limits go under/over (not to the side)
492
+ if (displayMode) {
493
+ cleanOmml = cleanOmml.replace(/<m:limLoc m:val="subSup"\/>/g, '<m:limLoc m:val="undOvr"/>');
494
+ }
495
+ const mathContent = displayMode ? '<m:oMathPara>' + cleanOmml + '</m:oMathPara>' : cleanOmml;
496
+
497
+ // For display mode: equation in its own paragraph with center justification
498
+ // A trailing empty paragraph with explicit Normal style resets the formatting context
499
+ // so that subsequent insertParagraph calls don't inherit math font/alignment.
500
+ // For inline mode: equation inserted at current selection (cursor) within existing paragraph
501
+ // A trailing run with explicit normal font resets the context for subsequent text insertion.
502
+ let bodyContent;
503
+ if (displayMode) {
504
+ bodyContent = '<w:p><w:pPr><w:jc w:val="center"/></w:pPr>' + mathContent + '</w:p>' +
505
+ '<w:p><w:pPr><w:pStyle w:val="Normal"/><w:jc w:val="left"/><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/><w:sz w:val="24"/></w:rPr></w:pPr></w:p>';
506
+ } else {
507
+ // Include a zero-width space run after the equation with explicit normal font to break math context
508
+ bodyContent = '<w:p>' + mathContent +
509
+ '<w:r><w:rPr><w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/><w:sz w:val="24"/><w:i w:val="0"/></w:rPr><w:t>\u200B</w:t></w:r>' +
510
+ '</w:p>';
511
+ }
512
+
513
+ const ooxml = '<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage">' +
514
+ '<pkg:part pkg:name="/_rels/.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">' +
515
+ '<pkg:xmlData>' +
516
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
517
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
518
+ '</Relationships>' +
519
+ '</pkg:xmlData>' +
520
+ '</pkg:part>' +
521
+ '<pkg:part pkg:name="/word/document.xml" pkg:contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml">' +
522
+ '<pkg:xmlData>' +
523
+ '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">' +
524
+ '<w:body>' + bodyContent + '</w:body>' +
525
+ '</w:document>' +
526
+ '</pkg:xmlData>' +
527
+ '</pkg:part>' +
528
+ '</pkg:package>';
529
+
530
+ // Step 4: Insert via taskpane
531
+ // Display mode: insert at body End (new paragraph)
532
+ // Inline mode: insert at current selection (cursor must be positioned by caller)
533
+ const action = displayMode ? 'insertOoxml' : 'insertOoxmlAtSelection';
534
+ const params = displayMode ? { ooxml, location: args.location || 'End' } : { ooxml };
535
+ const result = await sendToTaskpane(action, params);
536
+ return { content: [{ type: 'text', text: JSON.stringify({ success: true, displayMode, latex }) }] };
537
+ } catch (e) {
538
+ return { content: [{ type: 'text', text: 'Error: ' + e.message }], isError: true };
539
+ }
540
+ }
541
+
302
542
  const action = toolActionMap[name];
303
543
  if (!action) return { content: [{ type: 'text', text: 'Unknown tool: ' + name }], isError: true };
304
544
  try {
@@ -391,6 +631,16 @@ After inserting a Table of Contents, heading text appears twice in the document
391
631
  - "Occurrence N not found" — match index out of range
392
632
  - Timeout: 30s default, 60s for HTML/OOXML/styles/TOC insertion
393
633
 
634
+ ## Equations
635
+ - \`word_insert_equation\` takes a LaTeX string and inserts a native Word equation
636
+ - \`displayMode: true\` (default) = centered block equation; \`false\` = inline
637
+ - Inline mode inserts at the current cursor position. Use \`word_insert_paragraph\` or \`word_insert_text_at_selection\` first to position the cursor, then call \`word_insert_equation\` with \`displayMode: false\`.
638
+ - After an inline equation, use \`word_insert_text_at_selection\` (not \`word_insert_paragraph\`) to continue text in the same paragraph.
639
+ - Supports: fractions, roots, integrals, sums, matrices, Greek letters, AMS math
640
+ - Invalid LaTeX returns a descriptive parse error (not a crash)
641
+ - The equation is fully editable in Word's built-in equation editor
642
+ - Examples: \`\\\\frac{a}{b}\`, \`\\\\int_0^\\\\infty e^{-x} dx\`, \`\\\\sum_{i=1}^n x_i\`
643
+
394
644
  ## Best Practices
395
645
  1. Read before writing — understand document structure first
396
646
  2. \`word_get_comments_with_anchor\` before bulk replacements to avoid anchor damage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-word-bridge",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "MCP server for live Word document editing via Office Add-in",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -11,7 +11,14 @@
11
11
  "start": "node index.js",
12
12
  "install-manifest": "node install-manifest.js"
13
13
  },
14
- "keywords": ["mcp", "word", "office", "add-in", "document", "editing"],
14
+ "keywords": [
15
+ "mcp",
16
+ "word",
17
+ "office",
18
+ "add-in",
19
+ "document",
20
+ "editing"
21
+ ],
15
22
  "repository": {
16
23
  "type": "git",
17
24
  "url": "https://github.com/likelion/mcp-word-bridge.git"
@@ -20,6 +27,8 @@
20
27
  "license": "MIT",
21
28
  "dependencies": {
22
29
  "@modelcontextprotocol/sdk": "^1.29.0",
30
+ "mathml2omml": "^0.5.0",
31
+ "temml": "^0.13.3",
23
32
  "ws": "^8.16.0"
24
33
  }
25
34
  }
package/taskpane-app.js CHANGED
@@ -6,7 +6,7 @@ const wsStatus = document.getElementById('ws-status');
6
6
  function log(msg, cls) {
7
7
  const line = document.createElement('div');
8
8
  line.className = cls || '';
9
- line.textContent = new Date().toLocaleTimeString() + ' ' + msg;
9
+ line.textContent = new Date().toLocaleTimeString('en-GB', { hour12: false }) + ' ' + msg;
10
10
  logEl.appendChild(line);
11
11
  logEl.scrollTop = logEl.scrollHeight;
12
12
  if (logEl.children.length > 200) logEl.removeChild(logEl.firstChild);
@@ -998,6 +998,21 @@ commands.insertOoxml = (p) => Word.run(async (ctx) => {
998
998
  return { success: true };
999
999
  });
1000
1000
 
1001
+ commands.insertOoxmlAtSelection = (p) => Word.run(async (ctx) => {
1002
+ const sel = ctx.document.getSelection();
1003
+ const range = sel.insertOoxml(p.ooxml, Word.InsertLocation.replace);
1004
+ range.getRange('End').select();
1005
+ try {
1006
+ await ctx.sync();
1007
+ } catch (e) {
1008
+ if (e.message && e.message.includes('GeneralException')) {
1009
+ throw new Error('Invalid OOXML. Ensure the XML follows the Office Open XML package structure (pkg:package with word/document.xml part).');
1010
+ }
1011
+ throw e;
1012
+ }
1013
+ return { success: true };
1014
+ });
1015
+
1001
1016
  // == PARAGRAPH DETAILS & SPACING ==
1002
1017
  commands.getParagraphByIndex = (p) => Word.run(async (ctx) => {
1003
1018
  if (p.index < 0) throw new Error('Index must be non-negative');