mcp-word-bridge 3.5.1 → 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 +81 -34
- package/lib/tools.js +6 -5
- package/lib/usage-guide.js +7 -6
- package/manifest.xml +1 -1
- package/package.json +1 -1
- package/taskpane-app.js +241 -52
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
|
@@ -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.
|
|
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', () => {
|
|
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.
|
|
151
|
+
{ name: 'mcp-word-bridge', version: '3.5.3' },
|
|
138
152
|
{ capabilities: { tools: {}, resources: {} } }
|
|
139
153
|
);
|
|
140
154
|
|
|
@@ -163,16 +177,19 @@ async function executeTool(name, args) {
|
|
|
163
177
|
await sendToTaskpane('insertOoxml', { ooxml, location: args.location || 'End' });
|
|
164
178
|
} else if (args.anchorText) {
|
|
165
179
|
// Inline mode with anchor: search for anchor text, insert space+marker after it, insert equation at cursor, clean up marker
|
|
166
|
-
const marker = '\u200B\uFEFF'; //
|
|
180
|
+
const marker = '\u200B\uFEFF\u200B'; // three-char zero-width sequence — virtually impossible in real documents
|
|
167
181
|
const searchResult = await sendToTaskpane('search', { query: args.anchorText, matchCase: args.matchCase || false });
|
|
168
182
|
if (!searchResult || searchResult.count === 0) throw new Error('Anchor not found: ' + args.anchorText);
|
|
169
183
|
const occurrence = args.occurrence || 0;
|
|
170
184
|
if (occurrence >= searchResult.count) throw new Error('Occurrence ' + occurrence + ' not found (only ' + searchResult.count + ' match' + (searchResult.count === 1 ? '' : 'es') + ')');
|
|
171
185
|
// Insert space + marker to position cursor (space ensures equation doesn't glue to preceding text)
|
|
172
186
|
await sendToTaskpane('insertText', { text: ' ' + marker, after: args.anchorText, occurrence: occurrence, matchCase: args.matchCase || false });
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
}
|
|
176
193
|
} else {
|
|
177
194
|
await sendToTaskpane('insertOoxmlAtSelection', { ooxml });
|
|
178
195
|
}
|
|
@@ -185,18 +202,25 @@ async function executeTool(name, args) {
|
|
|
185
202
|
const result = await sendToTaskpane('getParagraphs', {});
|
|
186
203
|
const headings = [];
|
|
187
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"
|
|
188
209
|
const match = para.style && para.style.match(/^Heading (\d)$/i);
|
|
189
210
|
if (match) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 });
|
|
194
218
|
}
|
|
195
219
|
}
|
|
196
220
|
return { content: [{ type: 'text', text: JSON.stringify({ count: headings.length, outline: headings }, null, 2) }] };
|
|
197
221
|
}
|
|
198
222
|
|
|
199
|
-
// word_move_paragraph:
|
|
223
|
+
// word_move_paragraph: OOXML round-trip move (preserves all rich content: footnotes, hyperlinks, formatting, images, comments)
|
|
200
224
|
// Supports moving multiple consecutive paragraphs via optional 'count' parameter
|
|
201
225
|
if (name === 'word_move_paragraph') {
|
|
202
226
|
const fromIndex = args.fromIndex;
|
|
@@ -207,42 +231,65 @@ async function executeTool(name, args) {
|
|
|
207
231
|
if (toIndex < 0) throw new Error('toIndex must be non-negative');
|
|
208
232
|
if (count < 1) throw new Error('count must be at least 1');
|
|
209
233
|
if (fromIndex === toIndex && count === 1) throw new Error('fromIndex and toIndex must be different');
|
|
234
|
+
// Prevent overlapping moves that would cause data loss
|
|
235
|
+
if (toIndex >= fromIndex && toIndex < fromIndex + count) {
|
|
236
|
+
throw new Error('toIndex (' + toIndex + ') is inside the source range [' + fromIndex + ', ' + (fromIndex + count - 1) + ']. Move to a position outside the range being moved.');
|
|
237
|
+
}
|
|
210
238
|
// Validate all indices are in bounds before mutating
|
|
211
239
|
const paraCount = await sendToTaskpane('getParagraphs', {});
|
|
212
240
|
const total = paraCount.count;
|
|
213
241
|
if (fromIndex >= total) throw new Error('fromIndex ' + fromIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
|
|
214
242
|
if (fromIndex + count - 1 >= total) throw new Error('fromIndex + count (' + (fromIndex + count) + ') exceeds paragraph count (' + total + ').');
|
|
215
243
|
if (toIndex >= total) throw new Error('toIndex ' + toIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
|
|
216
|
-
//
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
collected.push({ text: details.text, style: details.style });
|
|
221
|
-
}
|
|
222
|
-
// 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)
|
|
223
248
|
for (let i = count - 1; i >= 0; i--) {
|
|
224
249
|
await sendToTaskpane('deleteParagraph', { index: fromIndex + i });
|
|
225
250
|
}
|
|
226
|
-
// Adjust destination index after deletions
|
|
251
|
+
// Step 3: Adjust destination index after deletions (same arithmetic as before)
|
|
227
252
|
let adjustedTo;
|
|
228
253
|
if (fromIndex < toIndex) {
|
|
229
254
|
adjustedTo = toIndex - count;
|
|
230
255
|
} else {
|
|
231
256
|
adjustedTo = toIndex;
|
|
232
257
|
}
|
|
233
|
-
// Insert
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
await sendToTaskpane('insertParagraphAtIndex', { index: prevInsertedAt, text: collected[i].text, style: collected[i].style, location: 'After' });
|
|
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.');
|
|
243
267
|
}
|
|
268
|
+
throw new Error('Move failed (content restored to original position): ' + insertErr.message);
|
|
244
269
|
}
|
|
245
|
-
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 } }) }] };
|
|
246
293
|
}
|
|
247
294
|
|
|
248
295
|
// word_batch: execute multiple operations in a single call
|
|
@@ -256,7 +303,7 @@ async function executeTool(name, args) {
|
|
|
256
303
|
}
|
|
257
304
|
|
|
258
305
|
// Partition into taskpane-native ops (have action mapping) vs server-composed ops
|
|
259
|
-
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']);
|
|
260
307
|
const results = [];
|
|
261
308
|
|
|
262
309
|
// Collect consecutive taskpane-native ops into batches, flush when hitting a server op
|
package/lib/tools.js
CHANGED
|
@@ -20,10 +20,11 @@ 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
|
-
{ 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
|
|
@@ -34,7 +35,7 @@ const tools = [
|
|
|
34
35
|
{ 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
36
|
{ 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
37
|
// 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'] } },
|
|
38
|
+
{ 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
39
|
{ 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
40
|
{ 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
41
|
// 5. TABLES
|
|
@@ -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.
|
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
|
@@ -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>
|
|
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
|
@@ -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://
|
|
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';
|
|
@@ -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) {
|
|
@@ -175,19 +182,35 @@ commands.getParagraphs = (p) => Word.run(async (ctx) => {
|
|
|
175
182
|
if (p.start !== undefined && p.start < 0) throw new Error('start index must be non-negative');
|
|
176
183
|
if (p.end !== undefined && p.end < 0) throw new Error('end index must be non-negative');
|
|
177
184
|
const paragraphs = ctx.document.body.paragraphs;
|
|
178
|
-
|
|
179
|
-
|
|
185
|
+
// Try loading all properties including parentTableCellOrNullObject
|
|
186
|
+
// If merged cells cause a failure, fall back to loading without it
|
|
187
|
+
let hasTableInfo = true;
|
|
188
|
+
try {
|
|
189
|
+
paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,outlineLevel,parentTableCellOrNullObject');
|
|
190
|
+
await ctx.sync();
|
|
191
|
+
} catch (_e) {
|
|
192
|
+
hasTableInfo = false;
|
|
193
|
+
paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,outlineLevel');
|
|
194
|
+
await ctx.sync();
|
|
195
|
+
}
|
|
180
196
|
const start = p.start !== undefined ? p.start : 0;
|
|
181
197
|
const end = p.end !== undefined ? p.end : paragraphs.items.length;
|
|
182
198
|
if (start > end) throw new Error('start (' + start + ') must be less than or equal to end (' + end + ')');
|
|
183
199
|
const items = [];
|
|
184
200
|
for (let i = start; i < Math.min(end, paragraphs.items.length); i++) {
|
|
185
201
|
const para = paragraphs.items[i];
|
|
186
|
-
|
|
202
|
+
let inTable = false;
|
|
203
|
+
if (hasTableInfo) {
|
|
204
|
+
try { inTable = para.parentTableCellOrNullObject && !para.parentTableCellOrNullObject.isNullObject; } catch (_e) { inTable = false; }
|
|
205
|
+
}
|
|
187
206
|
const isTocEntry = !!(para.style && para.style.startsWith('TOC')) || (para.style === '' && /\t\d+$/.test(para.text));
|
|
188
|
-
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 });
|
|
208
|
+
}
|
|
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) + ').';
|
|
189
212
|
}
|
|
190
|
-
return
|
|
213
|
+
return result;
|
|
191
214
|
});
|
|
192
215
|
|
|
193
216
|
commands.insertParagraph = (p) => Word.run(async (ctx) => {
|
|
@@ -253,8 +276,16 @@ commands.deleteParagraph = (p) => Word.run(async (ctx) => {
|
|
|
253
276
|
paragraphs.load('text');
|
|
254
277
|
await ctx.sync();
|
|
255
278
|
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).');
|
|
279
|
+
const countBefore = paragraphs.items.length;
|
|
256
280
|
paragraphs.items[p.index].delete();
|
|
257
|
-
|
|
281
|
+
try {
|
|
282
|
+
await ctx.sync();
|
|
283
|
+
} catch (e) {
|
|
284
|
+
if (e.message && e.message.includes('GeneralException')) {
|
|
285
|
+
throw new Error('Cannot delete paragraph ' + p.index + '. It may be a TOC field entry or inside a protected region.');
|
|
286
|
+
}
|
|
287
|
+
throw e;
|
|
288
|
+
}
|
|
258
289
|
// Move cursor to the paragraph that now occupies this position (or previous if at end)
|
|
259
290
|
const parasAfter = ctx.document.body.paragraphs;
|
|
260
291
|
parasAfter.load('text');
|
|
@@ -264,7 +295,11 @@ commands.deleteParagraph = (p) => Word.run(async (ctx) => {
|
|
|
264
295
|
parasAfter.items[cursorIdx].getRange('Start').select();
|
|
265
296
|
await ctx.sync();
|
|
266
297
|
}
|
|
267
|
-
|
|
298
|
+
const result = { success: true };
|
|
299
|
+
if (parasAfter.items.length >= countBefore) {
|
|
300
|
+
result.warning = 'Paragraph was cleared but not removed (Word requires at least one paragraph).';
|
|
301
|
+
}
|
|
302
|
+
return result;
|
|
268
303
|
});
|
|
269
304
|
|
|
270
305
|
commands.setParagraphStyle = (p) => Word.run(async (ctx) => {
|
|
@@ -326,6 +361,8 @@ commands.search = (p) => Word.run(async (ctx) => {
|
|
|
326
361
|
});
|
|
327
362
|
|
|
328
363
|
commands.searchAndReplace = (p) => Word.run(async (ctx) => {
|
|
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);
|
|
329
366
|
const results = ctx.document.body.search(p.find, { matchCase: p.matchCase || false, matchWholeWord: p.matchWholeWord || false });
|
|
330
367
|
results.load('text');
|
|
331
368
|
await ctx.sync();
|
|
@@ -347,6 +384,7 @@ commands.insertText = (p) => Word.run(async (ctx) => {
|
|
|
347
384
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
348
385
|
const anchor = p.after || p.before;
|
|
349
386
|
if (!anchor) throw new Error('Either "after" or "before" anchor text must be provided');
|
|
387
|
+
checkSearchLength(anchor);
|
|
350
388
|
const results = ctx.document.body.search(anchor, { matchCase: p.matchCase || false });
|
|
351
389
|
results.load('text');
|
|
352
390
|
await ctx.sync();
|
|
@@ -361,27 +399,14 @@ commands.insertText = (p) => Word.run(async (ctx) => {
|
|
|
361
399
|
return { success: true };
|
|
362
400
|
});
|
|
363
401
|
|
|
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
402
|
// == FORMATTING ==
|
|
380
403
|
commands.formatRange = (p) => Word.run(async (ctx) => {
|
|
381
404
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
405
|
+
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
406
|
if (p.size !== undefined && p.size <= 0) throw new Error('size must be positive');
|
|
383
407
|
if (p.size !== undefined && p.size > 1638) throw new Error('size must not exceed 1638 points (Word maximum)');
|
|
384
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);
|
|
385
410
|
const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
|
|
386
411
|
results.load('font');
|
|
387
412
|
await ctx.sync();
|
|
@@ -511,17 +536,30 @@ commands.deleteTableRow = (p) => Word.run(async (ctx) => {
|
|
|
511
536
|
commands.insertList = (p) => Word.run(async (ctx) => {
|
|
512
537
|
if (!p.items || p.items.length === 0) throw new Error('items array must not be empty');
|
|
513
538
|
const body = ctx.document.body;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
539
|
+
const location = p.location || 'End';
|
|
540
|
+
// Check if adjacent paragraph is a list item — if so, insert a non-list separator
|
|
541
|
+
if (location === 'End') {
|
|
542
|
+
const lastPara = body.paragraphs.getLast();
|
|
543
|
+
lastPara.load('isListItem');
|
|
544
|
+
await ctx.sync();
|
|
545
|
+
if (lastPara.isListItem) {
|
|
546
|
+
const sep = body.insertParagraph('', 'End');
|
|
547
|
+
sep.style = 'Normal';
|
|
548
|
+
sep.detachFromList();
|
|
549
|
+
await ctx.sync();
|
|
550
|
+
}
|
|
551
|
+
} else if (location === 'Start') {
|
|
552
|
+
const firstPara = body.paragraphs.getFirst();
|
|
553
|
+
firstPara.load('isListItem');
|
|
522
554
|
await ctx.sync();
|
|
555
|
+
if (firstPara.isListItem) {
|
|
556
|
+
const sep = body.insertParagraph('', 'Start');
|
|
557
|
+
sep.style = 'Normal';
|
|
558
|
+
sep.detachFromList();
|
|
559
|
+
await ctx.sync();
|
|
560
|
+
}
|
|
523
561
|
}
|
|
524
|
-
const para = body.insertParagraph(p.items[0],
|
|
562
|
+
const para = body.insertParagraph(p.items[0], location);
|
|
525
563
|
para.style = 'Normal';
|
|
526
564
|
await ctx.sync();
|
|
527
565
|
const list = para.startNewList();
|
|
@@ -545,6 +583,8 @@ commands.insertList = (p) => Word.run(async (ctx) => {
|
|
|
545
583
|
commands.addComment = (p) => Word.run(async (ctx) => {
|
|
546
584
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
547
585
|
if (!p.comment || typeof p.comment !== 'string' || p.comment.trim() === '') throw new Error('comment text must be a non-empty string');
|
|
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);
|
|
548
588
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
549
589
|
results.load('text');
|
|
550
590
|
await ctx.sync();
|
|
@@ -610,6 +650,8 @@ commands.deleteComment = (p) => Word.run(async (ctx) => {
|
|
|
610
650
|
// == FOOTNOTES & ENDNOTES ==
|
|
611
651
|
commands.insertFootnote = (p) => Word.run(async (ctx) => {
|
|
612
652
|
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Footnote 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.');
|
|
654
|
+
checkSearchLength(p.anchorText);
|
|
613
655
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
614
656
|
results.load('text');
|
|
615
657
|
await ctx.sync();
|
|
@@ -625,6 +667,8 @@ commands.insertFootnote = (p) => Word.run(async (ctx) => {
|
|
|
625
667
|
|
|
626
668
|
commands.insertEndnote = (p) => Word.run(async (ctx) => {
|
|
627
669
|
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Endnote text must be a non-empty string');
|
|
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);
|
|
628
672
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
629
673
|
results.load('text');
|
|
630
674
|
await ctx.sync();
|
|
@@ -847,6 +891,10 @@ commands.getBookmarks = () => Word.run(async (ctx) => {
|
|
|
847
891
|
|
|
848
892
|
commands.insertBookmark = (p) => Word.run(async (ctx) => {
|
|
849
893
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
894
|
+
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
895
|
+
if (!p.name || typeof p.name !== 'string' || p.name.trim() === '') throw new Error('Bookmark name must be a non-empty string.');
|
|
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);
|
|
850
898
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
851
899
|
results.load('text');
|
|
852
900
|
// Check for existing bookmark with same name
|
|
@@ -885,7 +933,8 @@ commands.getStyles = () => Word.run(async (ctx) => {
|
|
|
885
933
|
styles.load('nameLocal,type,builtIn');
|
|
886
934
|
await ctx.sync();
|
|
887
935
|
const items = styles.items.map(s => ({ name: s.nameLocal, type: s.type, builtIn: s.builtIn }));
|
|
888
|
-
|
|
936
|
+
const returned = items.slice(0, 80);
|
|
937
|
+
return { count: returned.length, total: items.length, styles: returned };
|
|
889
938
|
});
|
|
890
939
|
|
|
891
940
|
// == COAUTHORING (Desktop only) ==
|
|
@@ -976,7 +1025,14 @@ commands.insertPageBreak = (p) => Word.run(async (ctx) => {
|
|
|
976
1025
|
lastPara.insertBreak('Page', 'After');
|
|
977
1026
|
lastPara.getRange('End').select();
|
|
978
1027
|
}
|
|
979
|
-
|
|
1028
|
+
try {
|
|
1029
|
+
await ctx.sync();
|
|
1030
|
+
} catch (e) {
|
|
1031
|
+
if (e.message && e.message.includes('GeneralException')) {
|
|
1032
|
+
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).');
|
|
1033
|
+
}
|
|
1034
|
+
throw e;
|
|
1035
|
+
}
|
|
980
1036
|
return { success: true };
|
|
981
1037
|
});
|
|
982
1038
|
|
|
@@ -1014,11 +1070,15 @@ commands.insertHyperlink = (p) => Word.run(async (ctx) => {
|
|
|
1014
1070
|
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
1071
|
// Reject URLs containing characters that must be percent-encoded (RFC 3986 unsafe chars)
|
|
1016
1072
|
if (/[<>"{}|\\^`]/.test(p.url)) throw new Error('Malformed URL: "' + p.url + '". URL contains invalid characters that must be percent-encoded.');
|
|
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);
|
|
1017
1075
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1018
1076
|
results.load('text');
|
|
1019
1077
|
await ctx.sync();
|
|
1020
1078
|
if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
|
|
1021
|
-
const
|
|
1079
|
+
const idx = p.occurrence || 0;
|
|
1080
|
+
if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
|
|
1081
|
+
const target = results.items[idx];
|
|
1022
1082
|
target.hyperlink = p.url;
|
|
1023
1083
|
target.getRange('End').select();
|
|
1024
1084
|
await ctx.sync();
|
|
@@ -1028,9 +1088,20 @@ commands.insertHyperlink = (p) => Word.run(async (ctx) => {
|
|
|
1028
1088
|
// == FIELDS ==
|
|
1029
1089
|
commands.getFields = () => Word.run(async (ctx) => {
|
|
1030
1090
|
const fields = ctx.document.body.fields;
|
|
1031
|
-
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
|
+
}
|
|
1032
1098
|
await ctx.sync();
|
|
1033
|
-
|
|
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
|
+
}
|
|
1034
1105
|
return { count: items.length, fields: items };
|
|
1035
1106
|
});
|
|
1036
1107
|
|
|
@@ -1039,11 +1110,15 @@ commands.insertContentControl = (p) => Word.run(async (ctx) => {
|
|
|
1039
1110
|
const ccType = p.type || 'RichText';
|
|
1040
1111
|
let range;
|
|
1041
1112
|
if (p.anchorText) {
|
|
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);
|
|
1042
1115
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1043
1116
|
results.load('text');
|
|
1044
1117
|
await ctx.sync();
|
|
1045
1118
|
if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
|
|
1046
|
-
|
|
1119
|
+
const idx = p.occurrence || 0;
|
|
1120
|
+
if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
|
|
1121
|
+
range = results.items[idx];
|
|
1047
1122
|
} else {
|
|
1048
1123
|
range = ctx.document.getSelection();
|
|
1049
1124
|
}
|
|
@@ -1099,6 +1174,8 @@ commands.getPageLayout = (p) => Word.run(async (ctx) => {
|
|
|
1099
1174
|
// == FONT INFO ==
|
|
1100
1175
|
commands.getFontInfo = (p) => Word.run(async (ctx) => {
|
|
1101
1176
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
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);
|
|
1102
1179
|
const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
|
|
1103
1180
|
results.load('font');
|
|
1104
1181
|
await ctx.sync();
|
|
@@ -1151,6 +1228,10 @@ commands.insertHtml = (p) => Word.run(async (ctx) => {
|
|
|
1151
1228
|
});
|
|
1152
1229
|
|
|
1153
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
|
+
}
|
|
1154
1235
|
const body = ctx.document.body;
|
|
1155
1236
|
const range = body.insertOoxml(p.ooxml, p.location || 'End');
|
|
1156
1237
|
range.getRange('End').select();
|
|
@@ -1225,23 +1306,61 @@ commands.replaceParagraphText = (p) => Word.run(async (ctx) => {
|
|
|
1225
1306
|
const para = paragraphs.items[p.index];
|
|
1226
1307
|
const inserted = para.insertText(p.text, Word.InsertLocation.replace);
|
|
1227
1308
|
inserted.getRange('End').select();
|
|
1228
|
-
|
|
1309
|
+
try {
|
|
1310
|
+
await ctx.sync();
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
if (e.message && e.message.includes('GeneralException')) {
|
|
1313
|
+
throw new Error('Cannot replace text of paragraph ' + p.index + '. It may be a TOC field entry or inside a protected region.');
|
|
1314
|
+
}
|
|
1315
|
+
throw e;
|
|
1316
|
+
}
|
|
1229
1317
|
return { success: true };
|
|
1230
1318
|
});
|
|
1231
1319
|
|
|
1232
1320
|
commands.insertParagraphAtIndex = (p) => Word.run(async (ctx) => {
|
|
1233
1321
|
if (p.index < 0) throw new Error('Index must be non-negative');
|
|
1322
|
+
// Normalize and validate alignment
|
|
1323
|
+
const ALIGNMENT_MAP = { 'Left': 'Left', 'Center': 'Centered', 'Centered': 'Centered', 'Right': 'Right', 'Justify': 'Justified', 'Justified': 'Justified' };
|
|
1324
|
+
let alignment = null;
|
|
1325
|
+
if (p.alignment) {
|
|
1326
|
+
alignment = ALIGNMENT_MAP[p.alignment];
|
|
1327
|
+
if (!alignment) {
|
|
1328
|
+
throw new Error('Invalid alignment: "' + p.alignment + '". Valid values: Left, Center, Right, Justified');
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1234
1331
|
const paragraphs = ctx.document.body.paragraphs;
|
|
1235
|
-
paragraphs.load('text');
|
|
1332
|
+
paragraphs.load('text,parentTableCellOrNullObject');
|
|
1236
1333
|
await ctx.sync();
|
|
1237
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 */ }
|
|
1337
|
+
// Validate style exists (if provided)
|
|
1338
|
+
const styleName = p.style || 'Normal';
|
|
1339
|
+
if (p.style) {
|
|
1340
|
+
const styleObj = ctx.document.getStyles().getByNameOrNullObject(p.style);
|
|
1341
|
+
styleObj.load('nameLocal');
|
|
1342
|
+
await ctx.sync();
|
|
1343
|
+
if (styleObj.isNullObject) {
|
|
1344
|
+
throw new Error('Style not found: "' + p.style + '". Use word_get_styles to see available styles.');
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1238
1347
|
const ref = paragraphs.items[p.index];
|
|
1239
1348
|
const location = p.location === 'Before' ? Word.InsertLocation.before : Word.InsertLocation.after;
|
|
1240
1349
|
const newPara = ref.insertParagraph(p.text, location);
|
|
1241
|
-
newPara.style =
|
|
1350
|
+
newPara.style = styleName;
|
|
1351
|
+
await ctx.sync();
|
|
1352
|
+
// Apply alignment in a separate sync
|
|
1353
|
+
if (alignment) {
|
|
1354
|
+
newPara.alignment = alignment;
|
|
1355
|
+
await ctx.sync();
|
|
1356
|
+
}
|
|
1242
1357
|
newPara.getRange('End').select();
|
|
1243
1358
|
await ctx.sync();
|
|
1244
|
-
|
|
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;
|
|
1245
1364
|
});
|
|
1246
1365
|
|
|
1247
1366
|
// == BOOKMARK NAVIGATION ==
|
|
@@ -1318,13 +1437,23 @@ commands.mergeTableCells = (p) => Word.run(async (ctx) => {
|
|
|
1318
1437
|
commands.splitTableCell = (p) => Word.run(async (ctx) => {
|
|
1319
1438
|
if (p.rowCount !== undefined && p.rowCount <= 0) throw new Error('rowCount must be a positive integer (minimum 1)');
|
|
1320
1439
|
if (p.colCount !== undefined && p.colCount <= 0) throw new Error('colCount must be a positive integer (minimum 1)');
|
|
1440
|
+
if (p.tableIndex < 0) throw new Error('Table index must be non-negative');
|
|
1441
|
+
if (p.row < 0) throw new Error('Row index must be non-negative');
|
|
1442
|
+
if (p.col < 0) throw new Error('Column index must be non-negative');
|
|
1321
1443
|
const tables = ctx.document.body.tables;
|
|
1322
1444
|
tables.load('rowCount');
|
|
1323
1445
|
await ctx.sync();
|
|
1324
|
-
if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1446
|
+
if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range. Document has ' + tables.items.length + ' table(s).');
|
|
1447
|
+
try {
|
|
1448
|
+
const cell = tables.items[p.tableIndex].getCell(p.row, p.col);
|
|
1449
|
+
cell.split(p.rowCount || 1, p.colCount || 2);
|
|
1450
|
+
await ctx.sync();
|
|
1451
|
+
} catch (e) {
|
|
1452
|
+
if (e.message && e.message.includes('ItemNotFound')) {
|
|
1453
|
+
throw new Error('Cell not found at row ' + p.row + ', col ' + p.col + '. Use word_get_table_data to inspect the table.');
|
|
1454
|
+
}
|
|
1455
|
+
throw e;
|
|
1456
|
+
}
|
|
1328
1457
|
return { success: true };
|
|
1329
1458
|
});
|
|
1330
1459
|
|
|
@@ -1404,6 +1533,8 @@ commands.getChangeTrackingMode = () => Word.run(async (ctx) => {
|
|
|
1404
1533
|
// == CLEAR FORMATTING ==
|
|
1405
1534
|
commands.clearFormatting = (p) => Word.run(async (ctx) => {
|
|
1406
1535
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
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);
|
|
1407
1538
|
const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
|
|
1408
1539
|
results.load('font,style');
|
|
1409
1540
|
await ctx.sync();
|
|
@@ -1447,7 +1578,9 @@ commands.getPageInfo = async () => {
|
|
|
1447
1578
|
allParas.load('text,style');
|
|
1448
1579
|
await ctx.sync();
|
|
1449
1580
|
const pageDetails = [];
|
|
1450
|
-
|
|
1581
|
+
// Forward-only cursor: pages are sequential, so each page's paragraphs
|
|
1582
|
+
// must start at or after the previous page's last matched index.
|
|
1583
|
+
let nextStart = 0;
|
|
1451
1584
|
for (let i = 0; i < pages.items.length; i++) {
|
|
1452
1585
|
const page = pages.items[i];
|
|
1453
1586
|
page.load('index,height,width');
|
|
@@ -1458,11 +1591,11 @@ commands.getPageInfo = async () => {
|
|
|
1458
1591
|
let lastIdx = -1;
|
|
1459
1592
|
for (let j = 0; j < paras.items.length; j++) {
|
|
1460
1593
|
const p = paras.items[j];
|
|
1461
|
-
for (let k =
|
|
1462
|
-
if (
|
|
1463
|
-
usedIndices.add(k);
|
|
1594
|
+
for (let k = nextStart; k < allParas.items.length; k++) {
|
|
1595
|
+
if (allParas.items[k].text === p.text && allParas.items[k].style === p.style) {
|
|
1464
1596
|
if (firstIdx === -1) firstIdx = k;
|
|
1465
1597
|
lastIdx = k;
|
|
1598
|
+
nextStart = k + 1;
|
|
1466
1599
|
break;
|
|
1467
1600
|
}
|
|
1468
1601
|
}
|
|
@@ -1530,6 +1663,7 @@ commands.deleteImage = (p) => Word.run(async (ctx) => {
|
|
|
1530
1663
|
|
|
1531
1664
|
// == TABLE CELL SHADING ==
|
|
1532
1665
|
commands.setTableCellShading = (p) => Word.run(async (ctx) => {
|
|
1666
|
+
if (!/^#[0-9A-Fa-f]{6}$/.test(p.color)) throw new Error('color must be a valid hex color (e.g. #FFD700)');
|
|
1533
1667
|
const tables = ctx.document.body.tables;
|
|
1534
1668
|
tables.load('rowCount');
|
|
1535
1669
|
await ctx.sync();
|
|
@@ -1568,11 +1702,15 @@ commands.getHyperlinks = () => Word.run(async (ctx) => {
|
|
|
1568
1702
|
});
|
|
1569
1703
|
|
|
1570
1704
|
commands.removeHyperlink = (p) => Word.run(async (ctx) => {
|
|
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);
|
|
1571
1707
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1572
1708
|
results.load('hyperlink');
|
|
1573
1709
|
await ctx.sync();
|
|
1574
1710
|
if (results.items.length === 0) throw new Error('Text not found: ' + p.anchorText);
|
|
1575
|
-
const
|
|
1711
|
+
const idx = p.occurrence || 0;
|
|
1712
|
+
if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
|
|
1713
|
+
const target = results.items[idx];
|
|
1576
1714
|
target.hyperlink = '';
|
|
1577
1715
|
target.getRange('End').select();
|
|
1578
1716
|
await ctx.sync();
|
|
@@ -1652,14 +1790,65 @@ commands.setListLevel = (p) => Word.run(async (ctx) => {
|
|
|
1652
1790
|
|
|
1653
1791
|
// == LINE BREAK ==
|
|
1654
1792
|
commands.insertLineBreak = (p) => Word.run(async (ctx) => {
|
|
1793
|
+
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
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);
|
|
1655
1796
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1656
1797
|
results.load('text');
|
|
1657
1798
|
await ctx.sync();
|
|
1658
1799
|
if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
|
|
1659
|
-
const
|
|
1800
|
+
const idx = p.occurrence || 0;
|
|
1801
|
+
if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
|
|
1802
|
+
const target = results.items[idx];
|
|
1660
1803
|
const loc = p.before ? Word.InsertLocation.before : Word.InsertLocation.after;
|
|
1661
1804
|
target.insertBreak('Line', loc);
|
|
1662
1805
|
target.getRange('End').select();
|
|
1663
1806
|
await ctx.sync();
|
|
1664
1807
|
return { success: true };
|
|
1665
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
|
+
});
|