mcp-word-bridge 3.4.1 → 3.4.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
@@ -134,7 +134,7 @@ function sendToTaskpane(action, params) {
134
134
  }
135
135
 
136
136
  const mcpServer = new Server(
137
- { name: 'mcp-word-bridge', version: '3.4.1' },
137
+ { name: 'mcp-word-bridge', version: '3.4.2' },
138
138
  { capabilities: { tools: {}, resources: {} } }
139
139
  );
140
140
 
package/lib/tools.js CHANGED
@@ -23,18 +23,18 @@ const tools = [
23
23
  { name: 'word_set_paragraph_style', description: 'Change the style or alignment of a paragraph by index. Alignment accepts: Left, Center, Right, Justified.', inputSchema: { type: 'object', properties: { index: { type: 'number' }, style: { type: 'string' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['index'] } },
24
24
  { name: 'word_set_paragraph_spacing', description: 'Set line spacing (in points, e.g. 12=single for 12pt font, 24=double), before/after spacing (points), and indentation (points) on a paragraph by index.', inputSchema: { type: 'object', properties: { index: { type: 'number' }, lineSpacing: { type: 'number', description: 'Line spacing in points (e.g. 12=single for 12pt, 18=1.5x, 24=double)' }, spaceBefore: { type: 'number', description: 'Space before paragraph in points' }, spaceAfter: { type: 'number', description: 'Space after paragraph in points' }, firstLineIndent: { type: 'number', description: 'First line indent in points' }, leftIndent: { type: 'number', description: 'Left indent in points' }, rightIndent: { type: 'number', description: 'Right indent in points' } }, required: ['index'] } },
25
25
  // 3. SEARCH & TEXT
26
- { name: 'word_search', description: 'Search for text in the document (case-insensitive by default). Returns match count and up to 30 matches with their text.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, matchCase: { type: 'boolean', description: 'Case-sensitive search. Default: false' }, matchWholeWord: { type: 'boolean' } }, required: ['query'] } },
27
- { name: 'word_search_and_replace', description: 'Find and replace all occurrences of text (case-insensitive by default). Returns replacement count.', inputSchema: { type: 'object', properties: { find: { type: 'string' }, replace: { type: 'string' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' }, matchWholeWord: { type: 'boolean' } }, required: ['find', 'replace'] } },
26
+ { name: 'word_search', description: 'Search for text in the document (case-insensitive by default). Query must be non-empty and ≤255 chars. Returns match count and up to 30 matches with their text. Supports Word wildcards (e.g. ^p for paragraph mark).', inputSchema: { type: 'object', properties: { query: { type: 'string' }, matchCase: { type: 'boolean', description: 'Case-sensitive search. Default: false' }, matchWholeWord: { type: 'boolean' } }, required: ['query'] } },
27
+ { name: 'word_search_and_replace', description: 'Find and replace all occurrences of text (case-insensitive by default). Returns replacement count. Note: if find equals replace, Word still reports it as a replacement (the text is rewritten in-place). Supports Word wildcards (e.g. ^p for paragraph mark).', inputSchema: { type: 'object', properties: { find: { type: 'string' }, replace: { type: 'string' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' }, matchWholeWord: { type: 'boolean' } }, required: ['find', 'replace'] } },
28
28
  { name: 'word_insert_text', description: 'Insert text before or after a search match. Provide "after" OR "before" (not both) as the anchor string to locate.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, after: { type: 'string' }, before: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['text'] } },
29
29
  { name: 'word_get_selection_info', description: 'Get the current selection text with full font and style details.', inputSchema: { type: 'object', properties: {} } },
30
30
  { name: 'word_insert_text_at_selection', description: 'Insert text at the current cursor position, or replace the current selection (set replace=true).', inputSchema: { type: 'object', properties: { text: { type: 'string' }, replace: { type: 'boolean', description: 'Replace current selection instead of appending. Default: false' } }, required: ['text'] } },
31
31
  { name: 'word_insert_line_break', description: 'Insert a soft line break (Shift+Enter) before or after a text match.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string' }, before: { type: 'boolean' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['anchorText'] } },
32
32
  // 4. FORMATTING
33
- { name: 'word_format_text', description: 'Apply formatting (bold, italic, color, size, font) to a text match found by search.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, 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' }, name: { type: 'string', description: 'Font name' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['text'] } },
33
+ { name: 'word_format_text', description: 'Apply formatting (bold, italic, color, size, font) to a text match found by search. Color must be hex (e.g. #FF0000). Size range: 1-1638 points.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, 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 (case-insensitive)' } }, required: ['text'] } },
34
34
  { name: 'word_clear_formatting', description: 'Clear direct formatting from a text match, reverting it to the paragraph style defaults.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['text'] } },
35
35
  { name: 'word_get_font_info', description: 'Inspect font properties (name, size, bold, italic, color) of a text match.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['text'] } },
36
36
  // 5. TABLES
37
- { name: 'word_insert_table', description: 'Insert a table with data. Provide rows, cols, and data as array of arrays (e.g. [["A","B"],["C","D"]]).', inputSchema: { type: 'object', properties: { rows: { type: 'number' }, cols: { type: 'number' }, data: { type: 'array', items: { type: 'array', items: { type: 'string' } } }, location: { type: 'string', enum: ['Start', 'End'] }, style: { type: 'string' }, headerRowCount: { type: 'number' } }, required: ['rows', 'cols'] } },
37
+ { name: 'word_insert_table', description: 'Insert a table with data. Provide rows (1-500), cols (1-63), and data as array of arrays (e.g. [["A","B"],["C","D"]]).', inputSchema: { type: 'object', properties: { rows: { type: 'number' }, cols: { type: 'number' }, data: { type: 'array', items: { type: 'array', items: { type: 'string' } } }, location: { type: 'string', enum: ['Start', 'End'] }, style: { type: 'string' }, headerRowCount: { type: 'number' } }, required: ['rows', 'cols'] } },
38
38
  { name: 'word_get_tables', description: 'Get all tables with row counts, styles, and cell values.', inputSchema: { type: 'object', properties: {} } },
39
39
  { name: 'word_get_table_data', description: 'Get all cell values from a specific table by index.', inputSchema: { type: 'object', properties: { index: { type: 'number' } }, required: ['index'] } },
40
40
  { name: 'word_set_table_cell', description: 'Set text in a specific table cell by tableIndex, row, and col.', inputSchema: { type: 'object', properties: { tableIndex: { type: 'number' }, row: { type: 'number' }, col: { type: 'number' }, text: { type: 'string' } }, required: ['tableIndex', 'row', 'col', 'text'] } },
@@ -77,7 +77,7 @@ const tools = [
77
77
  { name: 'word_set_content_control_text', description: 'Set text in a content control by ID or tag.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, tag: { type: 'string' }, text: { type: 'string' } }, required: ['text'] } },
78
78
  // 11. BOOKMARKS
79
79
  { name: 'word_get_bookmarks', description: 'Get all bookmark names.', inputSchema: { type: 'object', properties: {} } },
80
- { name: 'word_insert_bookmark', description: 'Insert a bookmark at anchor text.', inputSchema: { type: 'object', properties: { name: { type: 'string' }, anchorText: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['name', 'anchorText'] } },
80
+ { name: 'word_insert_bookmark', description: 'Insert a bookmark at anchor text. If a bookmark with the same name already exists, it is moved to the new location (returns warning).', inputSchema: { type: 'object', properties: { name: { type: 'string' }, anchorText: { type: 'string' }, occurrence: { type: 'number', description: '0-indexed match to target (0=first, 1=second, etc)' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false (case-insensitive)' } }, required: ['name', 'anchorText'] } },
81
81
  { name: 'word_delete_bookmark', description: 'Delete a bookmark by name.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
82
82
  { name: 'word_go_to_bookmark', description: 'Navigate to a bookmark and select its text range.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
83
83
  { name: 'word_get_bookmark_text', description: 'Get the text content within a named bookmark.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-word-bridge",
3
- "version": "3.4.1",
3
+ "version": "3.4.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
@@ -138,9 +138,12 @@ commands.setDocumentProperties = (p) => Word.run(async (ctx) => {
138
138
 
139
139
  commands.setChangeTracking = (p) => Word.run(async (ctx) => {
140
140
  // mode: 'TrackAll', 'TrackMineOnly', or 'Off'
141
- ctx.document.changeTrackingMode = p.mode || 'TrackAll';
141
+ const validModes = ['TrackAll', 'TrackMineOnly', 'Off'];
142
+ const mode = p.mode || 'TrackAll';
143
+ if (!validModes.includes(mode)) throw new Error('Invalid mode: "' + mode + '". Valid values: TrackAll, TrackMineOnly, Off');
144
+ ctx.document.changeTrackingMode = mode;
142
145
  await ctx.sync();
143
- return { success: true, mode: p.mode || 'TrackAll' };
146
+ return { success: true, mode: mode };
144
147
  });
145
148
 
146
149
  // == PARAGRAPHS ==
@@ -150,8 +153,9 @@ commands.getParagraphs = (p) => Word.run(async (ctx) => {
150
153
  const paragraphs = ctx.document.body.paragraphs;
151
154
  paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,parentTableCellOrNullObject');
152
155
  await ctx.sync();
153
- const start = p.start || 0;
154
- const end = p.end || paragraphs.items.length;
156
+ const start = p.start !== undefined ? p.start : 0;
157
+ const end = p.end !== undefined ? p.end : paragraphs.items.length;
158
+ if (start > end) throw new Error('start (' + start + ') must be less than or equal to end (' + end + ')');
155
159
  const items = [];
156
160
  for (let i = start; i < Math.min(end, paragraphs.items.length); i++) {
157
161
  const para = paragraphs.items[i];
@@ -163,6 +167,10 @@ commands.getParagraphs = (p) => Word.run(async (ctx) => {
163
167
  });
164
168
 
165
169
  commands.insertParagraph = (p) => Word.run(async (ctx) => {
170
+ // Validate location enum
171
+ if (p.location && p.location !== 'Start' && p.location !== 'End') {
172
+ throw new Error('Invalid location: "' + p.location + '". Valid values: Start, End');
173
+ }
166
174
  // Normalize and validate alignment before any document mutation
167
175
  // Official Word.Alignment enum: Left, Centered, Right, Justified (Mixed/Unknown are read-only)
168
176
  const ALIGNMENT_MAP = { 'Left': 'Left', 'Center': 'Centered', 'Centered': 'Centered', 'Right': 'Right', 'Justify': 'Justified', 'Justified': 'Justified' };
@@ -267,9 +275,17 @@ commands.setParagraphStyle = (p) => Word.run(async (ctx) => {
267
275
 
268
276
  // == SEARCH & REPLACE ==
269
277
  commands.search = (p) => Word.run(async (ctx) => {
278
+ if (!p.query || typeof p.query !== 'string' || p.query.trim() === '') throw new Error('Search query cannot be empty. Provide a non-empty search string.');
270
279
  const results = ctx.document.body.search(p.query, { matchCase: p.matchCase || false, matchWholeWord: p.matchWholeWord || false });
271
280
  results.load('text');
272
- await ctx.sync();
281
+ try {
282
+ await ctx.sync();
283
+ } catch (e) {
284
+ if (e.message && e.message.includes('SearchStringInvalidOrTooLong')) {
285
+ throw new Error('Search query is too long (max ~255 characters). Shorten the query text.');
286
+ }
287
+ throw e;
288
+ }
273
289
  return { count: results.items.length, matches: results.items.slice(0, 30).map((r, i) => ({ index: i, text: r.text })) };
274
290
  });
275
291
 
@@ -327,6 +343,9 @@ commands.replaceSelection = (p) => Word.run(async (ctx) => {
327
343
  // == FORMATTING ==
328
344
  commands.formatRange = (p) => Word.run(async (ctx) => {
329
345
  if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
346
+ if (p.size !== undefined && p.size <= 0) throw new Error('size must be positive');
347
+ if (p.size !== undefined && p.size > 1638) throw new Error('size must not exceed 1638 points (Word maximum)');
348
+ if (p.color && !/^#[0-9A-Fa-f]{6}$/.test(p.color)) throw new Error('color must be a valid hex color (e.g. #FF0000)');
330
349
  const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
331
350
  results.load('font');
332
351
  await ctx.sync();
@@ -350,6 +369,11 @@ commands.formatRange = (p) => Word.run(async (ctx) => {
350
369
 
351
370
  // == TABLES ==
352
371
  commands.insertTable = (p) => Word.run(async (ctx) => {
372
+ // Validate rows and cols
373
+ if (!p.rows || p.rows <= 0) throw new Error('rows must be a positive integer (minimum 1)');
374
+ if (!p.cols || p.cols <= 0) throw new Error('cols must be a positive integer (minimum 1)');
375
+ if (p.cols > 63) throw new Error('cols must not exceed 63 (Word maximum column limit)');
376
+ if (p.rows > 500) throw new Error('rows must not exceed 500 (practical limit for performance)');
353
377
  // Validate data dimensions match rows/cols if data is provided
354
378
  if (p.data) {
355
379
  if (!Array.isArray(p.data)) throw new Error('data must be an array of arrays');
@@ -383,6 +407,7 @@ commands.getTables = () => Word.run(async (ctx) => {
383
407
  });
384
408
 
385
409
  commands.getTableData = (p) => Word.run(async (ctx) => {
410
+ if (p.index < 0) throw new Error('Table index must be non-negative');
386
411
  const tables = ctx.document.body.tables;
387
412
  tables.load('rowCount,values');
388
413
  await ctx.sync();
@@ -412,11 +437,16 @@ commands.setTableCell = (p) => Word.run(async (ctx) => {
412
437
  });
413
438
 
414
439
  commands.addTableRow = (p) => Word.run(async (ctx) => {
440
+ if (p.tableIndex < 0) throw new Error('Table index must be non-negative');
415
441
  const tables = ctx.document.body.tables;
416
- tables.load('rowCount');
442
+ tables.load('rowCount,values');
417
443
  await ctx.sync();
418
444
  if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
419
445
  const table = tables.items[p.tableIndex];
446
+ if (p.values && p.values.length > 0) {
447
+ const colCount = table.values[0].length;
448
+ if (p.values.length > colCount) throw new Error('values has ' + p.values.length + ' items but table only has ' + colCount + ' columns.');
449
+ }
420
450
  table.addRows(p.location || 'End', 1, p.values ? [p.values] : undefined);
421
451
  await ctx.sync();
422
452
  // Move cursor to end of table after adding row
@@ -427,6 +457,8 @@ commands.addTableRow = (p) => Word.run(async (ctx) => {
427
457
  });
428
458
 
429
459
  commands.deleteTableRow = (p) => Word.run(async (ctx) => {
460
+ if (p.tableIndex < 0) throw new Error('Table index must be non-negative');
461
+ if (p.rowIndex < 0) throw new Error('Row index must be non-negative');
430
462
  const tables = ctx.document.body.tables;
431
463
  tables.load('rowCount');
432
464
  await ctx.sync();
@@ -477,6 +509,7 @@ commands.insertList = (p) => Word.run(async (ctx) => {
477
509
  // == COMMENTS ==
478
510
  commands.addComment = (p) => Word.run(async (ctx) => {
479
511
  if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
512
+ if (!p.comment || typeof p.comment !== 'string' || p.comment.trim() === '') throw new Error('comment text must be a non-empty string');
480
513
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
481
514
  results.load('text');
482
515
  await ctx.sync();
@@ -541,6 +574,7 @@ commands.deleteComment = (p) => Word.run(async (ctx) => {
541
574
 
542
575
  // == FOOTNOTES & ENDNOTES ==
543
576
  commands.insertFootnote = (p) => Word.run(async (ctx) => {
577
+ if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Footnote text must be a non-empty string');
544
578
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
545
579
  results.load('text');
546
580
  await ctx.sync();
@@ -555,6 +589,7 @@ commands.insertFootnote = (p) => Word.run(async (ctx) => {
555
589
  });
556
590
 
557
591
  commands.insertEndnote = (p) => Word.run(async (ctx) => {
592
+ if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Endnote text must be a non-empty string');
558
593
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
559
594
  results.load('text');
560
595
  await ctx.sync();
@@ -572,6 +607,9 @@ commands.getFootnotes = () => Word.run(async (ctx) => {
572
607
  const footnotes = ctx.document.body.footnotes;
573
608
  footnotes.load('items');
574
609
  await ctx.sync();
610
+ // Re-load to avoid stale collection after recent insertions
611
+ footnotes.load('items');
612
+ await ctx.sync();
575
613
  const items = [];
576
614
  for (const fn of footnotes.items) {
577
615
  fn.body.load('text');
@@ -585,6 +623,9 @@ commands.getEndnotes = () => Word.run(async (ctx) => {
585
623
  const endnotes = ctx.document.body.endnotes;
586
624
  endnotes.load('items');
587
625
  await ctx.sync();
626
+ // Re-load to avoid stale collection after recent insertions
627
+ endnotes.load('items');
628
+ await ctx.sync();
588
629
  const items = [];
589
630
  for (const en of endnotes.items) {
590
631
  en.body.load('text');
@@ -604,9 +645,13 @@ commands.getTrackedChanges = () => Word.run(async (ctx) => {
604
645
  });
605
646
 
606
647
  commands.acceptTrackedChange = (p) => Word.run(async (ctx) => {
648
+ if (p.index < 0) throw new Error('Index must be non-negative');
607
649
  const changes = ctx.document.body.getTrackedChanges();
608
650
  changes.load('items');
609
651
  await ctx.sync();
652
+ // Re-load to avoid stale collection after recent document mutations
653
+ changes.load('items');
654
+ await ctx.sync();
610
655
  if (p.index >= changes.items.length) throw new Error('Change index out of range. Document has ' + changes.items.length + ' tracked change(s).');
611
656
  changes.items[p.index].accept();
612
657
  await ctx.sync();
@@ -614,9 +659,13 @@ commands.acceptTrackedChange = (p) => Word.run(async (ctx) => {
614
659
  });
615
660
 
616
661
  commands.rejectTrackedChange = (p) => Word.run(async (ctx) => {
662
+ if (p.index < 0) throw new Error('Index must be non-negative');
617
663
  const changes = ctx.document.body.getTrackedChanges();
618
664
  changes.load('items');
619
665
  await ctx.sync();
666
+ // Re-load to avoid stale collection after recent document mutations
667
+ changes.load('items');
668
+ await ctx.sync();
620
669
  if (p.index >= changes.items.length) throw new Error('Change index out of range. Document has ' + changes.items.length + ' tracked change(s).');
621
670
  changes.items[p.index].reject();
622
671
  await ctx.sync();
@@ -682,7 +731,7 @@ commands.setContentControlText = (p) => Word.run(async (ctx) => {
682
731
  }
683
732
  }
684
733
  }
685
- if (!target) throw new Error('Content control not found. Use word_get_content_controls to list available controls.');
734
+ if (!target) throw new Error('Content control not found. Provide "id" or "tag" to identify the control. Use word_get_content_controls to list available controls.');
686
735
  const inserted = target.insertText(p.text, Word.InsertLocation.replace);
687
736
  inserted.getRange('End').select();
688
737
  await ctx.sync();
@@ -694,7 +743,9 @@ commands.getHeaderFooter = (p) => Word.run(async (ctx) => {
694
743
  const sections = ctx.document.sections;
695
744
  sections.load('items');
696
745
  await ctx.sync();
697
- const section = sections.items[p.sectionIndex || 0];
746
+ const idx = p.sectionIndex || 0;
747
+ if (idx < 0 || idx >= sections.items.length) throw new Error('Section index out of range. Document has ' + sections.items.length + ' section(s) (0-indexed).');
748
+ const section = sections.items[idx];
698
749
  const target = p.type === 'footer' ? section.getFooter(p.headerType || 'Primary') : section.getHeader(p.headerType || 'Primary');
699
750
  target.load('text');
700
751
  await ctx.sync();
@@ -705,7 +756,9 @@ commands.setHeaderFooter = (p) => Word.run(async (ctx) => {
705
756
  const sections = ctx.document.sections;
706
757
  sections.load('items');
707
758
  await ctx.sync();
708
- const section = sections.items[p.sectionIndex || 0];
759
+ const idx = p.sectionIndex || 0;
760
+ if (idx < 0 || idx >= sections.items.length) throw new Error('Section index out of range. Document has ' + sections.items.length + ' section(s) (0-indexed).');
761
+ const section = sections.items[idx];
709
762
  const target = p.type === 'footer' ? section.getFooter(p.headerType || 'Primary') : section.getHeader(p.headerType || 'Primary');
710
763
  target.clear();
711
764
  target.insertText(p.text, Word.InsertLocation.start);
@@ -715,6 +768,7 @@ commands.setHeaderFooter = (p) => Word.run(async (ctx) => {
715
768
 
716
769
  // == IMAGES ==
717
770
  commands.insertImage = (p) => Word.run(async (ctx) => {
771
+ if (!p.base64 || typeof p.base64 !== 'string' || p.base64.trim() === '') throw new Error('Invalid image data. Ensure the base64 string is a valid PNG, JPEG, or GIF image.');
718
772
  const body = ctx.document.body;
719
773
  const picture = body.insertInlinePictureFromBase64(p.base64, p.location || 'End');
720
774
  if (p.width) picture.width = p.width;
@@ -757,15 +811,24 @@ commands.getBookmarks = () => Word.run(async (ctx) => {
757
811
  });
758
812
 
759
813
  commands.insertBookmark = (p) => Word.run(async (ctx) => {
814
+ if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
760
815
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
761
816
  results.load('text');
817
+ // Check for existing bookmark with same name
818
+ const range = ctx.document.body.getRange();
819
+ const existingBookmarks = range.getBookmarks(true, true);
762
820
  await ctx.sync();
821
+ const isDuplicate = existingBookmarks.value && existingBookmarks.value.includes(p.name);
763
822
  if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
764
- const target = results.items[p.occurrence || 0];
823
+ const idx = p.occurrence || 0;
824
+ if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
825
+ const target = results.items[idx];
765
826
  target.insertBookmark(p.name);
766
827
  target.getRange('End').select();
767
828
  await ctx.sync();
768
- return { success: true };
829
+ const result = { success: true };
830
+ if (isDuplicate) result.warning = 'Bookmark "' + p.name + '" already existed and was moved to the new location.';
831
+ return result;
769
832
  });
770
833
 
771
834
  commands.deleteBookmark = (p) => Word.run(async (ctx) => {
@@ -805,7 +868,24 @@ commands.getCoauthors = () => Word.run(async (ctx) => {
805
868
  commands.saveDocument = () => Word.run(async (ctx) => {
806
869
  ctx.document.save();
807
870
  await ctx.sync();
808
- return { success: true };
871
+ // Check if document has a file path (has been saved to a location)
872
+ let path = null;
873
+ try {
874
+ const fileProps = await new Promise((resolve) => {
875
+ Office.context.document.getFilePropertiesAsync(resolve);
876
+ });
877
+ if (fileProps.status === Office.AsyncResultStatus.Succeeded && fileProps.value.url) {
878
+ const url = fileProps.value.url;
879
+ if (/\.(docx?|docm|dotx?|dotm)$/i.test(url)) {
880
+ path = url;
881
+ }
882
+ }
883
+ } catch (_e) {}
884
+ const result = { success: true };
885
+ if (!path) {
886
+ result.warning = 'Document has no file path — use Save As in Word to set a location.';
887
+ }
888
+ return result;
809
889
  });
810
890
 
811
891
  commands.clearDocument = () => Word.run(async (ctx) => {
@@ -897,6 +977,8 @@ commands.insertSectionBreak = (p) => Word.run(async (ctx) => {
897
977
  // == HYPERLINKS ==
898
978
  commands.insertHyperlink = (p) => Word.run(async (ctx) => {
899
979
  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)');
980
+ // Reject URLs containing characters that must be percent-encoded (RFC 3986 unsafe chars)
981
+ if (/[<>"{}|\\^`]/.test(p.url)) throw new Error('Malformed URL: "' + p.url + '". URL contains invalid characters that must be percent-encoded.');
900
982
  const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
901
983
  results.load('text');
902
984
  await ctx.sync();
@@ -952,7 +1034,9 @@ commands.setPageLayout = (p) => Word.run(async (ctx) => {
952
1034
  const sections = ctx.document.sections;
953
1035
  sections.load('items');
954
1036
  await ctx.sync();
955
- const section = sections.items[p.sectionIndex || 0];
1037
+ const idx = p.sectionIndex || 0;
1038
+ if (idx < 0 || idx >= sections.items.length) throw new Error('Section index out of range. Document has ' + sections.items.length + ' section(s) (0-indexed).');
1039
+ const section = sections.items[idx];
956
1040
  const pageSetup = section.pageSetup;
957
1041
  if (p.orientation) pageSetup.orientation = p.orientation; // 'Portrait' or 'Landscape'
958
1042
  if (p.topMargin !== undefined) pageSetup.topMargin = p.topMargin;
@@ -968,7 +1052,9 @@ commands.getPageLayout = (p) => Word.run(async (ctx) => {
968
1052
  const sections = ctx.document.sections;
969
1053
  sections.load('items');
970
1054
  await ctx.sync();
971
- const section = sections.items[p.sectionIndex || 0];
1055
+ const idx = p.sectionIndex || 0;
1056
+ if (idx < 0 || idx >= sections.items.length) throw new Error('Section index out of range. Document has ' + sections.items.length + ' section(s) (0-indexed).');
1057
+ const section = sections.items[idx];
972
1058
  const ps = section.pageSetup;
973
1059
  ps.load('orientation,topMargin,bottomMargin,leftMargin,rightMargin,paperSize,headerDistance,footerDistance');
974
1060
  await ctx.sync();
@@ -1020,6 +1106,7 @@ commands.deleteCustomProperty = (p) => Word.run(async (ctx) => {
1020
1106
 
1021
1107
  // == ADVANCED TEXT INSERTION ==
1022
1108
  commands.insertHtml = (p) => Word.run(async (ctx) => {
1109
+ if (!p.html || typeof p.html !== 'string' || p.html.trim() === '') throw new Error('html parameter must be a non-empty string');
1023
1110
  const body = ctx.document.body;
1024
1111
  const loc = p.location || 'End';
1025
1112
  const range = body.insertHtml(p.html, loc);
@@ -1095,17 +1182,33 @@ commands.setParagraphSpacing = (p) => Word.run(async (ctx) => {
1095
1182
 
1096
1183
  // == BOOKMARK NAVIGATION ==
1097
1184
  commands.goToBookmark = (p) => Word.run(async (ctx) => {
1098
- const range = ctx.document.getBookmarkRange(p.name);
1099
- range.load('text');
1100
- range.select();
1101
- await ctx.sync();
1185
+ let range;
1186
+ try {
1187
+ range = ctx.document.getBookmarkRange(p.name);
1188
+ range.load('text');
1189
+ range.select();
1190
+ await ctx.sync();
1191
+ } catch (e) {
1192
+ if (e.message && e.message.includes('ItemNotFound')) {
1193
+ throw new Error('Bookmark not found: ' + p.name);
1194
+ }
1195
+ throw e;
1196
+ }
1102
1197
  return { success: true, text: range.text };
1103
1198
  });
1104
1199
 
1105
1200
  commands.getBookmarkText = (p) => Word.run(async (ctx) => {
1106
- const range = ctx.document.getBookmarkRange(p.name);
1107
- range.load('text');
1108
- await ctx.sync();
1201
+ let range;
1202
+ try {
1203
+ range = ctx.document.getBookmarkRange(p.name);
1204
+ range.load('text');
1205
+ await ctx.sync();
1206
+ } catch (e) {
1207
+ if (e.message && e.message.includes('ItemNotFound')) {
1208
+ throw new Error('Bookmark not found: ' + p.name);
1209
+ }
1210
+ throw e;
1211
+ }
1109
1212
  return { text: range.text };
1110
1213
  });
1111
1214
 
@@ -1126,14 +1229,25 @@ commands.getSections = () => Word.run(async (ctx) => {
1126
1229
 
1127
1230
  // == TABLE ADVANCED ==
1128
1231
  commands.mergeTableCells = (p) => Word.run(async (ctx) => {
1232
+ if (p.topRow < 0 || p.bottomRow < 0 || p.firstCell < 0 || p.lastCell < 0) throw new Error('All cell indices must be non-negative');
1129
1233
  if (p.topRow > p.bottomRow) throw new Error('topRow (' + p.topRow + ') must be less than or equal to bottomRow (' + p.bottomRow + ')');
1130
1234
  if (p.firstCell > p.lastCell) throw new Error('firstCell (' + p.firstCell + ') must be less than or equal to lastCell (' + p.lastCell + ')');
1235
+ if (p.tableIndex < 0) throw new Error('Table index must be non-negative');
1131
1236
  const tables = ctx.document.body.tables;
1132
1237
  tables.load('rowCount');
1133
1238
  await ctx.sync();
1134
1239
  if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
1135
- tables.items[p.tableIndex].mergeCells(p.topRow, p.firstCell, p.bottomRow, p.lastCell);
1136
- await ctx.sync();
1240
+ const table = tables.items[p.tableIndex];
1241
+ if (p.bottomRow >= table.rowCount) throw new Error('bottomRow (' + p.bottomRow + ') exceeds table row count (' + table.rowCount + ')');
1242
+ try {
1243
+ table.mergeCells(p.topRow, p.firstCell, p.bottomRow, p.lastCell);
1244
+ await ctx.sync();
1245
+ } catch (e) {
1246
+ if (e.message && e.message.includes('InvalidArgument')) {
1247
+ throw new Error('Cannot merge cells: range is out of bounds. Table has ' + table.rowCount + ' rows. Use word_get_table_data to inspect the table.');
1248
+ }
1249
+ throw e;
1250
+ }
1137
1251
  return { success: true };
1138
1252
  });
1139
1253
 
@@ -1151,12 +1265,20 @@ commands.splitTableCell = (p) => Word.run(async (ctx) => {
1151
1265
  });
1152
1266
 
1153
1267
  commands.setTableStyle = (p) => Word.run(async (ctx) => {
1268
+ if (p.tableIndex < 0) throw new Error('Table index must be non-negative');
1154
1269
  const tables = ctx.document.body.tables;
1155
1270
  tables.load('rowCount');
1156
1271
  await ctx.sync();
1157
1272
  if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
1158
- tables.items[p.tableIndex].style = p.style;
1159
- await ctx.sync();
1273
+ try {
1274
+ tables.items[p.tableIndex].style = p.style;
1275
+ await ctx.sync();
1276
+ } catch (e) {
1277
+ if (e.message && e.message.includes('InvalidArgument')) {
1278
+ throw new Error('Table style not found: "' + p.style + '". Use a built-in style name like "Grid Table 4 - Accent 1".');
1279
+ }
1280
+ throw e;
1281
+ }
1160
1282
  return { success: true };
1161
1283
  });
1162
1284
 
@@ -1283,6 +1405,7 @@ commands.getImages = () => Word.run(async (ctx) => {
1283
1405
  });
1284
1406
 
1285
1407
  commands.deleteImage = (p) => Word.run(async (ctx) => {
1408
+ if (p.index < 0) throw new Error('Index must be non-negative');
1286
1409
  const pics = ctx.document.body.inlinePictures;
1287
1410
  pics.load('altTextDescription');
1288
1411
  await ctx.sync();
@@ -1345,6 +1468,7 @@ commands.removeHyperlink = (p) => Word.run(async (ctx) => {
1345
1468
 
1346
1469
  // == FOOTNOTE/ENDNOTE MANAGEMENT ==
1347
1470
  commands.insertFootnoteAtIndex = (p) => Word.run(async (ctx) => {
1471
+ if (p.paragraphIndex < 0) throw new Error('Index must be non-negative');
1348
1472
  const paragraphs = ctx.document.body.paragraphs;
1349
1473
  paragraphs.load('text');
1350
1474
  await ctx.sync();
@@ -1358,6 +1482,7 @@ commands.insertFootnoteAtIndex = (p) => Word.run(async (ctx) => {
1358
1482
  });
1359
1483
 
1360
1484
  commands.deleteFootnote = (p) => Word.run(async (ctx) => {
1485
+ if (p.index < 0) throw new Error('Index must be non-negative');
1361
1486
  const footnotes = ctx.document.body.footnotes;
1362
1487
  footnotes.load('items');
1363
1488
  await ctx.sync();
@@ -1368,6 +1493,7 @@ commands.deleteFootnote = (p) => Word.run(async (ctx) => {
1368
1493
  });
1369
1494
 
1370
1495
  commands.deleteEndnote = (p) => Word.run(async (ctx) => {
1496
+ if (p.index < 0) throw new Error('Index must be non-negative');
1371
1497
  const endnotes = ctx.document.body.endnotes;
1372
1498
  endnotes.load('items');
1373
1499
  await ctx.sync();
@@ -1379,6 +1505,7 @@ commands.deleteEndnote = (p) => Word.run(async (ctx) => {
1379
1505
 
1380
1506
  // == LIST OPERATIONS ==
1381
1507
  commands.getListInfo = (p) => Word.run(async (ctx) => {
1508
+ if (p.index < 0) throw new Error('Index must be non-negative');
1382
1509
  const paragraphs = ctx.document.body.paragraphs;
1383
1510
  paragraphs.load('text,isListItem');
1384
1511
  await ctx.sync();
@@ -1395,6 +1522,7 @@ commands.getListInfo = (p) => Word.run(async (ctx) => {
1395
1522
 
1396
1523
  commands.setListLevel = (p) => Word.run(async (ctx) => {
1397
1524
  if (p.index < 0) throw new Error('Index must be non-negative');
1525
+ if (p.level < 0 || p.level > 8) throw new Error('level must be between 0 and 8 (inclusive)');
1398
1526
  const paragraphs = ctx.document.body.paragraphs;
1399
1527
  paragraphs.load('text,isListItem');
1400
1528
  await ctx.sync();