@remcp/runtime 0.2.47 → 0.2.49
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/CHANGELOG.md +23 -0
- package/README.md +45 -26
- package/package.json +6 -2
- package/src/catalog.mjs +73 -1
- package/src/extended/browser.mjs +592 -0
- package/src/extended/catalog.mjs +564 -0
- package/src/extended/common.mjs +209 -0
- package/src/extended/desktop-linux.mjs +1173 -0
- package/src/extended/desktop-macos.mjs +408 -0
- package/src/extended/desktop-windows.mjs +546 -0
- package/src/extended/desktop.mjs +1313 -0
- package/src/extended/diagnostics.mjs +274 -0
- package/src/extended/documents.mjs +381 -0
- package/src/extended/ocr.mjs +139 -0
- package/src/helpers/wayland-eis-helper.py +450 -0
- package/src/index.mjs +46 -44
- package/src/invoke.mjs +3 -3
- package/src/mcp-v2.mjs +104 -0
- package/src/permissions.mjs +1 -1
- package/src/tools/terminal.mjs +58 -13
- package/src/util.mjs +24 -1
- package/src/wayland-remote-desktop.mjs +654 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { readDocxText, readPdfText } from '../documents.mjs';
|
|
5
|
+
import { resolveSafePath, text } from '../util.mjs';
|
|
6
|
+
import {
|
|
7
|
+
clamp,
|
|
8
|
+
commandExists,
|
|
9
|
+
escapePowerShellSingle,
|
|
10
|
+
jsonResult,
|
|
11
|
+
optionalString,
|
|
12
|
+
removeTemp,
|
|
13
|
+
requireEnum,
|
|
14
|
+
runFile,
|
|
15
|
+
runPowerShell,
|
|
16
|
+
tempDir,
|
|
17
|
+
unavailable,
|
|
18
|
+
} from './common.mjs';
|
|
19
|
+
|
|
20
|
+
function xmlEscape(value) {
|
|
21
|
+
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
22
|
+
}
|
|
23
|
+
function xmlUnescape(value) {
|
|
24
|
+
return String(value).replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_OOXML_ENTRIES = 10_000;
|
|
28
|
+
const MAX_OOXML_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
function safeArchiveEntry(name) {
|
|
31
|
+
const normalized = String(name || '').replace(/\\/g, '/');
|
|
32
|
+
if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return false;
|
|
33
|
+
return !normalized.split('/').includes('..');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function validateZipArchive(source) {
|
|
37
|
+
if (process.platform === 'win32') {
|
|
38
|
+
const script = `Add-Type -AssemblyName System.IO.Compression.FileSystem;$z=[IO.Compression.ZipFile]::OpenRead('${escapePowerShellSingle(source)}');try{$count=$z.Entries.Count;$total=0;foreach($e in $z.Entries){$n=$e.FullName.Replace('\\','/');if([IO.Path]::IsPathRooted($n) -or $n.StartsWith('/') -or ($n.Split('/') -contains '..')){throw ('unsafe archive path: '+$n)};$total += $e.Length};if($count -gt ${MAX_OOXML_ENTRIES}){throw 'too many archive entries'};if($total -gt ${MAX_OOXML_UNCOMPRESSED_BYTES}){throw 'archive expands beyond the OOXML limit'};[pscustomobject]@{count=$count;total=$total}|ConvertTo-Json -Compress}finally{$z.Dispose()}`;
|
|
39
|
+
await runPowerShell(script, { label:'validate OOXML archive', timeout:30_000 });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
|
|
43
|
+
const names = await runFile('unzip', ['-Z','-1',source], { label:'inspect OOXML archive', timeout:30_000, maxBuffer:16 * 1024 * 1024 });
|
|
44
|
+
const entries = names.stdout.split(/\r?\n/).filter(Boolean);
|
|
45
|
+
if (entries.length > MAX_OOXML_ENTRIES) throw new Error(`OOXML archive has too many entries (${entries.length}; max ${MAX_OOXML_ENTRIES})`);
|
|
46
|
+
const unsafe = entries.find(name => !safeArchiveEntry(name));
|
|
47
|
+
if (unsafe) throw new Error(`OOXML archive contains an unsafe path: ${unsafe}`);
|
|
48
|
+
const totals = await runFile('unzip', ['-Z','-t',source], { label:'inspect OOXML archive size', timeout:30_000 });
|
|
49
|
+
const uncompressed = Number(totals.stdout.match(/(?:^|,\s)([0-9]+) bytes uncompressed/)?.[1] || 0);
|
|
50
|
+
if (uncompressed > MAX_OOXML_UNCOMPRESSED_BYTES) throw new Error(`OOXML archive expands to ${uncompressed} bytes; max ${MAX_OOXML_UNCOMPRESSED_BYTES}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function extractZip(source, destination) {
|
|
54
|
+
await validateZipArchive(source);
|
|
55
|
+
await mkdir(destination, { recursive:true });
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::ExtractToDirectory('${escapePowerShellSingle(source)}','${escapePowerShellSingle(destination)}')`, { label:'extract OOXML archive', timeout:30_000 });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!commandExists('unzip')) unavailable('OOXML document editing', 'unzip is required');
|
|
61
|
+
await runFile('unzip', ['-qq',source,'-d',destination], { label:'extract OOXML archive', timeout:30_000 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function createZip(sourceDir, destination) {
|
|
65
|
+
await rm(destination, { force:true }).catch(() => {});
|
|
66
|
+
if (process.platform === 'win32') {
|
|
67
|
+
await runPowerShell(`Add-Type -AssemblyName System.IO.Compression.FileSystem;[IO.Compression.ZipFile]::CreateFromDirectory('${escapePowerShellSingle(sourceDir)}','${escapePowerShellSingle(destination)}',[IO.Compression.CompressionLevel]::Optimal,$false)`, { label:'create OOXML archive', timeout:30_000 });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!commandExists('zip')) unavailable('OOXML document editing', 'zip is required');
|
|
71
|
+
await runFile('zip', ['-qr',destination,'.'], { cwd:sourceDir, label:'create OOXML archive', timeout:30_000 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function workbookSheetPath(workbookXml, relsXml, requestedName) {
|
|
75
|
+
const sheets = [...workbookXml.matchAll(/<sheet\b[^>]*name="([^"]+)"[^>]*(?:r:id|id)="([^"]+)"[^>]*\/?>(?:<\/sheet>)?/g)].map(match => ({ name:xmlUnescape(match[1]), rid:match[2] }));
|
|
76
|
+
const sheet = requestedName ? sheets.find(item => item.name.toLowerCase() === requestedName.toLowerCase()) : sheets[0];
|
|
77
|
+
if (!sheet) throw new Error(requestedName ? `Worksheet not found: ${requestedName}` : 'Workbook contains no worksheets');
|
|
78
|
+
const relationships = [...relsXml.matchAll(/<Relationship\b[^>]*Id="([^"]+)"[^>]*Target="([^"]+)"[^>]*\/?>/g)].map(match => ({ id:match[1], target:match[2] }));
|
|
79
|
+
const rel = relationships.find(item => item.id === sheet.rid);
|
|
80
|
+
if (!rel) throw new Error(`Relationship for worksheet ${sheet.name} was not found`);
|
|
81
|
+
const target = rel.target.replace(/^\//, '').replace(/\\/g, '/');
|
|
82
|
+
if (!safeArchiveEntry(target)) throw new Error(`Worksheet relationship contains an unsafe path: ${target}`);
|
|
83
|
+
const relative = path.posix.normalize(target.startsWith('xl/') ? target : path.posix.join('xl', target));
|
|
84
|
+
if (!relative.startsWith('xl/') || relative.split('/').includes('..')) throw new Error(`Worksheet relationship escapes the workbook: ${target}`);
|
|
85
|
+
return { name:sheet.name, relative };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readSharedStrings(xml) {
|
|
89
|
+
if (!xml) return [];
|
|
90
|
+
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map(match => [...match[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(textMatch => xmlUnescape(textMatch[1])).join(''));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readWorksheetCells(xml, sharedStrings, maxCells) {
|
|
94
|
+
const cells = [];
|
|
95
|
+
const pattern = /<c\b([^>]*)\br="([A-Z]+\d+)"([^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*)\br="([A-Z]+\d+)"([^>]*)\/>/g;
|
|
96
|
+
for (const match of xml.matchAll(pattern)) {
|
|
97
|
+
if (cells.length >= maxCells) break;
|
|
98
|
+
const attrs = `${match[1] || match[5] || ''}${match[3] || match[7] || ''}`;
|
|
99
|
+
const ref = match[2] || match[6];
|
|
100
|
+
const body = match[4] || '';
|
|
101
|
+
const type = attrs.match(/\bt="([^"]+)"/)?.[1] || '';
|
|
102
|
+
const formula = body.match(/<f\b[^>]*>([\s\S]*?)<\/f>/)?.[1];
|
|
103
|
+
const raw = body.match(/<v\b[^>]*>([\s\S]*?)<\/v>/)?.[1] ?? '';
|
|
104
|
+
let value;
|
|
105
|
+
if (type === 's') value = sharedStrings[Number(raw)] ?? raw;
|
|
106
|
+
else if (type === 'inlineStr') value = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(item => xmlUnescape(item[1])).join('');
|
|
107
|
+
else if (type === 'b') value = raw === '1';
|
|
108
|
+
else if (raw !== '' && Number.isFinite(Number(raw))) value = Number(raw);
|
|
109
|
+
else value = xmlUnescape(raw);
|
|
110
|
+
cells.push({ ref, value, ...(formula ? { formula:xmlUnescape(formula) } : {}) });
|
|
111
|
+
}
|
|
112
|
+
return cells;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readXlsx(filePath, args) {
|
|
116
|
+
const dir = await tempDir('remcp-xlsx-read-');
|
|
117
|
+
try {
|
|
118
|
+
await extractZip(filePath, dir);
|
|
119
|
+
const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
|
|
120
|
+
const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
|
|
121
|
+
const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
|
|
122
|
+
const worksheet = await readFile(path.join(dir,...sheet.relative.split('/')),'utf8');
|
|
123
|
+
let shared = [];
|
|
124
|
+
try { shared = readSharedStrings(await readFile(path.join(dir,'xl','sharedStrings.xml'),'utf8')); } catch {}
|
|
125
|
+
const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
|
|
126
|
+
return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
|
|
127
|
+
} finally { await removeTemp(dir); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function readDocument(args) {
|
|
131
|
+
const filePath = await resolveSafePath(args.path,'path');
|
|
132
|
+
const lower = filePath.toLowerCase();
|
|
133
|
+
const data = await readFile(filePath);
|
|
134
|
+
if (lower.endsWith('.docx')) return text(readDocxText(data));
|
|
135
|
+
if (lower.endsWith('.pdf')) return text(readPdfText(data));
|
|
136
|
+
if (lower.endsWith('.xlsx')) return jsonResult(await readXlsx(filePath,args));
|
|
137
|
+
if (/\.(txt|md|csv|json|xml|yaml|yml)$/i.test(lower)) return text(data.toString('utf8'));
|
|
138
|
+
throw new Error('read_document supports PDF, DOCX, XLSX, TXT, Markdown, CSV, JSON, XML, YAML');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function cellXml(ref, value, formula) {
|
|
142
|
+
if (formula != null) return `<c r="${ref}"><f>${xmlEscape(String(formula).replace(/^=/,''))}</f></c>`;
|
|
143
|
+
if (typeof value === 'number' && Number.isFinite(value)) return `<c r="${ref}"><v>${value}</v></c>`;
|
|
144
|
+
if (typeof value === 'boolean') return `<c r="${ref}" t="b"><v>${value ? 1 : 0}</v></c>`;
|
|
145
|
+
return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xmlEscape(value ?? '')}</t></is></c>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function setWorksheetCell(xml, ref, value, formula) {
|
|
149
|
+
if (!/^[A-Z]+[1-9]\d*$/.test(ref)) throw new Error(`Invalid cell reference: ${ref}`);
|
|
150
|
+
const replacement = cellXml(ref,value,formula);
|
|
151
|
+
const cellPattern = new RegExp(`<c\\b[^>]*\\br="${ref}"[^>]*(?:>[\\s\\S]*?<\\/c>|\\/>)`);
|
|
152
|
+
if (cellPattern.test(xml)) return xml.replace(cellPattern,replacement);
|
|
153
|
+
const rowNo = ref.match(/\d+$/)[0];
|
|
154
|
+
const rowPattern = new RegExp(`(<row\\b[^>]*\\br="${rowNo}"[^>]*>)([\\s\\S]*?)(<\\/row>)`);
|
|
155
|
+
if (rowPattern.test(xml)) return xml.replace(rowPattern, (_match,start,body,end) => `${start}${body}${replacement}${end}`);
|
|
156
|
+
if (!/<sheetData\b[^>]*>/.test(xml)) throw new Error('Worksheet XML has no sheetData');
|
|
157
|
+
return xml.replace(/<\/sheetData>/, `<row r="${rowNo}">${replacement}</row></sheetData>`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function columnNumber(label) {
|
|
161
|
+
let value = 0;
|
|
162
|
+
for (const char of String(label)) value = value * 26 + (char.charCodeAt(0) - 64);
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function columnLabel(number) {
|
|
167
|
+
let value = Number(number), out = '';
|
|
168
|
+
while (value > 0) { value -= 1; out = String.fromCharCode(65 + (value % 26)) + out; value = Math.floor(value / 26); }
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function cellPoint(ref) {
|
|
173
|
+
const match = String(ref || '').toUpperCase().match(/^([A-Z]+)([1-9]\d*)$/);
|
|
174
|
+
if (!match) throw new Error(`Invalid cell reference: ${ref}`);
|
|
175
|
+
return { col: columnNumber(match[1]), row: Number(match[2]) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function expandSpreadsheetEdits(edits) {
|
|
179
|
+
const expanded = [];
|
|
180
|
+
for (const edit of edits) {
|
|
181
|
+
if (edit?.cell) {
|
|
182
|
+
const cell = String(edit.cell).toUpperCase(); cellPoint(cell);
|
|
183
|
+
expanded.push({ cell, value:edit.value, formula:edit.formula });
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const range = String(edit?.range || '').toUpperCase();
|
|
187
|
+
const match = range.match(/^([A-Z]+[1-9]\d*):([A-Z]+[1-9]\d*)$/);
|
|
188
|
+
if (!match) throw new Error('Each spreadsheet edit requires cell or a range like A1:C3');
|
|
189
|
+
const start = cellPoint(match[1]), end = cellPoint(match[2]);
|
|
190
|
+
if (end.col < start.col || end.row < start.row) throw new Error(`Invalid range order: ${range}`);
|
|
191
|
+
const rows = end.row - start.row + 1, cols = end.col - start.col + 1;
|
|
192
|
+
const values = Array.isArray(edit.values) ? edit.values : null;
|
|
193
|
+
if (values && (values.length !== rows || values.some(row => !Array.isArray(row) || row.length !== cols))) {
|
|
194
|
+
throw new Error(`values for ${range} must be a ${rows}x${cols} matrix`);
|
|
195
|
+
}
|
|
196
|
+
for (let rowOffset = 0; rowOffset < rows; rowOffset += 1) {
|
|
197
|
+
for (let colOffset = 0; colOffset < cols; colOffset += 1) {
|
|
198
|
+
expanded.push({
|
|
199
|
+
cell: `${columnLabel(start.col + colOffset)}${start.row + rowOffset}`,
|
|
200
|
+
value: values ? values[rowOffset][colOffset] : edit.value,
|
|
201
|
+
formula: edit.formula,
|
|
202
|
+
});
|
|
203
|
+
if (expanded.length > 500) throw new Error('Expanded spreadsheet edits exceed the 500-cell limit');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return expanded;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function editSpreadsheet(args) {
|
|
211
|
+
const filePath = await resolveSafePath(args.path,'path');
|
|
212
|
+
if (!filePath.toLowerCase().endsWith('.xlsx')) throw new Error('edit_spreadsheet supports .xlsx files');
|
|
213
|
+
const requestedEdits = Array.isArray(args.edits) ? args.edits : [];
|
|
214
|
+
if (!requestedEdits.length || requestedEdits.length > 500) throw new Error('edits must contain 1..500 cell/range edits');
|
|
215
|
+
const edits = expandSpreadsheetEdits(requestedEdits);
|
|
216
|
+
const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
|
|
217
|
+
const dir = await tempDir('remcp-xlsx-edit-');
|
|
218
|
+
const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
|
|
219
|
+
try {
|
|
220
|
+
await extractZip(filePath,dir);
|
|
221
|
+
const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
|
|
222
|
+
const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
|
|
223
|
+
const sheet = workbookSheetPath(workbook,rels,optionalString(args.sheet));
|
|
224
|
+
const worksheetPath = path.join(dir,...sheet.relative.split('/'));
|
|
225
|
+
let xml = await readFile(worksheetPath,'utf8');
|
|
226
|
+
for (const edit of edits) xml = setWorksheetCell(xml,String(edit.cell || '').toUpperCase(),edit.value,edit.formula);
|
|
227
|
+
await writeFile(worksheetPath,xml,'utf8');
|
|
228
|
+
await createZip(dir,tempOut);
|
|
229
|
+
await copyFile(tempOut,output);
|
|
230
|
+
return jsonResult({ path:output, sheet:sheet.name, edited_cells:edits.length, bytes:(await stat(output)).size });
|
|
231
|
+
} finally {
|
|
232
|
+
await rm(tempOut,{force:true}).catch(() => {});
|
|
233
|
+
await removeTemp(dir);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function paragraphText(xml) {
|
|
238
|
+
return [...xml.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>/g)].map(match => xmlUnescape(match[1])).join('');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function replaceDocxText(documentXml, search, replacement, all) {
|
|
242
|
+
let changed = 0;
|
|
243
|
+
const needle = String(search);
|
|
244
|
+
const xml = documentXml.replace(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/g, paragraph => {
|
|
245
|
+
if (!needle || !paragraphText(paragraph).includes(needle) || (!all && changed)) return paragraph;
|
|
246
|
+
const current = paragraphText(paragraph);
|
|
247
|
+
const next = all ? current.split(needle).join(String(replacement)) : current.replace(needle,String(replacement));
|
|
248
|
+
if (next === current) return paragraph;
|
|
249
|
+
changed += all ? current.split(needle).length - 1 : 1;
|
|
250
|
+
const start = paragraph.match(/^<w:p\b[^>]*>/)?.[0] || '<w:p>';
|
|
251
|
+
const pPr = paragraph.match(/<w:pPr\b[^>]*>[\s\S]*?<\/w:pPr>/)?.[0] || '';
|
|
252
|
+
return `${start}${pPr}<w:r><w:t xml:space="preserve">${xmlEscape(next)}</w:t></w:r></w:p>`;
|
|
253
|
+
});
|
|
254
|
+
return { xml, changed };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function docxParagraph(value) {
|
|
258
|
+
return `<w:p><w:r><w:t xml:space="preserve">${xmlEscape(String(value ?? ''))}</w:t></w:r></w:p>`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function insertDocxParagraphAtEnd(xml, paragraph) {
|
|
262
|
+
const sect = xml.match(/<w:sectPr\b[\s\S]*?<\/w:sectPr>\s*<\/w:body>/);
|
|
263
|
+
if (sect) return xml.replace(sect[0], `${paragraph}${sect[0]}`);
|
|
264
|
+
return xml.replace(/<\/w:body>/, `${paragraph}</w:body>`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function mutateMatchingParagraphs(xml, op) {
|
|
268
|
+
const needle = optionalString(op.search);
|
|
269
|
+
if (!needle) throw new Error(`${op.action} requires search`);
|
|
270
|
+
const insert = docxParagraph(op.text);
|
|
271
|
+
let changed = 0;
|
|
272
|
+
const all = op.all !== false;
|
|
273
|
+
const next = xml.replace(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/g, paragraph => {
|
|
274
|
+
if (!paragraphText(paragraph).includes(needle) || (!all && changed)) return paragraph;
|
|
275
|
+
changed += 1;
|
|
276
|
+
if (op.action === 'delete_paragraph') return '';
|
|
277
|
+
if (op.action === 'insert_paragraph_before') return `${insert}${paragraph}`;
|
|
278
|
+
if (op.action === 'insert_paragraph_after') return `${paragraph}${insert}`;
|
|
279
|
+
return paragraph;
|
|
280
|
+
});
|
|
281
|
+
return { xml: next, changed };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function editDocument(args) {
|
|
285
|
+
const filePath = await resolveSafePath(args.path,'path');
|
|
286
|
+
if (!filePath.toLowerCase().endsWith('.docx')) throw new Error('edit_document supports .docx files');
|
|
287
|
+
const operations = Array.isArray(args.operations) ? args.operations : [];
|
|
288
|
+
if (!operations.length || operations.length > 100) throw new Error('operations must contain 1..100 edits');
|
|
289
|
+
const output = args.output ? await resolveSafePath(args.output,'output') : filePath;
|
|
290
|
+
const dir = await tempDir('remcp-docx-edit-');
|
|
291
|
+
const tempOut = path.join(path.dirname(output), `.remcp-${Date.now()}-${path.basename(output)}`);
|
|
292
|
+
let changes = 0;
|
|
293
|
+
try {
|
|
294
|
+
await extractZip(filePath,dir);
|
|
295
|
+
const documentPath = path.join(dir,'word','document.xml');
|
|
296
|
+
let xml = await readFile(documentPath,'utf8');
|
|
297
|
+
for (const op of operations) {
|
|
298
|
+
const action = requireEnum(op.action,'operation.action',['replace','append_paragraph','prepend_paragraph','insert_paragraph_before','insert_paragraph_after','delete_paragraph']);
|
|
299
|
+
if (action === 'replace') {
|
|
300
|
+
const search = optionalString(op.search); if (!search) throw new Error('replace requires search');
|
|
301
|
+
const result = replaceDocxText(xml,search,String(op.replacement ?? ''),op.all !== false);
|
|
302
|
+
xml = result.xml; changes += result.changed;
|
|
303
|
+
} else if (action === 'append_paragraph') {
|
|
304
|
+
xml = insertDocxParagraphAtEnd(xml, docxParagraph(op.text)); changes += 1;
|
|
305
|
+
} else if (action === 'prepend_paragraph') {
|
|
306
|
+
xml = xml.replace(/(<w:body\b[^>]*>)/, `$1${docxParagraph(op.text)}`); changes += 1;
|
|
307
|
+
} else {
|
|
308
|
+
const result = mutateMatchingParagraphs(xml, { ...op, action });
|
|
309
|
+
xml = result.xml; changes += result.changed;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
await writeFile(documentPath,xml,'utf8');
|
|
313
|
+
await createZip(dir,tempOut);
|
|
314
|
+
await copyFile(tempOut,output);
|
|
315
|
+
return jsonResult({ path:output, operations:operations.length, changes, bytes:(await stat(output)).size });
|
|
316
|
+
} finally {
|
|
317
|
+
await rm(tempOut,{force:true}).catch(() => {});
|
|
318
|
+
await removeTemp(dir);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function pdfAnnotations(buffer) {
|
|
323
|
+
const raw = buffer.toString('latin1');
|
|
324
|
+
const rows = [];
|
|
325
|
+
for (const match of raw.matchAll(/(\d+)\s+(\d+)\s+obj([\s\S]*?)endobj/g)) {
|
|
326
|
+
const body = match[3];
|
|
327
|
+
if (!/\/Type\s*\/Annot\b|\/Subtype\s*\/(?:Text|FreeText|Highlight|Underline|StrikeOut|Square|Circle|Stamp|Ink|Link)\b/.test(body)) continue;
|
|
328
|
+
rows.push({
|
|
329
|
+
object:Number(match[1]),
|
|
330
|
+
generation:Number(match[2]),
|
|
331
|
+
subtype:body.match(/\/Subtype\s*\/([A-Za-z]+)/)?.[1] || null,
|
|
332
|
+
contents:body.match(/\/Contents\s*\(((?:\\.|[^\\()])*)\)/)?.[1] || null,
|
|
333
|
+
rect:body.match(/\/Rect\s*\[([^\]]+)\]/)?.[1]?.trim().split(/\s+/).map(Number) || null,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return rows;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function pdfAction(args) {
|
|
340
|
+
const action = requireEnum(args.action,'action',['merge','split','extract_pages','annotations','info']);
|
|
341
|
+
if (action === 'annotations') {
|
|
342
|
+
const source = await resolveSafePath(args.path,'path');
|
|
343
|
+
return jsonResult({ path:source, annotations:pdfAnnotations(await readFile(source)) });
|
|
344
|
+
}
|
|
345
|
+
if (action === 'info') {
|
|
346
|
+
const source = await resolveSafePath(args.path,'path');
|
|
347
|
+
if (commandExists('pdfinfo')) return text((await runFile('pdfinfo',[source],{label:'pdfinfo'})).stdout);
|
|
348
|
+
const data = await readFile(source); return jsonResult({ path:source, bytes:data.length, annotations:pdfAnnotations(data).length });
|
|
349
|
+
}
|
|
350
|
+
if (action === 'merge') {
|
|
351
|
+
const requested = Array.isArray(args.paths) ? args.paths : [];
|
|
352
|
+
if (requested.length < 2 || requested.length > 100) throw new Error('merge requires 2..100 PDFs in paths');
|
|
353
|
+
const sources=[]; for(const value of requested) sources.push(await resolveSafePath(value,'paths'));
|
|
354
|
+
const output=await resolveSafePath(args.output,'output');
|
|
355
|
+
if(commandExists('pdfunite')) await runFile('pdfunite',[...sources,output],{label:'PDF merge',timeout:60_000});
|
|
356
|
+
else if(commandExists('qpdf')) await runFile('qpdf',['--empty','--pages',...sources,'--',output],{label:'PDF merge',timeout:60_000});
|
|
357
|
+
else unavailable('PDF merge','install poppler-utils (pdfunite) or qpdf');
|
|
358
|
+
return jsonResult({action,output,inputs:sources.length,bytes:(await stat(output)).size});
|
|
359
|
+
}
|
|
360
|
+
const source=await resolveSafePath(args.path,'path');
|
|
361
|
+
if(action==='split'){
|
|
362
|
+
const outputDir=await resolveSafePath(args.output_dir || path.dirname(source),'output_dir'); await mkdir(outputDir,{recursive:true});
|
|
363
|
+
const pattern=path.join(outputDir,optionalString(args.pattern)||`${path.basename(source,path.extname(source))}-%d.pdf`);
|
|
364
|
+
if(commandExists('pdfseparate')) await runFile('pdfseparate',[source,pattern],{label:'PDF split',timeout:60_000});
|
|
365
|
+
else unavailable('PDF split','install poppler-utils (pdfseparate)');
|
|
366
|
+
return jsonResult({action,output_dir:outputDir,files:(await readdir(outputDir)).filter(name=>name.toLowerCase().endsWith('.pdf')).sort()});
|
|
367
|
+
}
|
|
368
|
+
const pages=optionalString(args.pages); if(!pages || !/^[0-9,\-\s]+$/.test(pages)) throw new Error('pages must look like 1-3,5');
|
|
369
|
+
const output=await resolveSafePath(args.output,'output');
|
|
370
|
+
if(commandExists('qpdf')) await runFile('qpdf',[source,'--pages','.',pages.replace(/\s+/g,''),'--',output],{label:'PDF extract pages',timeout:60_000});
|
|
371
|
+
else if(commandExists('pdftk')) await runFile('pdftk',[source,'cat',...pages.replace(/\s+/g,'').split(','),'output',output],{label:'PDF extract pages',timeout:60_000});
|
|
372
|
+
else unavailable('PDF page extraction','install qpdf or pdftk');
|
|
373
|
+
return jsonResult({action,source,pages,output,bytes:(await stat(output)).size});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export const documentHandlers = {
|
|
377
|
+
read_document:readDocument,
|
|
378
|
+
edit_spreadsheet:editSpreadsheet,
|
|
379
|
+
edit_document:editDocument,
|
|
380
|
+
pdf_action:pdfAction,
|
|
381
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writeFile } from 'node:fs/promises';
|
|
3
|
+
import { clamp, commandExists, removeTemp, runFile, tempDir } from './common.mjs';
|
|
4
|
+
|
|
5
|
+
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
function normalizeText(value) {
|
|
8
|
+
return String(value || '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ocrCapabilityAvailable() {
|
|
12
|
+
return commandExists('tesseract');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function safeLanguage(value) {
|
|
16
|
+
const language = String(value || 'eng').trim();
|
|
17
|
+
if (!/^[A-Za-z0-9_+.-]{1,64}$/.test(language)) throw new Error('ocr_language contains unsupported characters');
|
|
18
|
+
return language;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseTesseractTsv(tsv, maxWords = 2000) {
|
|
22
|
+
const rows = String(tsv || '').split(/\r?\n/);
|
|
23
|
+
if (!rows.length) return { words: [], lines: [] };
|
|
24
|
+
const header = rows.shift().split('\t');
|
|
25
|
+
const at = name => header.indexOf(name);
|
|
26
|
+
const indexes = {
|
|
27
|
+
level: at('level'), page: at('page_num'), block: at('block_num'), par: at('par_num'), line: at('line_num'),
|
|
28
|
+
left: at('left'), top: at('top'), width: at('width'), height: at('height'), conf: at('conf'), text: at('text'),
|
|
29
|
+
};
|
|
30
|
+
const words = [];
|
|
31
|
+
let totalWords = 0;
|
|
32
|
+
for (const raw of rows) {
|
|
33
|
+
if (!raw.trim()) continue;
|
|
34
|
+
const columns = raw.split('\t');
|
|
35
|
+
if (Number(columns[indexes.level]) !== 5) continue;
|
|
36
|
+
const text = String(columns[indexes.text] || '').trim();
|
|
37
|
+
if (!text) continue;
|
|
38
|
+
totalWords += 1;
|
|
39
|
+
if (words.length >= maxWords) continue;
|
|
40
|
+
const confidence = Number(columns[indexes.conf]);
|
|
41
|
+
const left = Number(columns[indexes.left]);
|
|
42
|
+
const top = Number(columns[indexes.top]);
|
|
43
|
+
const width = Number(columns[indexes.width]);
|
|
44
|
+
const height = Number(columns[indexes.height]);
|
|
45
|
+
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) continue;
|
|
46
|
+
words.push({
|
|
47
|
+
text,
|
|
48
|
+
confidence: Number.isFinite(confidence) ? confidence : null,
|
|
49
|
+
x: left,
|
|
50
|
+
y: top,
|
|
51
|
+
width,
|
|
52
|
+
height,
|
|
53
|
+
page: Number(columns[indexes.page]) || 0,
|
|
54
|
+
block: Number(columns[indexes.block]) || 0,
|
|
55
|
+
paragraph: Number(columns[indexes.par]) || 0,
|
|
56
|
+
line: Number(columns[indexes.line]) || 0,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const grouped = new Map();
|
|
61
|
+
for (const word of words) {
|
|
62
|
+
const key = [word.page, word.block, word.paragraph, word.line].join(':');
|
|
63
|
+
const group = grouped.get(key) || [];
|
|
64
|
+
group.push(word);
|
|
65
|
+
grouped.set(key, group);
|
|
66
|
+
}
|
|
67
|
+
const lines = [...grouped.values()].map(group => {
|
|
68
|
+
const x1 = Math.min(...group.map(word => word.x));
|
|
69
|
+
const y1 = Math.min(...group.map(word => word.y));
|
|
70
|
+
const x2 = Math.max(...group.map(word => word.x + word.width));
|
|
71
|
+
const y2 = Math.max(...group.map(word => word.y + word.height));
|
|
72
|
+
const confidences = group.map(word => word.confidence).filter(Number.isFinite);
|
|
73
|
+
return {
|
|
74
|
+
text: group.map(word => word.text).join(' '),
|
|
75
|
+
confidence: confidences.length ? confidences.reduce((sum, value) => sum + value, 0) / confidences.length : null,
|
|
76
|
+
x: x1,
|
|
77
|
+
y: y1,
|
|
78
|
+
width: x2 - x1,
|
|
79
|
+
height: y2 - y1,
|
|
80
|
+
words: group.length,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
return { words, lines, totalWords };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function ocrImage(imageBuffer, options = {}) {
|
|
87
|
+
if (!ocrCapabilityAvailable()) return { available: false, reason: 'tesseract is not installed on this computer' };
|
|
88
|
+
const data = Buffer.isBuffer(imageBuffer) ? imageBuffer : Buffer.from(imageBuffer || []);
|
|
89
|
+
if (!data.length) return { available: false, reason: 'no screenshot image was available for OCR' };
|
|
90
|
+
if (data.length > MAX_IMAGE_BYTES) throw new Error(`OCR image is too large (${data.length} bytes; maximum ${MAX_IMAGE_BYTES})`);
|
|
91
|
+
|
|
92
|
+
const language = safeLanguage(options.language);
|
|
93
|
+
const psm = clamp(options.psm, 11, 3, 13);
|
|
94
|
+
const maxWords = clamp(options.max_words, 2000, 1, 10_000);
|
|
95
|
+
const dir = await tempDir('remcp-ocr-');
|
|
96
|
+
const input = path.join(dir, 'screen.png');
|
|
97
|
+
try {
|
|
98
|
+
await writeFile(input, data, { mode: 0o600 });
|
|
99
|
+
const result = await runFile('tesseract', [input, 'stdout', '-l', language, '--psm', String(psm), 'tsv'], {
|
|
100
|
+
label: 'OCR',
|
|
101
|
+
timeout: clamp(options.timeout_ms, 30_000, 1000, 120_000),
|
|
102
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
103
|
+
allowFailure: true,
|
|
104
|
+
});
|
|
105
|
+
if (result.code !== 0) {
|
|
106
|
+
const detail = String(result.stderr || result.stdout || `exit ${result.code}`).trim().slice(0, 1000);
|
|
107
|
+
return { available: false, reason: `tesseract failed: ${detail}` };
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseTesseractTsv(result.stdout, maxWords);
|
|
110
|
+
return {
|
|
111
|
+
available: true,
|
|
112
|
+
backend: 'tesseract',
|
|
113
|
+
language,
|
|
114
|
+
text: parsed.lines.map(line => line.text).join('\n'),
|
|
115
|
+
count: parsed.words.length,
|
|
116
|
+
total_words: parsed.totalWords,
|
|
117
|
+
truncated: parsed.totalWords > parsed.words.length,
|
|
118
|
+
words: parsed.words,
|
|
119
|
+
lines: parsed.lines,
|
|
120
|
+
};
|
|
121
|
+
} finally {
|
|
122
|
+
await removeTemp(dir);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function findOcrText(ocr, needle) {
|
|
127
|
+
if (!ocr?.available) return null;
|
|
128
|
+
const wanted = normalizeText(needle);
|
|
129
|
+
if (!wanted) return null;
|
|
130
|
+
const lineMatches = (ocr.lines || []).filter(line => normalizeText(line.text).includes(wanted));
|
|
131
|
+
if (lineMatches.length) {
|
|
132
|
+
return [...lineMatches].sort((a, b) => (Number(b.confidence) || -1) - (Number(a.confidence) || -1))[0];
|
|
133
|
+
}
|
|
134
|
+
const wordMatches = (ocr.words || []).filter(word => normalizeText(word.text).includes(wanted));
|
|
135
|
+
if (wordMatches.length) {
|
|
136
|
+
return [...wordMatches].sort((a, b) => (Number(b.confidence) || -1) - (Number(a.confidence) || -1))[0];
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|