mcp-word-bridge 3.5.2 → 3.5.3
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 +2 -1
- package/index.js +59 -52
- package/lib/tools.js +4 -3
- package/lib/usage-guide.js +3 -2
- package/manifest.xml +1 -1
- package/package.json +1 -1
- package/taskpane-app.js +100 -8
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ mcp-word-bridge/
|
|
|
80
80
|
└── README.md
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
## Tools (
|
|
83
|
+
## Tools (92)
|
|
84
84
|
|
|
85
85
|
### Document
|
|
86
86
|
| Tool | Description |
|
|
@@ -107,6 +107,7 @@ mcp-word-bridge/
|
|
|
107
107
|
| `word_delete_paragraph` | Delete a paragraph by index |
|
|
108
108
|
| `word_replace_paragraph_text` | Replace paragraph text by index (preserves style) |
|
|
109
109
|
| `word_move_paragraph` | Move one or more consecutive paragraphs to another position |
|
|
110
|
+
| `word_copy_paragraph` | Copy one or more consecutive paragraphs to another position |
|
|
110
111
|
| `word_set_paragraph_style` | Change style or alignment of a paragraph |
|
|
111
112
|
| `word_set_paragraph_spacing` | Set line spacing, before/after, and indentation |
|
|
112
113
|
|
package/index.js
CHANGED
|
@@ -148,7 +148,7 @@ function sendToTaskpane(action, params) {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
const mcpServer = new Server(
|
|
151
|
-
{ name: 'mcp-word-bridge', version: '3.5.
|
|
151
|
+
{ name: 'mcp-word-bridge', version: '3.5.3' },
|
|
152
152
|
{ capabilities: { tools: {}, resources: {} } }
|
|
153
153
|
);
|
|
154
154
|
|
|
@@ -177,16 +177,19 @@ async function executeTool(name, args) {
|
|
|
177
177
|
await sendToTaskpane('insertOoxml', { ooxml, location: args.location || 'End' });
|
|
178
178
|
} else if (args.anchorText) {
|
|
179
179
|
// Inline mode with anchor: search for anchor text, insert space+marker after it, insert equation at cursor, clean up marker
|
|
180
|
-
const marker = '\u200B\uFEFF'; //
|
|
180
|
+
const marker = '\u200B\uFEFF\u200B'; // three-char zero-width sequence — virtually impossible in real documents
|
|
181
181
|
const searchResult = await sendToTaskpane('search', { query: args.anchorText, matchCase: args.matchCase || false });
|
|
182
182
|
if (!searchResult || searchResult.count === 0) throw new Error('Anchor not found: ' + args.anchorText);
|
|
183
183
|
const occurrence = args.occurrence || 0;
|
|
184
184
|
if (occurrence >= searchResult.count) throw new Error('Occurrence ' + occurrence + ' not found (only ' + searchResult.count + ' match' + (searchResult.count === 1 ? '' : 'es') + ')');
|
|
185
185
|
// Insert space + marker to position cursor (space ensures equation doesn't glue to preceding text)
|
|
186
186
|
await sendToTaskpane('insertText', { text: ' ' + marker, after: args.anchorText, occurrence: occurrence, matchCase: args.matchCase || false });
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
try {
|
|
188
|
+
await sendToTaskpane('insertOoxmlAtSelection', { ooxml });
|
|
189
|
+
} finally {
|
|
190
|
+
// Always clean up the marker, even if equation insertion fails
|
|
191
|
+
await sendToTaskpane('searchAndReplace', { find: marker, replace: '' });
|
|
192
|
+
}
|
|
190
193
|
} else {
|
|
191
194
|
await sendToTaskpane('insertOoxmlAtSelection', { ooxml });
|
|
192
195
|
}
|
|
@@ -199,18 +202,25 @@ async function executeTool(name, args) {
|
|
|
199
202
|
const result = await sendToTaskpane('getParagraphs', {});
|
|
200
203
|
const headings = [];
|
|
201
204
|
for (const para of result.paragraphs) {
|
|
205
|
+
// Skip TOC entries — they duplicate real headings
|
|
206
|
+
if (para.isTocEntry) continue;
|
|
207
|
+
let level = null;
|
|
208
|
+
// Primary: match style name "Heading N"
|
|
202
209
|
const match = para.style && para.style.match(/^Heading (\d)$/i);
|
|
203
210
|
if (match) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
211
|
+
level = parseInt(match[1], 10);
|
|
212
|
+
} else if (para.outlineLevel !== undefined && para.outlineLevel >= 1 && para.outlineLevel <= 9) {
|
|
213
|
+
// Fallback: use outlineLevel when style is empty or unrecognized (e.g. after TOC insertion)
|
|
214
|
+
level = para.outlineLevel;
|
|
215
|
+
}
|
|
216
|
+
if (level !== null && level <= maxLevel) {
|
|
217
|
+
headings.push({ level, text: para.text, index: para.index });
|
|
208
218
|
}
|
|
209
219
|
}
|
|
210
220
|
return { content: [{ type: 'text', text: JSON.stringify({ count: headings.length, outline: headings }, null, 2) }] };
|
|
211
221
|
}
|
|
212
222
|
|
|
213
|
-
// word_move_paragraph:
|
|
223
|
+
// word_move_paragraph: OOXML round-trip move (preserves all rich content: footnotes, hyperlinks, formatting, images, comments)
|
|
214
224
|
// Supports moving multiple consecutive paragraphs via optional 'count' parameter
|
|
215
225
|
if (name === 'word_move_paragraph') {
|
|
216
226
|
const fromIndex = args.fromIndex;
|
|
@@ -231,58 +241,55 @@ async function executeTool(name, args) {
|
|
|
231
241
|
if (fromIndex >= total) throw new Error('fromIndex ' + fromIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
|
|
232
242
|
if (fromIndex + count - 1 >= total) throw new Error('fromIndex + count (' + (fromIndex + count) + ') exceeds paragraph count (' + total + ').');
|
|
233
243
|
if (toIndex >= total) throw new Error('toIndex ' + toIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
|
|
234
|
-
//
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
});
|
|
244
|
-
}
|
|
245
|
-
// Delete from last to first to preserve indices during deletion
|
|
244
|
+
// Step 1: Capture full OOXML of source paragraphs (preserves everything)
|
|
245
|
+
const ooxmlResult = await sendToTaskpane('getParaOoxml', { index: fromIndex, count: count });
|
|
246
|
+
const savedOoxml = ooxmlResult.ooxml;
|
|
247
|
+
// Step 2: Delete source paragraphs (last to first to preserve indices)
|
|
246
248
|
for (let i = count - 1; i >= 0; i--) {
|
|
247
249
|
await sendToTaskpane('deleteParagraph', { index: fromIndex + i });
|
|
248
250
|
}
|
|
249
|
-
// Adjust destination index after deletions
|
|
251
|
+
// Step 3: Adjust destination index after deletions (same arithmetic as before)
|
|
250
252
|
let adjustedTo;
|
|
251
253
|
if (fromIndex < toIndex) {
|
|
252
254
|
adjustedTo = toIndex - count;
|
|
253
255
|
} else {
|
|
254
256
|
adjustedTo = toIndex;
|
|
255
257
|
}
|
|
256
|
-
// Insert
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// For 'After': first para went to adjustedTo+1, so second goes After adjustedTo+1.
|
|
266
|
-
const prevInsertedAt = location === 'Before' ? adjustedTo + (i - 1) : adjustedTo + i;
|
|
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);
|
|
258
|
+
// Step 4: Insert OOXML at destination (with restore-on-failure)
|
|
259
|
+
try {
|
|
260
|
+
await sendToTaskpane('insertOoxmlAtIndex', { ooxml: savedOoxml, index: adjustedTo, location: location });
|
|
261
|
+
} catch (insertErr) {
|
|
262
|
+
// Restore at original position on failure
|
|
263
|
+
try {
|
|
264
|
+
await sendToTaskpane('insertOoxmlAtIndex', { ooxml: savedOoxml, index: Math.min(fromIndex, adjustedTo), location: 'Before' });
|
|
265
|
+
} catch (_restoreErr) {
|
|
266
|
+
throw new Error('Move failed AND restore failed: ' + insertErr.message + '. Use Ctrl+Z to recover.');
|
|
283
267
|
}
|
|
268
|
+
throw new Error('Move failed (content restored to original position): ' + insertErr.message);
|
|
284
269
|
}
|
|
285
|
-
return { content: [{ type: 'text', text: JSON.stringify({ success: true, moved: { from: fromIndex, count, to: adjustedTo, location } }) }] };
|
|
270
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, moved: { from: fromIndex, count, to: adjustedTo, toIndexRequested: toIndex, location } }) }] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// word_copy_paragraph: OOXML round-trip copy (preserves all rich content)
|
|
274
|
+
if (name === 'word_copy_paragraph') {
|
|
275
|
+
const fromIndex = args.fromIndex;
|
|
276
|
+
const toIndex = args.toIndex;
|
|
277
|
+
const count = args.count || 1;
|
|
278
|
+
const location = args.location || 'After';
|
|
279
|
+
if (fromIndex < 0) throw new Error('fromIndex must be non-negative');
|
|
280
|
+
if (toIndex < 0) throw new Error('toIndex must be non-negative');
|
|
281
|
+
if (count < 1) throw new Error('count must be at least 1');
|
|
282
|
+
// Validate all indices are in bounds
|
|
283
|
+
const paraCount = await sendToTaskpane('getParagraphs', {});
|
|
284
|
+
const total = paraCount.count;
|
|
285
|
+
if (fromIndex >= total) throw new Error('fromIndex ' + fromIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
|
|
286
|
+
if (fromIndex + count - 1 >= total) throw new Error('fromIndex + count (' + (fromIndex + count) + ') exceeds paragraph count (' + total + ').');
|
|
287
|
+
if (toIndex >= total) throw new Error('toIndex ' + toIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
|
|
288
|
+
// Step 1: Capture full OOXML of source paragraphs
|
|
289
|
+
const ooxmlResult = await sendToTaskpane('getParaOoxml', { index: fromIndex, count: count });
|
|
290
|
+
// Step 2: Insert OOXML at destination (no delete — it's a copy)
|
|
291
|
+
await sendToTaskpane('insertOoxmlAtIndex', { ooxml: ooxmlResult.ooxml, index: toIndex, location: location });
|
|
292
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, copied: { from: fromIndex, count, to: toIndex, location } }) }] };
|
|
286
293
|
}
|
|
287
294
|
|
|
288
295
|
// word_batch: execute multiple operations in a single call
|
|
@@ -296,7 +303,7 @@ async function executeTool(name, args) {
|
|
|
296
303
|
}
|
|
297
304
|
|
|
298
305
|
// Partition into taskpane-native ops (have action mapping) vs server-composed ops
|
|
299
|
-
const SERVER_HANDLED = new Set(['word_insert_equation', 'word_batch', 'word_get_document_outline', 'word_move_paragraph']);
|
|
306
|
+
const SERVER_HANDLED = new Set(['word_insert_equation', 'word_batch', 'word_get_document_outline', 'word_move_paragraph', 'word_copy_paragraph']);
|
|
300
307
|
const results = [];
|
|
301
308
|
|
|
302
309
|
// Collect consecutive taskpane-native ops into batches, flush when hitting a server op
|
package/lib/tools.js
CHANGED
|
@@ -23,7 +23,8 @@ const tools = [
|
|
|
23
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
|
-
{ name: 'word_move_paragraph', description: '[Paragraphs] Move a paragraph from one position to another.
|
|
26
|
+
{ name: 'word_move_paragraph', description: '[Paragraphs] Move a paragraph from one position to another. Preserves all rich content including footnotes, hyperlinks, formatting, images, and comments.', 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'] } },
|
|
27
|
+
{ name: 'word_copy_paragraph', description: '[Paragraphs] Copy paragraph(s) to another position. Preserves all rich content including footnotes, hyperlinks, formatting, images, and comments. Source remains unchanged.', inputSchema: { type: 'object', properties: { fromIndex: { type: 'number', description: 'Source paragraph index (0-based)' }, toIndex: { type: 'number', description: 'Destination paragraph index (0-based)' }, 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 copy (default: 1). Use to copy a heading + its body together.' } }, required: ['fromIndex', 'toIndex'] } },
|
|
27
28
|
{ name: 'word_set_paragraph_style', description: '[Paragraphs] Change the style or alignment of a paragraph by index. Alignment accepts: Left, Center, Right, Justified.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' }, style: { type: 'string' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['index'] } },
|
|
28
29
|
{ name: 'word_set_paragraph_spacing', description: '[Paragraphs] Set line spacing (in points: 12=single for 12pt font, 18=1.5x, 24=double), before/after spacing, and indentation on a paragraph by index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' }, 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'] } },
|
|
29
30
|
// 3. SEARCH & TEXT
|
|
@@ -113,7 +114,7 @@ const tools = [
|
|
|
113
114
|
// 18. EQUATIONS
|
|
114
115
|
{ name: 'word_insert_equation', description: '[Equations] Insert a LaTeX math equation as a native editable Word equation. Supports fractions, roots, integrals, matrices, Greek letters, and all standard LaTeX math. Display mode (default) inserts a centered block equation. Inline mode inserts after a search match (provide anchorText) or at cursor position.', 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 (default) = centered block equation, false = inline equation' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Where to insert display-mode equations. Default: End' }, anchorText: { type: 'string', description: 'For inline mode: search for this text and insert equation after it (avoids manual cursor positioning)' }, occurrence: { type: 'number', description: 'Which anchorText match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive anchor matching. Default: false' } }, required: ['latex'] } },
|
|
115
116
|
// 19. BATCH OPERATIONS
|
|
116
|
-
{ name: 'word_batch', description: '[Batch] Execute multiple operations in a single call
|
|
117
|
+
{ name: 'word_batch', description: '[Batch] Execute multiple operations in a single call. Each operation is a tool call object with "tool" and "args" fields. Operations execute sequentially — if one fails, subsequent operations are skipped. Returns results array with success/error for each operation.', inputSchema: { type: 'object', properties: { operations: { type: 'array', items: { type: 'object', properties: { tool: { type: 'string', description: 'Tool name (e.g. "word_insert_paragraph")' }, args: { type: 'object', description: 'Arguments for the tool' } }, required: ['tool'] }, description: 'Array of {tool, args} objects to execute sequentially' } }, required: ['operations'] } },
|
|
117
118
|
];
|
|
118
119
|
|
|
119
120
|
const toolActionMap = {
|
|
@@ -163,7 +164,7 @@ const toolActionMap = {
|
|
|
163
164
|
word_insert_html: 'insertHtml', word_insert_ooxml: 'insertOoxml',
|
|
164
165
|
word_insert_table_of_contents: 'insertTableOfContents', word_get_fields: 'getFields',
|
|
165
166
|
word_get_page_info: 'getPageInfo',
|
|
166
|
-
// word_insert_equation, word_batch, word_get_document_outline, word_move_paragraph
|
|
167
|
+
// word_insert_equation, word_batch, word_get_document_outline, word_move_paragraph, word_copy_paragraph
|
|
167
168
|
// are handled directly in index.js (not via taskpane action map)
|
|
168
169
|
};
|
|
169
170
|
|
package/lib/usage-guide.js
CHANGED
|
@@ -47,7 +47,7 @@ Controls a live Word document through an Office Add-in. All operations execute i
|
|
|
47
47
|
\`\`\`
|
|
48
48
|
Operations run sequentially. If one fails, the rest are skipped. Maximum 50 per batch.
|
|
49
49
|
|
|
50
|
-
**Performance:**
|
|
50
|
+
**Performance:** Batching is significantly faster than individual calls — use it whenever you have multiple operations to perform.
|
|
51
51
|
|
|
52
52
|
## Search Behavior (applies to ALL search-based tools)
|
|
53
53
|
- Case-insensitive by default. Pass \`matchCase: true\` for exact case match.
|
|
@@ -116,7 +116,7 @@ After inserting a Table of Contents, heading text appears twice in the document
|
|
|
116
116
|
- "Word taskpane not connected" — user must open the MCP Word Bridge add-in in Word
|
|
117
117
|
- "Anchor not found" — search text not found in document
|
|
118
118
|
- "Occurrence N not found" — match index out of range
|
|
119
|
-
-
|
|
119
|
+
- Operations on large documents or complex content (HTML, OOXML, TOC) may take longer
|
|
120
120
|
|
|
121
121
|
## Best Practices
|
|
122
122
|
1. Start with \`word_get_document_outline\` to understand structure
|
|
@@ -129,6 +129,7 @@ After inserting a Table of Contents, heading text appears twice in the document
|
|
|
129
129
|
6. \`word_save\` explicitly after significant changes
|
|
130
130
|
7. Resolve comments rather than deleting them (preserves audit trail)
|
|
131
131
|
8. Use \`word_move_paragraph\` to reorder content (avoids index-shifting bugs with manual delete+insert)
|
|
132
|
+
9. Use \`word_copy_paragraph\` to duplicate content with full fidelity (footnotes, formatting, hyperlinks preserved)
|
|
132
133
|
`;
|
|
133
134
|
|
|
134
135
|
module.exports = USAGE_GUIDE;
|
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>3.5.
|
|
6
|
+
<Version>3.5.3</Version>
|
|
7
7
|
<ProviderName>Leonid Mokrushin</ProviderName>
|
|
8
8
|
<DefaultLocale>en-US</DefaultLocale>
|
|
9
9
|
<DisplayName DefaultValue="MCP Word Bridge"/>
|
package/package.json
CHANGED
package/taskpane-app.js
CHANGED
|
@@ -70,6 +70,13 @@ async function handleCommand(cmd) {
|
|
|
70
70
|
// --- Command registry ---
|
|
71
71
|
const commands = {};
|
|
72
72
|
|
|
73
|
+
// Helper: validate search string length (Word's limit is ~255 characters)
|
|
74
|
+
function checkSearchLength(text) {
|
|
75
|
+
if (text && text.length > 255) {
|
|
76
|
+
throw new Error('Search text is too long (max ~255 characters). Shorten the text or use a substring.');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
// == BATCH EXECUTION (runs multiple actions in one WebSocket message) ==
|
|
74
81
|
commands.batchExecute = async (p) => {
|
|
75
82
|
if (!p.operations || !Array.isArray(p.operations) || p.operations.length === 0) {
|
|
@@ -179,11 +186,11 @@ commands.getParagraphs = (p) => Word.run(async (ctx) => {
|
|
|
179
186
|
// If merged cells cause a failure, fall back to loading without it
|
|
180
187
|
let hasTableInfo = true;
|
|
181
188
|
try {
|
|
182
|
-
paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,parentTableCellOrNullObject');
|
|
189
|
+
paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,outlineLevel,parentTableCellOrNullObject');
|
|
183
190
|
await ctx.sync();
|
|
184
191
|
} catch (_e) {
|
|
185
192
|
hasTableInfo = false;
|
|
186
|
-
paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem');
|
|
193
|
+
paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,outlineLevel');
|
|
187
194
|
await ctx.sync();
|
|
188
195
|
}
|
|
189
196
|
const start = p.start !== undefined ? p.start : 0;
|
|
@@ -197,9 +204,13 @@ commands.getParagraphs = (p) => Word.run(async (ctx) => {
|
|
|
197
204
|
try { inTable = para.parentTableCellOrNullObject && !para.parentTableCellOrNullObject.isNullObject; } catch (_e) { inTable = false; }
|
|
198
205
|
}
|
|
199
206
|
const isTocEntry = !!(para.style && para.style.startsWith('TOC')) || (para.style === '' && /\t\d+$/.test(para.text));
|
|
200
|
-
items.push({ index: i, text: para.text, style: para.style, alignment: para.alignment, isListItem: para.isListItem, inTable: inTable, isTocEntry: isTocEntry });
|
|
207
|
+
items.push({ index: i, text: para.text, style: para.style, alignment: para.alignment, isListItem: para.isListItem, inTable: inTable, isTocEntry: isTocEntry, outlineLevel: para.outlineLevel });
|
|
201
208
|
}
|
|
202
|
-
|
|
209
|
+
const result = { count: paragraphs.items.length, paragraphs: items };
|
|
210
|
+
if (p.start !== undefined && p.start >= paragraphs.items.length) {
|
|
211
|
+
result.warning = 'start index (' + p.start + ') is beyond the last paragraph. Document has ' + paragraphs.items.length + ' paragraphs (valid indices: 0-' + (paragraphs.items.length - 1) + ').';
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
203
214
|
});
|
|
204
215
|
|
|
205
216
|
commands.insertParagraph = (p) => Word.run(async (ctx) => {
|
|
@@ -351,6 +362,7 @@ commands.search = (p) => Word.run(async (ctx) => {
|
|
|
351
362
|
|
|
352
363
|
commands.searchAndReplace = (p) => Word.run(async (ctx) => {
|
|
353
364
|
if (!p.find || typeof p.find !== 'string' || p.find.trim() === '') throw new Error('find string cannot be empty. Provide a non-empty search string.');
|
|
365
|
+
checkSearchLength(p.find);
|
|
354
366
|
const results = ctx.document.body.search(p.find, { matchCase: p.matchCase || false, matchWholeWord: p.matchWholeWord || false });
|
|
355
367
|
results.load('text');
|
|
356
368
|
await ctx.sync();
|
|
@@ -372,6 +384,7 @@ commands.insertText = (p) => Word.run(async (ctx) => {
|
|
|
372
384
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
373
385
|
const anchor = p.after || p.before;
|
|
374
386
|
if (!anchor) throw new Error('Either "after" or "before" anchor text must be provided');
|
|
387
|
+
checkSearchLength(anchor);
|
|
375
388
|
const results = ctx.document.body.search(anchor, { matchCase: p.matchCase || false });
|
|
376
389
|
results.load('text');
|
|
377
390
|
await ctx.sync();
|
|
@@ -393,6 +406,7 @@ commands.formatRange = (p) => Word.run(async (ctx) => {
|
|
|
393
406
|
if (p.size !== undefined && p.size <= 0) throw new Error('size must be positive');
|
|
394
407
|
if (p.size !== undefined && p.size > 1638) throw new Error('size must not exceed 1638 points (Word maximum)');
|
|
395
408
|
if (p.color && !/^#[0-9A-Fa-f]{6}$/.test(p.color)) throw new Error('color must be a valid hex color (e.g. #FF0000)');
|
|
409
|
+
checkSearchLength(p.text);
|
|
396
410
|
const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
|
|
397
411
|
results.load('font');
|
|
398
412
|
await ctx.sync();
|
|
@@ -541,6 +555,7 @@ commands.insertList = (p) => Word.run(async (ctx) => {
|
|
|
541
555
|
if (firstPara.isListItem) {
|
|
542
556
|
const sep = body.insertParagraph('', 'Start');
|
|
543
557
|
sep.style = 'Normal';
|
|
558
|
+
sep.detachFromList();
|
|
544
559
|
await ctx.sync();
|
|
545
560
|
}
|
|
546
561
|
}
|
|
@@ -569,6 +584,7 @@ commands.addComment = (p) => Word.run(async (ctx) => {
|
|
|
569
584
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
570
585
|
if (!p.comment || typeof p.comment !== 'string' || p.comment.trim() === '') throw new Error('comment text must be a non-empty string');
|
|
571
586
|
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
587
|
+
checkSearchLength(p.anchorText);
|
|
572
588
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
573
589
|
results.load('text');
|
|
574
590
|
await ctx.sync();
|
|
@@ -635,6 +651,7 @@ commands.deleteComment = (p) => Word.run(async (ctx) => {
|
|
|
635
651
|
commands.insertFootnote = (p) => Word.run(async (ctx) => {
|
|
636
652
|
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Footnote text must be a non-empty string');
|
|
637
653
|
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
654
|
+
checkSearchLength(p.anchorText);
|
|
638
655
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
639
656
|
results.load('text');
|
|
640
657
|
await ctx.sync();
|
|
@@ -651,6 +668,7 @@ commands.insertFootnote = (p) => Word.run(async (ctx) => {
|
|
|
651
668
|
commands.insertEndnote = (p) => Word.run(async (ctx) => {
|
|
652
669
|
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Endnote text must be a non-empty string');
|
|
653
670
|
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
671
|
+
checkSearchLength(p.anchorText);
|
|
654
672
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
655
673
|
results.load('text');
|
|
656
674
|
await ctx.sync();
|
|
@@ -876,6 +894,7 @@ commands.insertBookmark = (p) => Word.run(async (ctx) => {
|
|
|
876
894
|
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
877
895
|
if (!p.name || typeof p.name !== 'string' || p.name.trim() === '') throw new Error('Bookmark name must be a non-empty string.');
|
|
878
896
|
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).');
|
|
897
|
+
checkSearchLength(p.anchorText);
|
|
879
898
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
880
899
|
results.load('text');
|
|
881
900
|
// Check for existing bookmark with same name
|
|
@@ -1052,6 +1071,7 @@ commands.insertHyperlink = (p) => Word.run(async (ctx) => {
|
|
|
1052
1071
|
// Reject URLs containing characters that must be percent-encoded (RFC 3986 unsafe chars)
|
|
1053
1072
|
if (/[<>"{}|\\^`]/.test(p.url)) throw new Error('Malformed URL: "' + p.url + '". URL contains invalid characters that must be percent-encoded.');
|
|
1054
1073
|
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
1074
|
+
checkSearchLength(p.anchorText);
|
|
1055
1075
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1056
1076
|
results.load('text');
|
|
1057
1077
|
await ctx.sync();
|
|
@@ -1068,9 +1088,20 @@ commands.insertHyperlink = (p) => Word.run(async (ctx) => {
|
|
|
1068
1088
|
// == FIELDS ==
|
|
1069
1089
|
commands.getFields = () => Word.run(async (ctx) => {
|
|
1070
1090
|
const fields = ctx.document.body.fields;
|
|
1071
|
-
fields.load('code,
|
|
1091
|
+
fields.load('code,type');
|
|
1092
|
+
await ctx.sync();
|
|
1093
|
+
const items = [];
|
|
1094
|
+
for (let i = 0; i < fields.items.length; i++) {
|
|
1095
|
+
const f = fields.items[i];
|
|
1096
|
+
f.result.load('text');
|
|
1097
|
+
}
|
|
1072
1098
|
await ctx.sync();
|
|
1073
|
-
|
|
1099
|
+
for (let i = 0; i < fields.items.length; i++) {
|
|
1100
|
+
const f = fields.items[i];
|
|
1101
|
+
const code = (f.code || '').replace(/[\u0001\u0013\u0014\u0015]/g, '').trim();
|
|
1102
|
+
const resultText = (f.result.text || '').replace(/[\u0001\u0002\u0013\u0014\u0015]/g, '').trim();
|
|
1103
|
+
items.push({ index: i, code: code, result: resultText, type: f.type });
|
|
1104
|
+
}
|
|
1074
1105
|
return { count: items.length, fields: items };
|
|
1075
1106
|
});
|
|
1076
1107
|
|
|
@@ -1080,6 +1111,7 @@ commands.insertContentControl = (p) => Word.run(async (ctx) => {
|
|
|
1080
1111
|
let range;
|
|
1081
1112
|
if (p.anchorText) {
|
|
1082
1113
|
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.');
|
|
1114
|
+
checkSearchLength(p.anchorText);
|
|
1083
1115
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1084
1116
|
results.load('text');
|
|
1085
1117
|
await ctx.sync();
|
|
@@ -1143,6 +1175,7 @@ commands.getPageLayout = (p) => Word.run(async (ctx) => {
|
|
|
1143
1175
|
commands.getFontInfo = (p) => Word.run(async (ctx) => {
|
|
1144
1176
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
1145
1177
|
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('text cannot be empty. Provide a non-empty search string.');
|
|
1178
|
+
checkSearchLength(p.text);
|
|
1146
1179
|
const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
|
|
1147
1180
|
results.load('font');
|
|
1148
1181
|
await ctx.sync();
|
|
@@ -1195,6 +1228,10 @@ commands.insertHtml = (p) => Word.run(async (ctx) => {
|
|
|
1195
1228
|
});
|
|
1196
1229
|
|
|
1197
1230
|
commands.insertOoxml = (p) => Word.run(async (ctx) => {
|
|
1231
|
+
if (!p.ooxml || typeof p.ooxml !== 'string' || p.ooxml.trim() === '') throw new Error('ooxml parameter must be a non-empty string');
|
|
1232
|
+
if (!p.ooxml.includes('pkg:package') && !p.ooxml.includes('pkg:part')) {
|
|
1233
|
+
throw new Error('Invalid OOXML: missing pkg:package structure. The XML must be a valid Office Open XML flat package (containing pkg:package and pkg:part elements with a word/document.xml part).');
|
|
1234
|
+
}
|
|
1198
1235
|
const body = ctx.document.body;
|
|
1199
1236
|
const range = body.insertOoxml(p.ooxml, p.location || 'End');
|
|
1200
1237
|
range.getRange('End').select();
|
|
@@ -1292,9 +1329,11 @@ commands.insertParagraphAtIndex = (p) => Word.run(async (ctx) => {
|
|
|
1292
1329
|
}
|
|
1293
1330
|
}
|
|
1294
1331
|
const paragraphs = ctx.document.body.paragraphs;
|
|
1295
|
-
paragraphs.load('text');
|
|
1332
|
+
paragraphs.load('text,parentTableCellOrNullObject');
|
|
1296
1333
|
await ctx.sync();
|
|
1297
1334
|
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).');
|
|
1335
|
+
let inTable = false;
|
|
1336
|
+
try { inTable = paragraphs.items[p.index].parentTableCellOrNullObject && !paragraphs.items[p.index].parentTableCellOrNullObject.isNullObject; } catch (_e) { /* ignore */ }
|
|
1298
1337
|
// Validate style exists (if provided)
|
|
1299
1338
|
const styleName = p.style || 'Normal';
|
|
1300
1339
|
if (p.style) {
|
|
@@ -1317,7 +1356,11 @@ commands.insertParagraphAtIndex = (p) => Word.run(async (ctx) => {
|
|
|
1317
1356
|
}
|
|
1318
1357
|
newPara.getRange('End').select();
|
|
1319
1358
|
await ctx.sync();
|
|
1320
|
-
|
|
1359
|
+
const result = { success: true };
|
|
1360
|
+
if (inTable) {
|
|
1361
|
+
result.warning = 'Paragraph inserted inside a table cell. This creates a multi-paragraph cell which may not be intended.';
|
|
1362
|
+
}
|
|
1363
|
+
return result;
|
|
1321
1364
|
});
|
|
1322
1365
|
|
|
1323
1366
|
// == BOOKMARK NAVIGATION ==
|
|
@@ -1491,6 +1534,7 @@ commands.getChangeTrackingMode = () => Word.run(async (ctx) => {
|
|
|
1491
1534
|
commands.clearFormatting = (p) => Word.run(async (ctx) => {
|
|
1492
1535
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
1493
1536
|
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('text cannot be empty. Provide a non-empty search string.');
|
|
1537
|
+
checkSearchLength(p.text);
|
|
1494
1538
|
const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
|
|
1495
1539
|
results.load('font,style');
|
|
1496
1540
|
await ctx.sync();
|
|
@@ -1659,6 +1703,7 @@ commands.getHyperlinks = () => Word.run(async (ctx) => {
|
|
|
1659
1703
|
|
|
1660
1704
|
commands.removeHyperlink = (p) => Word.run(async (ctx) => {
|
|
1661
1705
|
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
1706
|
+
checkSearchLength(p.anchorText);
|
|
1662
1707
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1663
1708
|
results.load('hyperlink');
|
|
1664
1709
|
await ctx.sync();
|
|
@@ -1747,6 +1792,7 @@ commands.setListLevel = (p) => Word.run(async (ctx) => {
|
|
|
1747
1792
|
commands.insertLineBreak = (p) => Word.run(async (ctx) => {
|
|
1748
1793
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
1749
1794
|
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
1795
|
+
checkSearchLength(p.anchorText);
|
|
1750
1796
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1751
1797
|
results.load('text');
|
|
1752
1798
|
await ctx.sync();
|
|
@@ -1760,3 +1806,49 @@ commands.insertLineBreak = (p) => Word.run(async (ctx) => {
|
|
|
1760
1806
|
await ctx.sync();
|
|
1761
1807
|
return { success: true };
|
|
1762
1808
|
});
|
|
1809
|
+
|
|
1810
|
+
// == OOXML ROUND-TRIP (for move/copy paragraph with full fidelity) ==
|
|
1811
|
+
commands.getParaOoxml = (p) => Word.run(async (ctx) => {
|
|
1812
|
+
if (p.index < 0) throw new Error('Index must be non-negative');
|
|
1813
|
+
const count = p.count || 1;
|
|
1814
|
+
if (count < 1) throw new Error('count must be at least 1');
|
|
1815
|
+
const paragraphs = ctx.document.body.paragraphs;
|
|
1816
|
+
paragraphs.load('text');
|
|
1817
|
+
await ctx.sync();
|
|
1818
|
+
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).');
|
|
1819
|
+
if (p.index + count - 1 >= paragraphs.items.length) throw new Error('index + count (' + (p.index + count) + ') exceeds paragraph count (' + paragraphs.items.length + ').');
|
|
1820
|
+
let range;
|
|
1821
|
+
if (count === 1) {
|
|
1822
|
+
range = paragraphs.items[p.index].getRange('Whole');
|
|
1823
|
+
} else {
|
|
1824
|
+
const firstRange = paragraphs.items[p.index].getRange('Whole');
|
|
1825
|
+
const lastRange = paragraphs.items[p.index + count - 1].getRange('Whole');
|
|
1826
|
+
range = firstRange.expandTo(lastRange);
|
|
1827
|
+
}
|
|
1828
|
+
const ooxml = range.getOoxml();
|
|
1829
|
+
await ctx.sync();
|
|
1830
|
+
return { ooxml: ooxml.value };
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
commands.insertOoxmlAtIndex = (p) => Word.run(async (ctx) => {
|
|
1834
|
+
if (p.index < 0) throw new Error('Index must be non-negative');
|
|
1835
|
+
if (!p.ooxml || typeof p.ooxml !== 'string' || p.ooxml.trim() === '') throw new Error('ooxml parameter must be a non-empty string');
|
|
1836
|
+
const paragraphs = ctx.document.body.paragraphs;
|
|
1837
|
+
paragraphs.load('text');
|
|
1838
|
+
await ctx.sync();
|
|
1839
|
+
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).');
|
|
1840
|
+
const ref = paragraphs.items[p.index];
|
|
1841
|
+
const location = p.location === 'Before' ? 'Before' : 'After';
|
|
1842
|
+
const range = ref.getRange(location);
|
|
1843
|
+
const inserted = range.insertOoxml(p.ooxml, 'Replace');
|
|
1844
|
+
inserted.getRange('End').select();
|
|
1845
|
+
try {
|
|
1846
|
+
await ctx.sync();
|
|
1847
|
+
} catch (e) {
|
|
1848
|
+
if (e.message && e.message.includes('GeneralException')) {
|
|
1849
|
+
throw new Error('Invalid OOXML or cannot insert at paragraph ' + p.index + '. Ensure the XML follows the Office Open XML package structure.');
|
|
1850
|
+
}
|
|
1851
|
+
throw e;
|
|
1852
|
+
}
|
|
1853
|
+
return { success: true };
|
|
1854
|
+
});
|