mcp-word-bridge 3.5.1 → 3.5.2

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/index.js CHANGED
@@ -62,12 +62,18 @@ const sslOptions = {
62
62
  cert: fs.readFileSync(path.join(CERTS_DIR, 'cert.pem'))
63
63
  };
64
64
 
65
- const MIME = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.png': 'image/png', '.json': 'application/json' };
65
+ const MIME = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.png': 'image/png', '.json': 'application/json', '.xml': 'application/xml' };
66
66
 
67
67
  const httpsServer = https.createServer(sslOptions, (req, res) => {
68
68
  let urlPath = req.url.split('?')[0];
69
69
  let filePath = urlPath === '/' ? '/taskpane.html' : urlPath;
70
- filePath = path.join(__dirname, filePath);
70
+ filePath = path.resolve(__dirname, '.' + filePath);
71
+ // Prevent path traversal — resolved path must stay within __dirname
72
+ if (!filePath.startsWith(__dirname + path.sep) && filePath !== __dirname) {
73
+ res.writeHead(403);
74
+ res.end('Forbidden');
75
+ return;
76
+ }
71
77
  const ext = path.extname(filePath);
72
78
  const contentType = MIME[ext] || 'application/octet-stream';
73
79
  fs.readFile(filePath, (err, data) => {
@@ -88,7 +94,7 @@ const httpsServer = https.createServer(sslOptions, (req, res) => {
88
94
  });
89
95
 
90
96
  // WebSocket relay: taskpane ↔ bridge (MCP server)
91
- const wss = new WebSocketServer({ server: httpsServer });
97
+ const wss = new WebSocketServer({ server: httpsServer, maxPayload: 10 * 1024 * 1024 });
92
98
  let taskpaneSocket = null;
93
99
  const bridgePending = new Map();
94
100
 
@@ -105,7 +111,15 @@ wss.on('connection', (ws, req) => {
105
111
  }
106
112
  } catch (_e) {}
107
113
  });
108
- ws.on('close', () => { process.stderr.write('[bridge] Taskpane disconnected\n'); taskpaneSocket = null; });
114
+ ws.on('close', () => {
115
+ process.stderr.write('[bridge] Taskpane disconnected\n');
116
+ taskpaneSocket = null;
117
+ // Reject all pending operations immediately instead of waiting for timeout
118
+ for (const [_id, pending] of bridgePending) {
119
+ pending.reject(new Error('Word taskpane disconnected. Reopen the MCP Word Bridge add-in in Word.'));
120
+ }
121
+ bridgePending.clear();
122
+ });
109
123
  }
110
124
  // The /bridge endpoint is no longer needed — MCP server calls sendToTaskpane directly
111
125
  });
@@ -134,7 +148,7 @@ function sendToTaskpane(action, params) {
134
148
  }
135
149
 
136
150
  const mcpServer = new Server(
137
- { name: 'mcp-word-bridge', version: '3.5.1' },
151
+ { name: 'mcp-word-bridge', version: '3.5.2' },
138
152
  { capabilities: { tools: {}, resources: {} } }
139
153
  );
140
154
 
@@ -207,17 +221,26 @@ async function executeTool(name, args) {
207
221
  if (toIndex < 0) throw new Error('toIndex must be non-negative');
208
222
  if (count < 1) throw new Error('count must be at least 1');
209
223
  if (fromIndex === toIndex && count === 1) throw new Error('fromIndex and toIndex must be different');
224
+ // Prevent overlapping moves that would cause data loss
225
+ if (toIndex >= fromIndex && toIndex < fromIndex + count) {
226
+ throw new Error('toIndex (' + toIndex + ') is inside the source range [' + fromIndex + ', ' + (fromIndex + count - 1) + ']. Move to a position outside the range being moved.');
227
+ }
210
228
  // Validate all indices are in bounds before mutating
211
229
  const paraCount = await sendToTaskpane('getParagraphs', {});
212
230
  const total = paraCount.count;
213
231
  if (fromIndex >= total) throw new Error('fromIndex ' + fromIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
214
232
  if (fromIndex + count - 1 >= total) throw new Error('fromIndex + count (' + (fromIndex + count) + ') exceeds paragraph count (' + total + ').');
215
233
  if (toIndex >= total) throw new Error('toIndex ' + toIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
216
- // Collect all paragraphs to move (text + style)
234
+ // Collect all paragraphs to move (text + style + formatting)
217
235
  const collected = [];
218
236
  for (let i = 0; i < count; i++) {
219
237
  const details = await sendToTaskpane('getParagraphByIndex', { index: fromIndex + i });
220
- collected.push({ text: details.text, style: details.style });
238
+ collected.push({
239
+ text: details.text, style: details.style, alignment: details.alignment,
240
+ firstLineIndent: details.firstLineIndent, leftIndent: details.leftIndent,
241
+ rightIndent: details.rightIndent, lineSpacing: details.lineSpacing,
242
+ spaceBefore: details.spaceBefore, spaceAfter: details.spaceAfter
243
+ });
221
244
  }
222
245
  // Delete from last to first to preserve indices during deletion
223
246
  for (let i = count - 1; i >= 0; i--) {
@@ -230,16 +253,33 @@ async function executeTool(name, args) {
230
253
  } else {
231
254
  adjustedTo = toIndex;
232
255
  }
233
- // Insert in order at destination
256
+ // Insert in order at destination, then restore formatting
234
257
  for (let i = 0; i < collected.length; i++) {
258
+ let insertedAt;
235
259
  if (i === 0) {
236
260
  await sendToTaskpane('insertParagraphAtIndex', { index: adjustedTo, text: collected[i].text, style: collected[i].style, location: location });
261
+ insertedAt = location === 'Before' ? adjustedTo : adjustedTo + 1;
237
262
  } else {
238
263
  // After the first insert, the paragraph we just inserted is at a known position.
239
264
  // For 'Before': first para went to adjustedTo, so second goes After adjustedTo.
240
265
  // For 'After': first para went to adjustedTo+1, so second goes After adjustedTo+1.
241
266
  const prevInsertedAt = location === 'Before' ? adjustedTo + (i - 1) : adjustedTo + i;
242
267
  await sendToTaskpane('insertParagraphAtIndex', { index: prevInsertedAt, text: collected[i].text, style: collected[i].style, location: 'After' });
268
+ insertedAt = prevInsertedAt + 1;
269
+ }
270
+ // Restore alignment if non-default
271
+ const c = collected[i];
272
+ if (c.alignment && c.alignment !== 'Left') {
273
+ await sendToTaskpane('setParagraphStyle', { index: insertedAt, alignment: c.alignment });
274
+ }
275
+ // Restore spacing/indentation if non-default
276
+ const spacingArgs = { index: insertedAt };
277
+ let hasSpacing = false;
278
+ if (c.firstLineIndent && c.firstLineIndent !== 0) { spacingArgs.firstLineIndent = c.firstLineIndent; hasSpacing = true; }
279
+ if (c.leftIndent && c.leftIndent !== 0) { spacingArgs.leftIndent = c.leftIndent; hasSpacing = true; }
280
+ if (c.rightIndent && c.rightIndent !== 0) { spacingArgs.rightIndent = c.rightIndent; hasSpacing = true; }
281
+ if (hasSpacing) {
282
+ await sendToTaskpane('setParagraphSpacing', spacingArgs);
243
283
  }
244
284
  }
245
285
  return { content: [{ type: 'text', text: JSON.stringify({ success: true, moved: { from: fromIndex, count, to: adjustedTo, location } }) }] };
package/lib/tools.js CHANGED
@@ -20,7 +20,7 @@ const tools = [
20
20
  { name: 'word_get_paragraphs', description: '[Paragraphs] Get paragraphs with text, style, alignment, and isTocEntry flag. Supports pagination via optional start/end index range (0-based). Returns total paragraph count.', inputSchema: { type: 'object', properties: { start: { type: 'number', description: 'First paragraph index to return (0-based, inclusive)' }, end: { type: 'number', description: 'Last paragraph index (exclusive). Omit to get all from start.' } } } },
21
21
  { name: 'word_get_paragraph_by_index', description: '[Paragraphs] Get full details of a single paragraph including font, spacing, indentation, and outline level.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' } }, required: ['index'] } },
22
22
  { name: 'word_insert_paragraph', description: '[Paragraphs] Append or prepend a styled paragraph to the document (Start or End only). For inserting at a specific position, use word_insert_paragraph_at_index instead.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Where in the document. Default: End' }, style: { type: 'string', description: 'Paragraph style (e.g. "Heading 1", "Normal"). Default: Normal' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['text'] } },
23
- { name: 'word_insert_paragraph_at_index', description: '[Paragraphs] Insert a new paragraph Before or After a specific paragraph index. Use this for precise positioning within the document (preferred over word_insert_paragraph for mid-document insertions).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Reference paragraph index (0-based)' }, text: { type: 'string', description: 'Text content for the new paragraph' }, location: { type: 'string', enum: ['Before', 'After'], description: 'Insert before or after the reference paragraph. Default: After' }, style: { type: 'string', description: 'Paragraph style (e.g. "Heading 1", "Normal")' } }, required: ['index', 'text'] } },
23
+ { name: 'word_insert_paragraph_at_index', description: '[Paragraphs] Insert a new paragraph Before or After a specific paragraph index. Use this for precise positioning within the document (preferred over word_insert_paragraph for mid-document insertions).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Reference paragraph index (0-based)' }, text: { type: 'string', description: 'Text content for the new paragraph' }, location: { type: 'string', enum: ['Before', 'After'], description: 'Insert before or after the reference paragraph. Default: After' }, style: { type: 'string', description: 'Paragraph style (e.g. "Heading 1", "Normal")' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['index', 'text'] } },
24
24
  { name: 'word_delete_paragraph', description: '[Paragraphs] Delete a paragraph by its 0-based index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' } }, required: ['index'] } },
25
25
  { name: 'word_replace_paragraph_text', description: '[Paragraphs] Replace the entire text of a paragraph by index. Preserves style/formatting. Preferred over word_search_and_replace in collaborative editing (targets by position, immune to text-drift).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' }, text: { type: 'string', description: 'New text content for the paragraph' } }, required: ['index', 'text'] } },
26
26
  { name: 'word_move_paragraph', description: '[Paragraphs] Move a paragraph from one position to another. Handles the delete+insert atomically with correct index adjustment.', inputSchema: { type: 'object', properties: { fromIndex: { type: 'number', description: 'Source paragraph index (0-based)' }, toIndex: { type: 'number', description: 'Destination paragraph index (0-based, position after removal)' }, location: { type: 'string', enum: ['Before', 'After'], description: 'Insert before or after the destination index. Default: After' }, count: { type: 'number', description: 'Number of consecutive paragraphs to move (default: 1). Use to move a heading + its body together.' } }, required: ['fromIndex', 'toIndex'] } },
@@ -34,7 +34,7 @@ const tools = [
34
34
  { name: 'word_insert_text_at_selection', description: '[Search] Insert text at the current cursor position, or replace the current selection (set replace=true). Cursor moves to end of inserted text.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, replace: { type: 'boolean', description: 'Replace current selection instead of appending. Default: false' } }, required: ['text'] } },
35
35
  { name: 'word_insert_line_break', description: '[Search] Insert a soft line break (Shift+Enter) before or after a text match.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text to search for as anchor point' }, before: { type: 'boolean', description: 'Insert before the match (default: after)' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['anchorText'] } },
36
36
  // 4. FORMATTING
37
- { name: 'word_format_text', description: '[Formatting] Apply formatting (bold, italic, color, size, font) to a text match found by search. Color must be hex (#FF0000). Size: 1-1638pt.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for and format' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, bold: { type: 'boolean' }, italic: { type: 'boolean' }, underline: { type: 'boolean' }, strikeThrough: { type: 'boolean' }, color: { type: 'string', description: 'Hex color e.g. #FF0000' }, highlightColor: { type: 'string' }, size: { type: 'number', description: 'Font size in points (1-1638)' }, name: { type: 'string', description: 'Font name' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
37
+ { name: 'word_format_text', description: '[Formatting] Apply formatting (bold, italic, color, size, font) to a text match found by search. Color must be hex (#FF0000). Size: 1-1638pt.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for and format' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, bold: { type: 'boolean' }, italic: { type: 'boolean' }, underline: { type: 'boolean' }, strikeThrough: { type: 'boolean' }, color: { type: 'string', description: 'Hex color e.g. #FF0000' }, highlightColor: { type: 'string', description: 'Highlight color name (Yellow, Green, Cyan, Magenta, Blue, Red, DarkBlue, DarkCyan, DarkGreen, DarkMagenta, DarkRed, DarkYellow, Gray25, Gray50, Black, White) or hex.' }, size: { type: 'number', description: 'Font size in points (1-1638)' }, name: { type: 'string', description: 'Font name' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
38
38
  { name: 'word_clear_formatting', description: '[Formatting] Clear direct formatting from a text match, reverting it to the paragraph style defaults.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
39
39
  { name: 'word_get_font_info', description: '[Formatting] Inspect font properties (name, size, bold, italic, color) of a text match.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
40
40
  // 5. TABLES
@@ -67,17 +67,17 @@ The bridge normalizes aliases: Left, Center/Centered, Right, Justify/Justified.
67
67
  - \`word_get_comments\` — returns all comments with their anchored document text, author, dates, resolved status
68
68
 
69
69
  ### Comment + text editing interaction
70
- **Problem:** \`word_search_and_replace\` on text anchoring a comment collapses the anchor (shrinks to empty). The comment survives but loses its positional context.
70
+ **Problem:** \`word_search_and_replace\` on text anchoring a comment **deletes the comment entirely**. The comment does NOT survive it is removed along with the anchor text.
71
71
 
72
72
  **Safe pattern:**
73
73
  1. \`word_get_comments\` — identify commented text via anchorText field
74
74
  2. If replacement overlaps a comment anchor:
75
75
  a. \`word_reply_to_comment\` explaining resolution (if appropriate)
76
76
  b. \`word_resolve_comment\`
77
- c. THEN replace the text
78
- 3. Resolved thread is preserved as a record
77
+ c. THEN replace the text (knowing the comment will be deleted)
78
+ 3. Resolved thread preserves context in the reply before deletion
79
79
 
80
- **Avoid:** replacing text first (anchor collapses), or deleting+recreating comments (loses author/date/thread).
80
+ **Avoid:** replacing text first without checking comments (silently deletes them), or assuming comments survive anchor replacement (they do not).
81
81
 
82
82
  ## Tables
83
83
  - All indices 0-based: tableIndex, row, col
package/manifest.xml CHANGED
@@ -3,7 +3,7 @@
3
3
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4
4
  xsi:type="TaskPaneApp">
5
5
  <Id>a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
6
- <Version>1.0.0</Version>
6
+ <Version>3.5.2</Version>
7
7
  <ProviderName>Leonid Mokrushin</ProviderName>
8
8
  <DefaultLocale>en-US</DefaultLocale>
9
9
  <DisplayName DefaultValue="MCP Word Bridge"/>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-word-bridge",
3
- "version": "3.5.1",
3
+ "version": "3.5.2",
4
4
  "description": "MCP server for live Word document editing via Office Add-in",
5
5
  "main": "index.js",
6
6
  "bin": {
package/taskpane-app.js CHANGED
@@ -30,7 +30,7 @@ Office.onReady(function(info) {
30
30
  // --- WebSocket ---
31
31
  let ws = null;
32
32
  function connectWebSocket() {
33
- ws = new WebSocket('wss://localhost:3000/taskpane');
33
+ ws = new WebSocket('wss://' + window.location.host + '/taskpane');
34
34
  ws.onopen = function() {
35
35
  wsStatus.textContent = 'WebSocket: connected ✓';
36
36
  wsStatus.className = 'status ok';
@@ -175,15 +175,27 @@ commands.getParagraphs = (p) => Word.run(async (ctx) => {
175
175
  if (p.start !== undefined && p.start < 0) throw new Error('start index must be non-negative');
176
176
  if (p.end !== undefined && p.end < 0) throw new Error('end index must be non-negative');
177
177
  const paragraphs = ctx.document.body.paragraphs;
178
- paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,parentTableCellOrNullObject');
179
- await ctx.sync();
178
+ // Try loading all properties including parentTableCellOrNullObject
179
+ // If merged cells cause a failure, fall back to loading without it
180
+ let hasTableInfo = true;
181
+ try {
182
+ paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,parentTableCellOrNullObject');
183
+ await ctx.sync();
184
+ } catch (_e) {
185
+ hasTableInfo = false;
186
+ paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem');
187
+ await ctx.sync();
188
+ }
180
189
  const start = p.start !== undefined ? p.start : 0;
181
190
  const end = p.end !== undefined ? p.end : paragraphs.items.length;
182
191
  if (start > end) throw new Error('start (' + start + ') must be less than or equal to end (' + end + ')');
183
192
  const items = [];
184
193
  for (let i = start; i < Math.min(end, paragraphs.items.length); i++) {
185
194
  const para = paragraphs.items[i];
186
- const inTable = para.parentTableCellOrNullObject && !para.parentTableCellOrNullObject.isNullObject;
195
+ let inTable = false;
196
+ if (hasTableInfo) {
197
+ try { inTable = para.parentTableCellOrNullObject && !para.parentTableCellOrNullObject.isNullObject; } catch (_e) { inTable = false; }
198
+ }
187
199
  const isTocEntry = !!(para.style && para.style.startsWith('TOC')) || (para.style === '' && /\t\d+$/.test(para.text));
188
200
  items.push({ index: i, text: para.text, style: para.style, alignment: para.alignment, isListItem: para.isListItem, inTable: inTable, isTocEntry: isTocEntry });
189
201
  }
@@ -253,8 +265,16 @@ commands.deleteParagraph = (p) => Word.run(async (ctx) => {
253
265
  paragraphs.load('text');
254
266
  await ctx.sync();
255
267
  if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Valid indices: 0-' + (paragraphs.items.length - 1) + ' (document has ' + paragraphs.items.length + ' paragraphs).');
268
+ const countBefore = paragraphs.items.length;
256
269
  paragraphs.items[p.index].delete();
257
- await ctx.sync();
270
+ try {
271
+ await ctx.sync();
272
+ } catch (e) {
273
+ if (e.message && e.message.includes('GeneralException')) {
274
+ throw new Error('Cannot delete paragraph ' + p.index + '. It may be a TOC field entry or inside a protected region.');
275
+ }
276
+ throw e;
277
+ }
258
278
  // Move cursor to the paragraph that now occupies this position (or previous if at end)
259
279
  const parasAfter = ctx.document.body.paragraphs;
260
280
  parasAfter.load('text');
@@ -264,7 +284,11 @@ commands.deleteParagraph = (p) => Word.run(async (ctx) => {
264
284
  parasAfter.items[cursorIdx].getRange('Start').select();
265
285
  await ctx.sync();
266
286
  }
267
- return { success: true };
287
+ const result = { success: true };
288
+ if (parasAfter.items.length >= countBefore) {
289
+ result.warning = 'Paragraph was cleared but not removed (Word requires at least one paragraph).';
290
+ }
291
+ return result;
268
292
  });
269
293
 
270
294
  commands.setParagraphStyle = (p) => Word.run(async (ctx) => {
@@ -326,6 +350,7 @@ commands.search = (p) => Word.run(async (ctx) => {
326
350
  });
327
351
 
328
352
  commands.searchAndReplace = (p) => Word.run(async (ctx) => {
353
+ if (!p.find || typeof p.find !== 'string' || p.find.trim() === '') throw new Error('find string cannot be empty. Provide a non-empty search string.');
329
354
  const results = ctx.document.body.search(p.find, { matchCase: p.matchCase || false, matchWholeWord: p.matchWholeWord || false });
330
355
  results.load('text');
331
356
  await ctx.sync();
@@ -361,24 +386,10 @@ commands.insertText = (p) => Word.run(async (ctx) => {
361
386
  return { success: true };
362
387
  });
363
388
 
364
- commands.getSelection = () => Word.run(async (ctx) => {
365
- const sel = ctx.document.getSelection();
366
- sel.load('text,style');
367
- await ctx.sync();
368
- return { text: sel.text, style: sel.style };
369
- });
370
-
371
- commands.replaceSelection = (p) => Word.run(async (ctx) => {
372
- const sel = ctx.document.getSelection();
373
- const inserted = sel.insertText(p.text, Word.InsertLocation.replace);
374
- inserted.getRange('End').select();
375
- await ctx.sync();
376
- return { success: true };
377
- });
378
-
379
389
  // == FORMATTING ==
380
390
  commands.formatRange = (p) => Word.run(async (ctx) => {
381
391
  if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
392
+ if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('text to format cannot be empty. Provide a non-empty search string.');
382
393
  if (p.size !== undefined && p.size <= 0) throw new Error('size must be positive');
383
394
  if (p.size !== undefined && p.size > 1638) throw new Error('size must not exceed 1638 points (Word maximum)');
384
395
  if (p.color && !/^#[0-9A-Fa-f]{6}$/.test(p.color)) throw new Error('color must be a valid hex color (e.g. #FF0000)');
@@ -511,17 +522,29 @@ commands.deleteTableRow = (p) => Word.run(async (ctx) => {
511
522
  commands.insertList = (p) => Word.run(async (ctx) => {
512
523
  if (!p.items || p.items.length === 0) throw new Error('items array must not be empty');
513
524
  const body = ctx.document.body;
514
- // Check if last paragraph is a list item — if so, insert a non-list separator
515
- const lastPara = body.paragraphs.getLast();
516
- lastPara.load('isListItem');
517
- await ctx.sync();
518
- if (lastPara.isListItem && (p.location || 'End') === 'End') {
519
- const sep = body.insertParagraph('', 'End');
520
- sep.style = 'Normal';
521
- sep.detachFromList();
525
+ const location = p.location || 'End';
526
+ // Check if adjacent paragraph is a list item — if so, insert a non-list separator
527
+ if (location === 'End') {
528
+ const lastPara = body.paragraphs.getLast();
529
+ lastPara.load('isListItem');
530
+ await ctx.sync();
531
+ if (lastPara.isListItem) {
532
+ const sep = body.insertParagraph('', 'End');
533
+ sep.style = 'Normal';
534
+ sep.detachFromList();
535
+ await ctx.sync();
536
+ }
537
+ } else if (location === 'Start') {
538
+ const firstPara = body.paragraphs.getFirst();
539
+ firstPara.load('isListItem');
522
540
  await ctx.sync();
541
+ if (firstPara.isListItem) {
542
+ const sep = body.insertParagraph('', 'Start');
543
+ sep.style = 'Normal';
544
+ await ctx.sync();
545
+ }
523
546
  }
524
- const para = body.insertParagraph(p.items[0], p.location || 'End');
547
+ const para = body.insertParagraph(p.items[0], location);
525
548
  para.style = 'Normal';
526
549
  await ctx.sync();
527
550
  const list = para.startNewList();
@@ -545,6 +568,7 @@ commands.insertList = (p) => Word.run(async (ctx) => {
545
568
  commands.addComment = (p) => Word.run(async (ctx) => {
546
569
  if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
547
570
  if (!p.comment || typeof p.comment !== 'string' || p.comment.trim() === '') throw new Error('comment text must be a non-empty string');
571
+ if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
548
572
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
549
573
  results.load('text');
550
574
  await ctx.sync();
@@ -610,6 +634,7 @@ commands.deleteComment = (p) => Word.run(async (ctx) => {
610
634
  // == FOOTNOTES & ENDNOTES ==
611
635
  commands.insertFootnote = (p) => Word.run(async (ctx) => {
612
636
  if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Footnote text must be a non-empty string');
637
+ if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
613
638
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
614
639
  results.load('text');
615
640
  await ctx.sync();
@@ -625,6 +650,7 @@ commands.insertFootnote = (p) => Word.run(async (ctx) => {
625
650
 
626
651
  commands.insertEndnote = (p) => Word.run(async (ctx) => {
627
652
  if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Endnote text must be a non-empty string');
653
+ if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
628
654
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
629
655
  results.load('text');
630
656
  await ctx.sync();
@@ -847,6 +873,9 @@ commands.getBookmarks = () => Word.run(async (ctx) => {
847
873
 
848
874
  commands.insertBookmark = (p) => Word.run(async (ctx) => {
849
875
  if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
876
+ if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
877
+ if (!p.name || typeof p.name !== 'string' || p.name.trim() === '') throw new Error('Bookmark name must be a non-empty string.');
878
+ if (!/^[A-Za-z_]\w*$/.test(p.name)) throw new Error('Invalid bookmark name: "' + p.name + '". Names must start with a letter or underscore and contain only letters, numbers, and underscores (no spaces).');
850
879
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
851
880
  results.load('text');
852
881
  // Check for existing bookmark with same name
@@ -885,7 +914,8 @@ commands.getStyles = () => Word.run(async (ctx) => {
885
914
  styles.load('nameLocal,type,builtIn');
886
915
  await ctx.sync();
887
916
  const items = styles.items.map(s => ({ name: s.nameLocal, type: s.type, builtIn: s.builtIn }));
888
- return { count: items.length, styles: items.slice(0, 80) };
917
+ const returned = items.slice(0, 80);
918
+ return { count: returned.length, total: items.length, styles: returned };
889
919
  });
890
920
 
891
921
  // == COAUTHORING (Desktop only) ==
@@ -976,7 +1006,14 @@ commands.insertPageBreak = (p) => Word.run(async (ctx) => {
976
1006
  lastPara.insertBreak('Page', 'After');
977
1007
  lastPara.getRange('End').select();
978
1008
  }
979
- await ctx.sync();
1009
+ try {
1010
+ await ctx.sync();
1011
+ } catch (e) {
1012
+ if (e.message && e.message.includes('GeneralException')) {
1013
+ throw new Error('Cannot insert page break at paragraph ' + (p.paragraphIndex !== undefined ? p.paragraphIndex : 'end') + '. The paragraph may be inside a table cell (page breaks are not allowed inside tables).');
1014
+ }
1015
+ throw e;
1016
+ }
980
1017
  return { success: true };
981
1018
  });
982
1019
 
@@ -1014,11 +1051,14 @@ commands.insertHyperlink = (p) => Word.run(async (ctx) => {
1014
1051
  if (!p.url || !/^https?:\/\/.+/i.test(p.url)) throw new Error('URL must be a valid HTTP or HTTPS URL (e.g. https://example.com)');
1015
1052
  // Reject URLs containing characters that must be percent-encoded (RFC 3986 unsafe chars)
1016
1053
  if (/[<>"{}|\\^`]/.test(p.url)) throw new Error('Malformed URL: "' + p.url + '". URL contains invalid characters that must be percent-encoded.');
1054
+ if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
1017
1055
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
1018
1056
  results.load('text');
1019
1057
  await ctx.sync();
1020
1058
  if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
1021
- const target = results.items[p.occurrence || 0];
1059
+ const idx = p.occurrence || 0;
1060
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
1061
+ const target = results.items[idx];
1022
1062
  target.hyperlink = p.url;
1023
1063
  target.getRange('End').select();
1024
1064
  await ctx.sync();
@@ -1039,11 +1079,14 @@ commands.insertContentControl = (p) => Word.run(async (ctx) => {
1039
1079
  const ccType = p.type || 'RichText';
1040
1080
  let range;
1041
1081
  if (p.anchorText) {
1082
+ if (typeof p.anchorText === 'string' && p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string or omit the parameter to use the current selection.');
1042
1083
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
1043
1084
  results.load('text');
1044
1085
  await ctx.sync();
1045
1086
  if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
1046
- range = results.items[p.occurrence || 0];
1087
+ const idx = p.occurrence || 0;
1088
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
1089
+ range = results.items[idx];
1047
1090
  } else {
1048
1091
  range = ctx.document.getSelection();
1049
1092
  }
@@ -1099,6 +1142,7 @@ commands.getPageLayout = (p) => Word.run(async (ctx) => {
1099
1142
  // == FONT INFO ==
1100
1143
  commands.getFontInfo = (p) => Word.run(async (ctx) => {
1101
1144
  if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
1145
+ if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('text cannot be empty. Provide a non-empty search string.');
1102
1146
  const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
1103
1147
  results.load('font');
1104
1148
  await ctx.sync();
@@ -1225,20 +1269,52 @@ commands.replaceParagraphText = (p) => Word.run(async (ctx) => {
1225
1269
  const para = paragraphs.items[p.index];
1226
1270
  const inserted = para.insertText(p.text, Word.InsertLocation.replace);
1227
1271
  inserted.getRange('End').select();
1228
- await ctx.sync();
1272
+ try {
1273
+ await ctx.sync();
1274
+ } catch (e) {
1275
+ if (e.message && e.message.includes('GeneralException')) {
1276
+ throw new Error('Cannot replace text of paragraph ' + p.index + '. It may be a TOC field entry or inside a protected region.');
1277
+ }
1278
+ throw e;
1279
+ }
1229
1280
  return { success: true };
1230
1281
  });
1231
1282
 
1232
1283
  commands.insertParagraphAtIndex = (p) => Word.run(async (ctx) => {
1233
1284
  if (p.index < 0) throw new Error('Index must be non-negative');
1285
+ // Normalize and validate alignment
1286
+ const ALIGNMENT_MAP = { 'Left': 'Left', 'Center': 'Centered', 'Centered': 'Centered', 'Right': 'Right', 'Justify': 'Justified', 'Justified': 'Justified' };
1287
+ let alignment = null;
1288
+ if (p.alignment) {
1289
+ alignment = ALIGNMENT_MAP[p.alignment];
1290
+ if (!alignment) {
1291
+ throw new Error('Invalid alignment: "' + p.alignment + '". Valid values: Left, Center, Right, Justified');
1292
+ }
1293
+ }
1234
1294
  const paragraphs = ctx.document.body.paragraphs;
1235
1295
  paragraphs.load('text');
1236
1296
  await ctx.sync();
1237
1297
  if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Valid indices: 0-' + (paragraphs.items.length - 1) + ' (document has ' + paragraphs.items.length + ' paragraphs).');
1298
+ // Validate style exists (if provided)
1299
+ const styleName = p.style || 'Normal';
1300
+ if (p.style) {
1301
+ const styleObj = ctx.document.getStyles().getByNameOrNullObject(p.style);
1302
+ styleObj.load('nameLocal');
1303
+ await ctx.sync();
1304
+ if (styleObj.isNullObject) {
1305
+ throw new Error('Style not found: "' + p.style + '". Use word_get_styles to see available styles.');
1306
+ }
1307
+ }
1238
1308
  const ref = paragraphs.items[p.index];
1239
1309
  const location = p.location === 'Before' ? Word.InsertLocation.before : Word.InsertLocation.after;
1240
1310
  const newPara = ref.insertParagraph(p.text, location);
1241
- newPara.style = p.style || 'Normal';
1311
+ newPara.style = styleName;
1312
+ await ctx.sync();
1313
+ // Apply alignment in a separate sync
1314
+ if (alignment) {
1315
+ newPara.alignment = alignment;
1316
+ await ctx.sync();
1317
+ }
1242
1318
  newPara.getRange('End').select();
1243
1319
  await ctx.sync();
1244
1320
  return { success: true };
@@ -1318,13 +1394,23 @@ commands.mergeTableCells = (p) => Word.run(async (ctx) => {
1318
1394
  commands.splitTableCell = (p) => Word.run(async (ctx) => {
1319
1395
  if (p.rowCount !== undefined && p.rowCount <= 0) throw new Error('rowCount must be a positive integer (minimum 1)');
1320
1396
  if (p.colCount !== undefined && p.colCount <= 0) throw new Error('colCount must be a positive integer (minimum 1)');
1397
+ if (p.tableIndex < 0) throw new Error('Table index must be non-negative');
1398
+ if (p.row < 0) throw new Error('Row index must be non-negative');
1399
+ if (p.col < 0) throw new Error('Column index must be non-negative');
1321
1400
  const tables = ctx.document.body.tables;
1322
1401
  tables.load('rowCount');
1323
1402
  await ctx.sync();
1324
- if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
1325
- const cell = tables.items[p.tableIndex].getCell(p.row, p.col);
1326
- cell.split(p.rowCount || 1, p.colCount || 2);
1327
- await ctx.sync();
1403
+ if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range. Document has ' + tables.items.length + ' table(s).');
1404
+ try {
1405
+ const cell = tables.items[p.tableIndex].getCell(p.row, p.col);
1406
+ cell.split(p.rowCount || 1, p.colCount || 2);
1407
+ await ctx.sync();
1408
+ } catch (e) {
1409
+ if (e.message && e.message.includes('ItemNotFound')) {
1410
+ throw new Error('Cell not found at row ' + p.row + ', col ' + p.col + '. Use word_get_table_data to inspect the table.');
1411
+ }
1412
+ throw e;
1413
+ }
1328
1414
  return { success: true };
1329
1415
  });
1330
1416
 
@@ -1404,6 +1490,7 @@ commands.getChangeTrackingMode = () => Word.run(async (ctx) => {
1404
1490
  // == CLEAR FORMATTING ==
1405
1491
  commands.clearFormatting = (p) => Word.run(async (ctx) => {
1406
1492
  if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
1493
+ if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('text cannot be empty. Provide a non-empty search string.');
1407
1494
  const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
1408
1495
  results.load('font,style');
1409
1496
  await ctx.sync();
@@ -1447,7 +1534,9 @@ commands.getPageInfo = async () => {
1447
1534
  allParas.load('text,style');
1448
1535
  await ctx.sync();
1449
1536
  const pageDetails = [];
1450
- const usedIndices = new Set();
1537
+ // Forward-only cursor: pages are sequential, so each page's paragraphs
1538
+ // must start at or after the previous page's last matched index.
1539
+ let nextStart = 0;
1451
1540
  for (let i = 0; i < pages.items.length; i++) {
1452
1541
  const page = pages.items[i];
1453
1542
  page.load('index,height,width');
@@ -1458,11 +1547,11 @@ commands.getPageInfo = async () => {
1458
1547
  let lastIdx = -1;
1459
1548
  for (let j = 0; j < paras.items.length; j++) {
1460
1549
  const p = paras.items[j];
1461
- for (let k = 0; k < allParas.items.length; k++) {
1462
- if (!usedIndices.has(k) && allParas.items[k].text === p.text && allParas.items[k].style === p.style) {
1463
- usedIndices.add(k);
1550
+ for (let k = nextStart; k < allParas.items.length; k++) {
1551
+ if (allParas.items[k].text === p.text && allParas.items[k].style === p.style) {
1464
1552
  if (firstIdx === -1) firstIdx = k;
1465
1553
  lastIdx = k;
1554
+ nextStart = k + 1;
1466
1555
  break;
1467
1556
  }
1468
1557
  }
@@ -1530,6 +1619,7 @@ commands.deleteImage = (p) => Word.run(async (ctx) => {
1530
1619
 
1531
1620
  // == TABLE CELL SHADING ==
1532
1621
  commands.setTableCellShading = (p) => Word.run(async (ctx) => {
1622
+ if (!/^#[0-9A-Fa-f]{6}$/.test(p.color)) throw new Error('color must be a valid hex color (e.g. #FFD700)');
1533
1623
  const tables = ctx.document.body.tables;
1534
1624
  tables.load('rowCount');
1535
1625
  await ctx.sync();
@@ -1568,11 +1658,14 @@ commands.getHyperlinks = () => Word.run(async (ctx) => {
1568
1658
  });
1569
1659
 
1570
1660
  commands.removeHyperlink = (p) => Word.run(async (ctx) => {
1661
+ if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
1571
1662
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
1572
1663
  results.load('hyperlink');
1573
1664
  await ctx.sync();
1574
1665
  if (results.items.length === 0) throw new Error('Text not found: ' + p.anchorText);
1575
- const target = results.items[p.occurrence || 0];
1666
+ const idx = p.occurrence || 0;
1667
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
1668
+ const target = results.items[idx];
1576
1669
  target.hyperlink = '';
1577
1670
  target.getRange('End').select();
1578
1671
  await ctx.sync();
@@ -1652,11 +1745,15 @@ commands.setListLevel = (p) => Word.run(async (ctx) => {
1652
1745
 
1653
1746
  // == LINE BREAK ==
1654
1747
  commands.insertLineBreak = (p) => Word.run(async (ctx) => {
1748
+ if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
1749
+ if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
1655
1750
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
1656
1751
  results.load('text');
1657
1752
  await ctx.sync();
1658
1753
  if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
1659
- const target = results.items[p.occurrence || 0];
1754
+ const idx = p.occurrence || 0;
1755
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
1756
+ const target = results.items[idx];
1660
1757
  const loc = p.before ? Word.InsertLocation.before : Word.InsertLocation.after;
1661
1758
  target.insertBreak('Line', loc);
1662
1759
  target.getRange('End').select();