mcp-word-bridge 3.5.0 → 3.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/index.js +48 -8
- package/lib/tools.js +4 -2
- package/lib/usage-guide.js +4 -4
- package/manifest.xml +1 -1
- package/package.json +1 -1
- package/taskpane-app.js +188 -42
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ mcp-word-bridge/
|
|
|
80
80
|
└── README.md
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
## Tools (
|
|
83
|
+
## Tools (91)
|
|
84
84
|
|
|
85
85
|
### Document
|
|
86
86
|
| Tool | Description |
|
|
@@ -222,6 +222,7 @@ mcp-word-bridge/
|
|
|
222
222
|
| `word_get_sections` | List all sections with page setup |
|
|
223
223
|
| `word_insert_page_break` | Insert a page break after a paragraph |
|
|
224
224
|
| `word_insert_section_break` | Insert a section break after a paragraph |
|
|
225
|
+
| `word_get_page_info` | Get page count and per-page paragraphs with document indices (Desktop only) |
|
|
225
226
|
|
|
226
227
|
### Custom Properties
|
|
227
228
|
| Tool | Description |
|
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.2' },
|
|
138
152
|
{ capabilities: { tools: {}, resources: {} } }
|
|
139
153
|
);
|
|
140
154
|
|
|
@@ -207,17 +221,26 @@ async function executeTool(name, args) {
|
|
|
207
221
|
if (toIndex < 0) throw new Error('toIndex must be non-negative');
|
|
208
222
|
if (count < 1) throw new Error('count must be at least 1');
|
|
209
223
|
if (fromIndex === toIndex && count === 1) throw new Error('fromIndex and toIndex must be different');
|
|
224
|
+
// Prevent overlapping moves that would cause data loss
|
|
225
|
+
if (toIndex >= fromIndex && toIndex < fromIndex + count) {
|
|
226
|
+
throw new Error('toIndex (' + toIndex + ') is inside the source range [' + fromIndex + ', ' + (fromIndex + count - 1) + ']. Move to a position outside the range being moved.');
|
|
227
|
+
}
|
|
210
228
|
// Validate all indices are in bounds before mutating
|
|
211
229
|
const paraCount = await sendToTaskpane('getParagraphs', {});
|
|
212
230
|
const total = paraCount.count;
|
|
213
231
|
if (fromIndex >= total) throw new Error('fromIndex ' + fromIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
|
|
214
232
|
if (fromIndex + count - 1 >= total) throw new Error('fromIndex + count (' + (fromIndex + count) + ') exceeds paragraph count (' + total + ').');
|
|
215
233
|
if (toIndex >= total) throw new Error('toIndex ' + toIndex + ' out of range. Document has ' + total + ' paragraphs (0-indexed).');
|
|
216
|
-
// Collect all paragraphs to move (text + style)
|
|
234
|
+
// Collect all paragraphs to move (text + style + formatting)
|
|
217
235
|
const collected = [];
|
|
218
236
|
for (let i = 0; i < count; i++) {
|
|
219
237
|
const details = await sendToTaskpane('getParagraphByIndex', { index: fromIndex + i });
|
|
220
|
-
collected.push({
|
|
238
|
+
collected.push({
|
|
239
|
+
text: details.text, style: details.style, alignment: details.alignment,
|
|
240
|
+
firstLineIndent: details.firstLineIndent, leftIndent: details.leftIndent,
|
|
241
|
+
rightIndent: details.rightIndent, lineSpacing: details.lineSpacing,
|
|
242
|
+
spaceBefore: details.spaceBefore, spaceAfter: details.spaceAfter
|
|
243
|
+
});
|
|
221
244
|
}
|
|
222
245
|
// Delete from last to first to preserve indices during deletion
|
|
223
246
|
for (let i = count - 1; i >= 0; i--) {
|
|
@@ -230,16 +253,33 @@ async function executeTool(name, args) {
|
|
|
230
253
|
} else {
|
|
231
254
|
adjustedTo = toIndex;
|
|
232
255
|
}
|
|
233
|
-
// Insert in order at destination
|
|
256
|
+
// Insert in order at destination, then restore formatting
|
|
234
257
|
for (let i = 0; i < collected.length; i++) {
|
|
258
|
+
let insertedAt;
|
|
235
259
|
if (i === 0) {
|
|
236
260
|
await sendToTaskpane('insertParagraphAtIndex', { index: adjustedTo, text: collected[i].text, style: collected[i].style, location: location });
|
|
261
|
+
insertedAt = location === 'Before' ? adjustedTo : adjustedTo + 1;
|
|
237
262
|
} else {
|
|
238
263
|
// After the first insert, the paragraph we just inserted is at a known position.
|
|
239
264
|
// For 'Before': first para went to adjustedTo, so second goes After adjustedTo.
|
|
240
265
|
// For 'After': first para went to adjustedTo+1, so second goes After adjustedTo+1.
|
|
241
266
|
const prevInsertedAt = location === 'Before' ? adjustedTo + (i - 1) : adjustedTo + i;
|
|
242
267
|
await sendToTaskpane('insertParagraphAtIndex', { index: prevInsertedAt, text: collected[i].text, style: collected[i].style, location: 'After' });
|
|
268
|
+
insertedAt = prevInsertedAt + 1;
|
|
269
|
+
}
|
|
270
|
+
// Restore alignment if non-default
|
|
271
|
+
const c = collected[i];
|
|
272
|
+
if (c.alignment && c.alignment !== 'Left') {
|
|
273
|
+
await sendToTaskpane('setParagraphStyle', { index: insertedAt, alignment: c.alignment });
|
|
274
|
+
}
|
|
275
|
+
// Restore spacing/indentation if non-default
|
|
276
|
+
const spacingArgs = { index: insertedAt };
|
|
277
|
+
let hasSpacing = false;
|
|
278
|
+
if (c.firstLineIndent && c.firstLineIndent !== 0) { spacingArgs.firstLineIndent = c.firstLineIndent; hasSpacing = true; }
|
|
279
|
+
if (c.leftIndent && c.leftIndent !== 0) { spacingArgs.leftIndent = c.leftIndent; hasSpacing = true; }
|
|
280
|
+
if (c.rightIndent && c.rightIndent !== 0) { spacingArgs.rightIndent = c.rightIndent; hasSpacing = true; }
|
|
281
|
+
if (hasSpacing) {
|
|
282
|
+
await sendToTaskpane('setParagraphSpacing', spacingArgs);
|
|
243
283
|
}
|
|
244
284
|
}
|
|
245
285
|
return { content: [{ type: 'text', text: JSON.stringify({ success: true, moved: { from: fromIndex, count, to: adjustedTo, location } }) }] };
|
package/lib/tools.js
CHANGED
|
@@ -20,7 +20,7 @@ const tools = [
|
|
|
20
20
|
{ name: 'word_get_paragraphs', description: '[Paragraphs] Get paragraphs with text, style, alignment, and isTocEntry flag. Supports pagination via optional start/end index range (0-based). Returns total paragraph count.', inputSchema: { type: 'object', properties: { start: { type: 'number', description: 'First paragraph index to return (0-based, inclusive)' }, end: { type: 'number', description: 'Last paragraph index (exclusive). Omit to get all from start.' } } } },
|
|
21
21
|
{ name: 'word_get_paragraph_by_index', description: '[Paragraphs] Get full details of a single paragraph including font, spacing, indentation, and outline level.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' } }, required: ['index'] } },
|
|
22
22
|
{ name: 'word_insert_paragraph', description: '[Paragraphs] Append or prepend a styled paragraph to the document (Start or End only). For inserting at a specific position, use word_insert_paragraph_at_index instead.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, location: { type: 'string', enum: ['Start', 'End'], description: 'Where in the document. Default: End' }, style: { type: 'string', description: 'Paragraph style (e.g. "Heading 1", "Normal"). Default: Normal' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['text'] } },
|
|
23
|
-
{ name: 'word_insert_paragraph_at_index', description: '[Paragraphs] Insert a new paragraph Before or After a specific paragraph index. Use this for precise positioning within the document (preferred over word_insert_paragraph for mid-document insertions).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Reference paragraph index (0-based)' }, text: { type: 'string', description: 'Text content for the new paragraph' }, location: { type: 'string', enum: ['Before', 'After'], description: 'Insert before or after the reference paragraph. Default: After' }, style: { type: 'string', description: 'Paragraph style (e.g. "Heading 1", "Normal")' } }, required: ['index', 'text'] } },
|
|
23
|
+
{ name: 'word_insert_paragraph_at_index', description: '[Paragraphs] Insert a new paragraph Before or After a specific paragraph index. Use this for precise positioning within the document (preferred over word_insert_paragraph for mid-document insertions).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Reference paragraph index (0-based)' }, text: { type: 'string', description: 'Text content for the new paragraph' }, location: { type: 'string', enum: ['Before', 'After'], description: 'Insert before or after the reference paragraph. Default: After' }, style: { type: 'string', description: 'Paragraph style (e.g. "Heading 1", "Normal")' }, alignment: { type: 'string', description: 'Paragraph alignment: Left, Center, Right, or Justified' } }, required: ['index', 'text'] } },
|
|
24
24
|
{ name: 'word_delete_paragraph', description: '[Paragraphs] Delete a paragraph by its 0-based index.', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' } }, required: ['index'] } },
|
|
25
25
|
{ name: 'word_replace_paragraph_text', description: '[Paragraphs] Replace the entire text of a paragraph by index. Preserves style/formatting. Preferred over word_search_and_replace in collaborative editing (targets by position, immune to text-drift).', inputSchema: { type: 'object', properties: { index: { type: 'number', description: 'Paragraph index (0-based)' }, text: { type: 'string', description: 'New text content for the paragraph' } }, required: ['index', 'text'] } },
|
|
26
26
|
{ name: 'word_move_paragraph', description: '[Paragraphs] Move a paragraph from one position to another. Handles the delete+insert atomically with correct index adjustment.', inputSchema: { type: 'object', properties: { fromIndex: { type: 'number', description: 'Source paragraph index (0-based)' }, toIndex: { type: 'number', description: 'Destination paragraph index (0-based, position after removal)' }, location: { type: 'string', enum: ['Before', 'After'], description: 'Insert before or after the destination index. Default: After' }, count: { type: 'number', description: 'Number of consecutive paragraphs to move (default: 1). Use to move a heading + its body together.' } }, required: ['fromIndex', 'toIndex'] } },
|
|
@@ -34,7 +34,7 @@ const tools = [
|
|
|
34
34
|
{ name: 'word_insert_text_at_selection', description: '[Search] Insert text at the current cursor position, or replace the current selection (set replace=true). Cursor moves to end of inserted text.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, replace: { type: 'boolean', description: 'Replace current selection instead of appending. Default: false' } }, required: ['text'] } },
|
|
35
35
|
{ name: 'word_insert_line_break', description: '[Search] Insert a soft line break (Shift+Enter) before or after a text match.', inputSchema: { type: 'object', properties: { anchorText: { type: 'string', description: 'Text to search for as anchor point' }, before: { type: 'boolean', description: 'Insert before the match (default: after)' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['anchorText'] } },
|
|
36
36
|
// 4. FORMATTING
|
|
37
|
-
{ name: 'word_format_text', description: '[Formatting] Apply formatting (bold, italic, color, size, font) to a text match found by search. Color must be hex (#FF0000). Size: 1-1638pt.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for and format' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, bold: { type: 'boolean' }, italic: { type: 'boolean' }, underline: { type: 'boolean' }, strikeThrough: { type: 'boolean' }, color: { type: 'string', description: 'Hex color e.g. #FF0000' }, highlightColor: { type: 'string' }, size: { type: 'number', description: 'Font size in points (1-1638)' }, name: { type: 'string', description: 'Font name' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
|
|
37
|
+
{ name: 'word_format_text', description: '[Formatting] Apply formatting (bold, italic, color, size, font) to a text match found by search. Color must be hex (#FF0000). Size: 1-1638pt.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for and format' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, bold: { type: 'boolean' }, italic: { type: 'boolean' }, underline: { type: 'boolean' }, strikeThrough: { type: 'boolean' }, color: { type: 'string', description: 'Hex color e.g. #FF0000' }, highlightColor: { type: 'string', description: 'Highlight color name (Yellow, Green, Cyan, Magenta, Blue, Red, DarkBlue, DarkCyan, DarkGreen, DarkMagenta, DarkRed, DarkYellow, Gray25, Gray50, Black, White) or hex.' }, size: { type: 'number', description: 'Font size in points (1-1638)' }, name: { type: 'string', description: 'Font name' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
|
|
38
38
|
{ name: 'word_clear_formatting', description: '[Formatting] Clear direct formatting from a text match, reverting it to the paragraph style defaults.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
|
|
39
39
|
{ name: 'word_get_font_info', description: '[Formatting] Inspect font properties (name, size, bold, italic, color) of a text match.', inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to search for' }, occurrence: { type: 'number', description: 'Which match to target: 0=first, 1=second, etc. Default: 0' }, matchCase: { type: 'boolean', description: 'Case-sensitive matching. Default: false' } }, required: ['text'] } },
|
|
40
40
|
// 5. TABLES
|
|
@@ -100,6 +100,7 @@ const tools = [
|
|
|
100
100
|
{ name: 'word_get_sections', description: '[Layout] List all sections with their page setup (margins, orientation, paper size).', inputSchema: { type: 'object', properties: {} } },
|
|
101
101
|
{ name: 'word_insert_page_break', description: '[Layout] Insert a page break after a paragraph. Omit paragraphIndex to insert at end of document.', inputSchema: { type: 'object', properties: { paragraphIndex: { type: 'number', description: 'Paragraph index (0-based). Omit for end of document.' } } } },
|
|
102
102
|
{ name: 'word_insert_section_break', description: '[Layout] Insert a section break after a paragraph. Cannot be used inside table cells.', inputSchema: { type: 'object', properties: { paragraphIndex: { type: 'number', description: 'Paragraph index (0-based). Omit for end of document.' }, breakType: { type: 'string', enum: ['SectionNext', 'SectionContinuous', 'SectionEven', 'SectionOdd'], description: 'Default: SectionNext' } } } },
|
|
103
|
+
{ name: 'word_get_page_info', description: '[Layout] Get page count and per-page paragraph ranges. Returns page dimensions and the first/last document paragraph index for each page. Use with word_get_paragraphs to read specific page content.', inputSchema: { type: 'object', properties: {} } },
|
|
103
104
|
// 16. CUSTOM PROPERTIES
|
|
104
105
|
{ name: 'word_get_custom_properties', description: '[Properties] Get all custom document properties (key-value pairs with types).', inputSchema: { type: 'object', properties: {} } },
|
|
105
106
|
{ name: 'word_set_custom_property', description: '[Properties] Set a custom document property. Creates or updates the key-value pair.', inputSchema: { type: 'object', properties: { key: { type: 'string' }, value: { type: 'string' } }, required: ['key', 'value'] } },
|
|
@@ -161,6 +162,7 @@ const toolActionMap = {
|
|
|
161
162
|
word_delete_custom_property: 'deleteCustomProperty',
|
|
162
163
|
word_insert_html: 'insertHtml', word_insert_ooxml: 'insertOoxml',
|
|
163
164
|
word_insert_table_of_contents: 'insertTableOfContents', word_get_fields: 'getFields',
|
|
165
|
+
word_get_page_info: 'getPageInfo',
|
|
164
166
|
// word_insert_equation, word_batch, word_get_document_outline, word_move_paragraph
|
|
165
167
|
// are handled directly in index.js (not via taskpane action map)
|
|
166
168
|
};
|
package/lib/usage-guide.js
CHANGED
|
@@ -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
|
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.2</Version>
|
|
7
7
|
<ProviderName>Leonid Mokrushin</ProviderName>
|
|
8
8
|
<DefaultLocale>en-US</DefaultLocale>
|
|
9
9
|
<DisplayName DefaultValue="MCP Word Bridge"/>
|
package/package.json
CHANGED
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';
|
|
@@ -175,15 +175,27 @@ commands.getParagraphs = (p) => Word.run(async (ctx) => {
|
|
|
175
175
|
if (p.start !== undefined && p.start < 0) throw new Error('start index must be non-negative');
|
|
176
176
|
if (p.end !== undefined && p.end < 0) throw new Error('end index must be non-negative');
|
|
177
177
|
const paragraphs = ctx.document.body.paragraphs;
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
// Try loading all properties including parentTableCellOrNullObject
|
|
179
|
+
// If merged cells cause a failure, fall back to loading without it
|
|
180
|
+
let hasTableInfo = true;
|
|
181
|
+
try {
|
|
182
|
+
paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem,parentTableCellOrNullObject');
|
|
183
|
+
await ctx.sync();
|
|
184
|
+
} catch (_e) {
|
|
185
|
+
hasTableInfo = false;
|
|
186
|
+
paragraphs.load('text,style,alignment,firstLineIndent,leftIndent,lineSpacing,isListItem');
|
|
187
|
+
await ctx.sync();
|
|
188
|
+
}
|
|
180
189
|
const start = p.start !== undefined ? p.start : 0;
|
|
181
190
|
const end = p.end !== undefined ? p.end : paragraphs.items.length;
|
|
182
191
|
if (start > end) throw new Error('start (' + start + ') must be less than or equal to end (' + end + ')');
|
|
183
192
|
const items = [];
|
|
184
193
|
for (let i = start; i < Math.min(end, paragraphs.items.length); i++) {
|
|
185
194
|
const para = paragraphs.items[i];
|
|
186
|
-
|
|
195
|
+
let inTable = false;
|
|
196
|
+
if (hasTableInfo) {
|
|
197
|
+
try { inTable = para.parentTableCellOrNullObject && !para.parentTableCellOrNullObject.isNullObject; } catch (_e) { inTable = false; }
|
|
198
|
+
}
|
|
187
199
|
const isTocEntry = !!(para.style && para.style.startsWith('TOC')) || (para.style === '' && /\t\d+$/.test(para.text));
|
|
188
200
|
items.push({ index: i, text: para.text, style: para.style, alignment: para.alignment, isListItem: para.isListItem, inTable: inTable, isTocEntry: isTocEntry });
|
|
189
201
|
}
|
|
@@ -253,8 +265,16 @@ commands.deleteParagraph = (p) => Word.run(async (ctx) => {
|
|
|
253
265
|
paragraphs.load('text');
|
|
254
266
|
await ctx.sync();
|
|
255
267
|
if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Valid indices: 0-' + (paragraphs.items.length - 1) + ' (document has ' + paragraphs.items.length + ' paragraphs).');
|
|
268
|
+
const countBefore = paragraphs.items.length;
|
|
256
269
|
paragraphs.items[p.index].delete();
|
|
257
|
-
|
|
270
|
+
try {
|
|
271
|
+
await ctx.sync();
|
|
272
|
+
} catch (e) {
|
|
273
|
+
if (e.message && e.message.includes('GeneralException')) {
|
|
274
|
+
throw new Error('Cannot delete paragraph ' + p.index + '. It may be a TOC field entry or inside a protected region.');
|
|
275
|
+
}
|
|
276
|
+
throw e;
|
|
277
|
+
}
|
|
258
278
|
// Move cursor to the paragraph that now occupies this position (or previous if at end)
|
|
259
279
|
const parasAfter = ctx.document.body.paragraphs;
|
|
260
280
|
parasAfter.load('text');
|
|
@@ -264,7 +284,11 @@ commands.deleteParagraph = (p) => Word.run(async (ctx) => {
|
|
|
264
284
|
parasAfter.items[cursorIdx].getRange('Start').select();
|
|
265
285
|
await ctx.sync();
|
|
266
286
|
}
|
|
267
|
-
|
|
287
|
+
const result = { success: true };
|
|
288
|
+
if (parasAfter.items.length >= countBefore) {
|
|
289
|
+
result.warning = 'Paragraph was cleared but not removed (Word requires at least one paragraph).';
|
|
290
|
+
}
|
|
291
|
+
return result;
|
|
268
292
|
});
|
|
269
293
|
|
|
270
294
|
commands.setParagraphStyle = (p) => Word.run(async (ctx) => {
|
|
@@ -326,6 +350,7 @@ commands.search = (p) => Word.run(async (ctx) => {
|
|
|
326
350
|
});
|
|
327
351
|
|
|
328
352
|
commands.searchAndReplace = (p) => Word.run(async (ctx) => {
|
|
353
|
+
if (!p.find || typeof p.find !== 'string' || p.find.trim() === '') throw new Error('find string cannot be empty. Provide a non-empty search string.');
|
|
329
354
|
const results = ctx.document.body.search(p.find, { matchCase: p.matchCase || false, matchWholeWord: p.matchWholeWord || false });
|
|
330
355
|
results.load('text');
|
|
331
356
|
await ctx.sync();
|
|
@@ -361,24 +386,10 @@ commands.insertText = (p) => Word.run(async (ctx) => {
|
|
|
361
386
|
return { success: true };
|
|
362
387
|
});
|
|
363
388
|
|
|
364
|
-
commands.getSelection = () => Word.run(async (ctx) => {
|
|
365
|
-
const sel = ctx.document.getSelection();
|
|
366
|
-
sel.load('text,style');
|
|
367
|
-
await ctx.sync();
|
|
368
|
-
return { text: sel.text, style: sel.style };
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
commands.replaceSelection = (p) => Word.run(async (ctx) => {
|
|
372
|
-
const sel = ctx.document.getSelection();
|
|
373
|
-
const inserted = sel.insertText(p.text, Word.InsertLocation.replace);
|
|
374
|
-
inserted.getRange('End').select();
|
|
375
|
-
await ctx.sync();
|
|
376
|
-
return { success: true };
|
|
377
|
-
});
|
|
378
|
-
|
|
379
389
|
// == FORMATTING ==
|
|
380
390
|
commands.formatRange = (p) => Word.run(async (ctx) => {
|
|
381
391
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
392
|
+
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('text to format cannot be empty. Provide a non-empty search string.');
|
|
382
393
|
if (p.size !== undefined && p.size <= 0) throw new Error('size must be positive');
|
|
383
394
|
if (p.size !== undefined && p.size > 1638) throw new Error('size must not exceed 1638 points (Word maximum)');
|
|
384
395
|
if (p.color && !/^#[0-9A-Fa-f]{6}$/.test(p.color)) throw new Error('color must be a valid hex color (e.g. #FF0000)');
|
|
@@ -511,17 +522,29 @@ commands.deleteTableRow = (p) => Word.run(async (ctx) => {
|
|
|
511
522
|
commands.insertList = (p) => Word.run(async (ctx) => {
|
|
512
523
|
if (!p.items || p.items.length === 0) throw new Error('items array must not be empty');
|
|
513
524
|
const body = ctx.document.body;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const sep = body.insertParagraph('', 'End');
|
|
520
|
-
sep.style = 'Normal';
|
|
521
|
-
sep.detachFromList();
|
|
525
|
+
const location = p.location || 'End';
|
|
526
|
+
// Check if adjacent paragraph is a list item — if so, insert a non-list separator
|
|
527
|
+
if (location === 'End') {
|
|
528
|
+
const lastPara = body.paragraphs.getLast();
|
|
529
|
+
lastPara.load('isListItem');
|
|
522
530
|
await ctx.sync();
|
|
531
|
+
if (lastPara.isListItem) {
|
|
532
|
+
const sep = body.insertParagraph('', 'End');
|
|
533
|
+
sep.style = 'Normal';
|
|
534
|
+
sep.detachFromList();
|
|
535
|
+
await ctx.sync();
|
|
536
|
+
}
|
|
537
|
+
} else if (location === 'Start') {
|
|
538
|
+
const firstPara = body.paragraphs.getFirst();
|
|
539
|
+
firstPara.load('isListItem');
|
|
540
|
+
await ctx.sync();
|
|
541
|
+
if (firstPara.isListItem) {
|
|
542
|
+
const sep = body.insertParagraph('', 'Start');
|
|
543
|
+
sep.style = 'Normal';
|
|
544
|
+
await ctx.sync();
|
|
545
|
+
}
|
|
523
546
|
}
|
|
524
|
-
const para = body.insertParagraph(p.items[0],
|
|
547
|
+
const para = body.insertParagraph(p.items[0], location);
|
|
525
548
|
para.style = 'Normal';
|
|
526
549
|
await ctx.sync();
|
|
527
550
|
const list = para.startNewList();
|
|
@@ -545,6 +568,7 @@ commands.insertList = (p) => Word.run(async (ctx) => {
|
|
|
545
568
|
commands.addComment = (p) => Word.run(async (ctx) => {
|
|
546
569
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
547
570
|
if (!p.comment || typeof p.comment !== 'string' || p.comment.trim() === '') throw new Error('comment text must be a non-empty string');
|
|
571
|
+
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
548
572
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
549
573
|
results.load('text');
|
|
550
574
|
await ctx.sync();
|
|
@@ -610,6 +634,7 @@ commands.deleteComment = (p) => Word.run(async (ctx) => {
|
|
|
610
634
|
// == FOOTNOTES & ENDNOTES ==
|
|
611
635
|
commands.insertFootnote = (p) => Word.run(async (ctx) => {
|
|
612
636
|
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Footnote text must be a non-empty string');
|
|
637
|
+
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
613
638
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
614
639
|
results.load('text');
|
|
615
640
|
await ctx.sync();
|
|
@@ -625,6 +650,7 @@ commands.insertFootnote = (p) => Word.run(async (ctx) => {
|
|
|
625
650
|
|
|
626
651
|
commands.insertEndnote = (p) => Word.run(async (ctx) => {
|
|
627
652
|
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('Endnote text must be a non-empty string');
|
|
653
|
+
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
628
654
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
629
655
|
results.load('text');
|
|
630
656
|
await ctx.sync();
|
|
@@ -847,6 +873,9 @@ commands.getBookmarks = () => Word.run(async (ctx) => {
|
|
|
847
873
|
|
|
848
874
|
commands.insertBookmark = (p) => Word.run(async (ctx) => {
|
|
849
875
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
876
|
+
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
877
|
+
if (!p.name || typeof p.name !== 'string' || p.name.trim() === '') throw new Error('Bookmark name must be a non-empty string.');
|
|
878
|
+
if (!/^[A-Za-z_]\w*$/.test(p.name)) throw new Error('Invalid bookmark name: "' + p.name + '". Names must start with a letter or underscore and contain only letters, numbers, and underscores (no spaces).');
|
|
850
879
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
851
880
|
results.load('text');
|
|
852
881
|
// Check for existing bookmark with same name
|
|
@@ -885,7 +914,8 @@ commands.getStyles = () => Word.run(async (ctx) => {
|
|
|
885
914
|
styles.load('nameLocal,type,builtIn');
|
|
886
915
|
await ctx.sync();
|
|
887
916
|
const items = styles.items.map(s => ({ name: s.nameLocal, type: s.type, builtIn: s.builtIn }));
|
|
888
|
-
|
|
917
|
+
const returned = items.slice(0, 80);
|
|
918
|
+
return { count: returned.length, total: items.length, styles: returned };
|
|
889
919
|
});
|
|
890
920
|
|
|
891
921
|
// == COAUTHORING (Desktop only) ==
|
|
@@ -976,7 +1006,14 @@ commands.insertPageBreak = (p) => Word.run(async (ctx) => {
|
|
|
976
1006
|
lastPara.insertBreak('Page', 'After');
|
|
977
1007
|
lastPara.getRange('End').select();
|
|
978
1008
|
}
|
|
979
|
-
|
|
1009
|
+
try {
|
|
1010
|
+
await ctx.sync();
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
if (e.message && e.message.includes('GeneralException')) {
|
|
1013
|
+
throw new Error('Cannot insert page break at paragraph ' + (p.paragraphIndex !== undefined ? p.paragraphIndex : 'end') + '. The paragraph may be inside a table cell (page breaks are not allowed inside tables).');
|
|
1014
|
+
}
|
|
1015
|
+
throw e;
|
|
1016
|
+
}
|
|
980
1017
|
return { success: true };
|
|
981
1018
|
});
|
|
982
1019
|
|
|
@@ -1014,11 +1051,14 @@ commands.insertHyperlink = (p) => Word.run(async (ctx) => {
|
|
|
1014
1051
|
if (!p.url || !/^https?:\/\/.+/i.test(p.url)) throw new Error('URL must be a valid HTTP or HTTPS URL (e.g. https://example.com)');
|
|
1015
1052
|
// Reject URLs containing characters that must be percent-encoded (RFC 3986 unsafe chars)
|
|
1016
1053
|
if (/[<>"{}|\\^`]/.test(p.url)) throw new Error('Malformed URL: "' + p.url + '". URL contains invalid characters that must be percent-encoded.');
|
|
1054
|
+
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
1017
1055
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1018
1056
|
results.load('text');
|
|
1019
1057
|
await ctx.sync();
|
|
1020
1058
|
if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
|
|
1021
|
-
const
|
|
1059
|
+
const idx = p.occurrence || 0;
|
|
1060
|
+
if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
|
|
1061
|
+
const target = results.items[idx];
|
|
1022
1062
|
target.hyperlink = p.url;
|
|
1023
1063
|
target.getRange('End').select();
|
|
1024
1064
|
await ctx.sync();
|
|
@@ -1039,11 +1079,14 @@ commands.insertContentControl = (p) => Word.run(async (ctx) => {
|
|
|
1039
1079
|
const ccType = p.type || 'RichText';
|
|
1040
1080
|
let range;
|
|
1041
1081
|
if (p.anchorText) {
|
|
1082
|
+
if (typeof p.anchorText === 'string' && p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string or omit the parameter to use the current selection.');
|
|
1042
1083
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1043
1084
|
results.load('text');
|
|
1044
1085
|
await ctx.sync();
|
|
1045
1086
|
if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
|
|
1046
|
-
|
|
1087
|
+
const idx = p.occurrence || 0;
|
|
1088
|
+
if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
|
|
1089
|
+
range = results.items[idx];
|
|
1047
1090
|
} else {
|
|
1048
1091
|
range = ctx.document.getSelection();
|
|
1049
1092
|
}
|
|
@@ -1099,6 +1142,7 @@ commands.getPageLayout = (p) => Word.run(async (ctx) => {
|
|
|
1099
1142
|
// == FONT INFO ==
|
|
1100
1143
|
commands.getFontInfo = (p) => Word.run(async (ctx) => {
|
|
1101
1144
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
1145
|
+
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('text cannot be empty. Provide a non-empty search string.');
|
|
1102
1146
|
const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
|
|
1103
1147
|
results.load('font');
|
|
1104
1148
|
await ctx.sync();
|
|
@@ -1225,20 +1269,52 @@ commands.replaceParagraphText = (p) => Word.run(async (ctx) => {
|
|
|
1225
1269
|
const para = paragraphs.items[p.index];
|
|
1226
1270
|
const inserted = para.insertText(p.text, Word.InsertLocation.replace);
|
|
1227
1271
|
inserted.getRange('End').select();
|
|
1228
|
-
|
|
1272
|
+
try {
|
|
1273
|
+
await ctx.sync();
|
|
1274
|
+
} catch (e) {
|
|
1275
|
+
if (e.message && e.message.includes('GeneralException')) {
|
|
1276
|
+
throw new Error('Cannot replace text of paragraph ' + p.index + '. It may be a TOC field entry or inside a protected region.');
|
|
1277
|
+
}
|
|
1278
|
+
throw e;
|
|
1279
|
+
}
|
|
1229
1280
|
return { success: true };
|
|
1230
1281
|
});
|
|
1231
1282
|
|
|
1232
1283
|
commands.insertParagraphAtIndex = (p) => Word.run(async (ctx) => {
|
|
1233
1284
|
if (p.index < 0) throw new Error('Index must be non-negative');
|
|
1285
|
+
// Normalize and validate alignment
|
|
1286
|
+
const ALIGNMENT_MAP = { 'Left': 'Left', 'Center': 'Centered', 'Centered': 'Centered', 'Right': 'Right', 'Justify': 'Justified', 'Justified': 'Justified' };
|
|
1287
|
+
let alignment = null;
|
|
1288
|
+
if (p.alignment) {
|
|
1289
|
+
alignment = ALIGNMENT_MAP[p.alignment];
|
|
1290
|
+
if (!alignment) {
|
|
1291
|
+
throw new Error('Invalid alignment: "' + p.alignment + '". Valid values: Left, Center, Right, Justified');
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1234
1294
|
const paragraphs = ctx.document.body.paragraphs;
|
|
1235
1295
|
paragraphs.load('text');
|
|
1236
1296
|
await ctx.sync();
|
|
1237
1297
|
if (p.index >= paragraphs.items.length) throw new Error('Paragraph index out of range. Valid indices: 0-' + (paragraphs.items.length - 1) + ' (document has ' + paragraphs.items.length + ' paragraphs).');
|
|
1298
|
+
// Validate style exists (if provided)
|
|
1299
|
+
const styleName = p.style || 'Normal';
|
|
1300
|
+
if (p.style) {
|
|
1301
|
+
const styleObj = ctx.document.getStyles().getByNameOrNullObject(p.style);
|
|
1302
|
+
styleObj.load('nameLocal');
|
|
1303
|
+
await ctx.sync();
|
|
1304
|
+
if (styleObj.isNullObject) {
|
|
1305
|
+
throw new Error('Style not found: "' + p.style + '". Use word_get_styles to see available styles.');
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1238
1308
|
const ref = paragraphs.items[p.index];
|
|
1239
1309
|
const location = p.location === 'Before' ? Word.InsertLocation.before : Word.InsertLocation.after;
|
|
1240
1310
|
const newPara = ref.insertParagraph(p.text, location);
|
|
1241
|
-
newPara.style =
|
|
1311
|
+
newPara.style = styleName;
|
|
1312
|
+
await ctx.sync();
|
|
1313
|
+
// Apply alignment in a separate sync
|
|
1314
|
+
if (alignment) {
|
|
1315
|
+
newPara.alignment = alignment;
|
|
1316
|
+
await ctx.sync();
|
|
1317
|
+
}
|
|
1242
1318
|
newPara.getRange('End').select();
|
|
1243
1319
|
await ctx.sync();
|
|
1244
1320
|
return { success: true };
|
|
@@ -1318,13 +1394,23 @@ commands.mergeTableCells = (p) => Word.run(async (ctx) => {
|
|
|
1318
1394
|
commands.splitTableCell = (p) => Word.run(async (ctx) => {
|
|
1319
1395
|
if (p.rowCount !== undefined && p.rowCount <= 0) throw new Error('rowCount must be a positive integer (minimum 1)');
|
|
1320
1396
|
if (p.colCount !== undefined && p.colCount <= 0) throw new Error('colCount must be a positive integer (minimum 1)');
|
|
1397
|
+
if (p.tableIndex < 0) throw new Error('Table index must be non-negative');
|
|
1398
|
+
if (p.row < 0) throw new Error('Row index must be non-negative');
|
|
1399
|
+
if (p.col < 0) throw new Error('Column index must be non-negative');
|
|
1321
1400
|
const tables = ctx.document.body.tables;
|
|
1322
1401
|
tables.load('rowCount');
|
|
1323
1402
|
await ctx.sync();
|
|
1324
|
-
if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range');
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1403
|
+
if (p.tableIndex >= tables.items.length) throw new Error('Table index out of range. Document has ' + tables.items.length + ' table(s).');
|
|
1404
|
+
try {
|
|
1405
|
+
const cell = tables.items[p.tableIndex].getCell(p.row, p.col);
|
|
1406
|
+
cell.split(p.rowCount || 1, p.colCount || 2);
|
|
1407
|
+
await ctx.sync();
|
|
1408
|
+
} catch (e) {
|
|
1409
|
+
if (e.message && e.message.includes('ItemNotFound')) {
|
|
1410
|
+
throw new Error('Cell not found at row ' + p.row + ', col ' + p.col + '. Use word_get_table_data to inspect the table.');
|
|
1411
|
+
}
|
|
1412
|
+
throw e;
|
|
1413
|
+
}
|
|
1328
1414
|
return { success: true };
|
|
1329
1415
|
});
|
|
1330
1416
|
|
|
@@ -1404,6 +1490,7 @@ commands.getChangeTrackingMode = () => Word.run(async (ctx) => {
|
|
|
1404
1490
|
// == CLEAR FORMATTING ==
|
|
1405
1491
|
commands.clearFormatting = (p) => Word.run(async (ctx) => {
|
|
1406
1492
|
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
1493
|
+
if (!p.text || typeof p.text !== 'string' || p.text.trim() === '') throw new Error('text cannot be empty. Provide a non-empty search string.');
|
|
1407
1494
|
const results = ctx.document.body.search(p.text, { matchCase: p.matchCase || false });
|
|
1408
1495
|
results.load('font,style');
|
|
1409
1496
|
await ctx.sync();
|
|
@@ -1431,6 +1518,57 @@ commands.clearFormatting = (p) => Word.run(async (ctx) => {
|
|
|
1431
1518
|
return { success: true };
|
|
1432
1519
|
});
|
|
1433
1520
|
|
|
1521
|
+
// == PAGE INFO (WordApiDesktop 1.2+) ==
|
|
1522
|
+
commands.getPageInfo = async () => {
|
|
1523
|
+
if (!Office.context.requirements.isSetSupported('WordApiDesktop', '1.2')) {
|
|
1524
|
+
throw new Error('Page API not available on this platform (requires desktop Word).');
|
|
1525
|
+
}
|
|
1526
|
+
return Word.run(async (ctx) => {
|
|
1527
|
+
const bodyRange = ctx.document.body.getRange();
|
|
1528
|
+
const pages = bodyRange.pages;
|
|
1529
|
+
pages.load('items');
|
|
1530
|
+
await ctx.sync();
|
|
1531
|
+
const pageCount = pages.items.length;
|
|
1532
|
+
// Load all body paragraphs to map page boundaries to document indices
|
|
1533
|
+
const allParas = ctx.document.body.paragraphs;
|
|
1534
|
+
allParas.load('text,style');
|
|
1535
|
+
await ctx.sync();
|
|
1536
|
+
const pageDetails = [];
|
|
1537
|
+
// Forward-only cursor: pages are sequential, so each page's paragraphs
|
|
1538
|
+
// must start at or after the previous page's last matched index.
|
|
1539
|
+
let nextStart = 0;
|
|
1540
|
+
for (let i = 0; i < pages.items.length; i++) {
|
|
1541
|
+
const page = pages.items[i];
|
|
1542
|
+
page.load('index,height,width');
|
|
1543
|
+
const paras = page.getRange().paragraphs;
|
|
1544
|
+
paras.load('text,style');
|
|
1545
|
+
await ctx.sync();
|
|
1546
|
+
let firstIdx = -1;
|
|
1547
|
+
let lastIdx = -1;
|
|
1548
|
+
for (let j = 0; j < paras.items.length; j++) {
|
|
1549
|
+
const p = paras.items[j];
|
|
1550
|
+
for (let k = nextStart; k < allParas.items.length; k++) {
|
|
1551
|
+
if (allParas.items[k].text === p.text && allParas.items[k].style === p.style) {
|
|
1552
|
+
if (firstIdx === -1) firstIdx = k;
|
|
1553
|
+
lastIdx = k;
|
|
1554
|
+
nextStart = k + 1;
|
|
1555
|
+
break;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
pageDetails.push({
|
|
1560
|
+
pageIndex: page.index,
|
|
1561
|
+
height: page.height,
|
|
1562
|
+
width: page.width,
|
|
1563
|
+
paragraphCount: paras.items.length,
|
|
1564
|
+
firstParagraphIndex: firstIdx,
|
|
1565
|
+
lastParagraphIndex: lastIdx
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
return { pageCount, pages: pageDetails };
|
|
1569
|
+
});
|
|
1570
|
+
};
|
|
1571
|
+
|
|
1434
1572
|
// == INSERT TOC ==
|
|
1435
1573
|
commands.insertTableOfContents = (p) => Word.run(async (ctx) => {
|
|
1436
1574
|
const body = ctx.document.body;
|
|
@@ -1481,6 +1619,7 @@ commands.deleteImage = (p) => Word.run(async (ctx) => {
|
|
|
1481
1619
|
|
|
1482
1620
|
// == TABLE CELL SHADING ==
|
|
1483
1621
|
commands.setTableCellShading = (p) => Word.run(async (ctx) => {
|
|
1622
|
+
if (!/^#[0-9A-Fa-f]{6}$/.test(p.color)) throw new Error('color must be a valid hex color (e.g. #FFD700)');
|
|
1484
1623
|
const tables = ctx.document.body.tables;
|
|
1485
1624
|
tables.load('rowCount');
|
|
1486
1625
|
await ctx.sync();
|
|
@@ -1519,11 +1658,14 @@ commands.getHyperlinks = () => Word.run(async (ctx) => {
|
|
|
1519
1658
|
});
|
|
1520
1659
|
|
|
1521
1660
|
commands.removeHyperlink = (p) => Word.run(async (ctx) => {
|
|
1661
|
+
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
1522
1662
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1523
1663
|
results.load('hyperlink');
|
|
1524
1664
|
await ctx.sync();
|
|
1525
1665
|
if (results.items.length === 0) throw new Error('Text not found: ' + p.anchorText);
|
|
1526
|
-
const
|
|
1666
|
+
const idx = p.occurrence || 0;
|
|
1667
|
+
if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
|
|
1668
|
+
const target = results.items[idx];
|
|
1527
1669
|
target.hyperlink = '';
|
|
1528
1670
|
target.getRange('End').select();
|
|
1529
1671
|
await ctx.sync();
|
|
@@ -1603,11 +1745,15 @@ commands.setListLevel = (p) => Word.run(async (ctx) => {
|
|
|
1603
1745
|
|
|
1604
1746
|
// == LINE BREAK ==
|
|
1605
1747
|
commands.insertLineBreak = (p) => Word.run(async (ctx) => {
|
|
1748
|
+
if (p.occurrence !== undefined && p.occurrence < 0) throw new Error('occurrence must be non-negative (0-indexed)');
|
|
1749
|
+
if (!p.anchorText || typeof p.anchorText !== 'string' || p.anchorText.trim() === '') throw new Error('anchorText cannot be empty. Provide a non-empty search string.');
|
|
1606
1750
|
const results = ctx.document.body.search(p.anchorText, { matchCase: p.matchCase || false });
|
|
1607
1751
|
results.load('text');
|
|
1608
1752
|
await ctx.sync();
|
|
1609
1753
|
if (results.items.length === 0) throw new Error('Anchor not found: ' + p.anchorText);
|
|
1610
|
-
const
|
|
1754
|
+
const idx = p.occurrence || 0;
|
|
1755
|
+
if (idx >= results.items.length) throw new Error('Occurrence ' + idx + ' not found (only ' + results.items.length + ' match' + (results.items.length === 1 ? '' : 'es') + ')');
|
|
1756
|
+
const target = results.items[idx];
|
|
1611
1757
|
const loc = p.before ? Word.InsertLocation.before : Word.InsertLocation.after;
|
|
1612
1758
|
target.insertBreak('Line', loc);
|
|
1613
1759
|
target.getRange('End').select();
|