file-toolkit-mcp 1.0.0
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/LICENSE +21 -0
- package/README.md +79 -0
- package/bin/file-toolkit-mcp.js +6 -0
- package/package.json +40 -0
- package/src/lib/batch.js +53 -0
- package/src/lib/csv.js +178 -0
- package/src/lib/dataops.js +78 -0
- package/src/lib/encoding.js +66 -0
- package/src/lib/errors.js +98 -0
- package/src/lib/image.js +331 -0
- package/src/lib/inspect.js +108 -0
- package/src/lib/paths.js +231 -0
- package/src/lib/pdf.js +328 -0
- package/src/lib/registry.js +53 -0
- package/src/lib/rename.js +285 -0
- package/src/lib/result.js +86 -0
- package/src/lib/text.js +80 -0
- package/src/server.js +60 -0
- package/src/tools/data-tools.js +497 -0
- package/src/tools/file-tools.js +348 -0
- package/src/tools/image-tools.js +294 -0
- package/src/tools/pdf-tools.js +335 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { defineTool } from '../lib/registry.js';
|
|
5
|
+
import { runBatch } from '../lib/batch.js';
|
|
6
|
+
import {
|
|
7
|
+
expandInputs,
|
|
8
|
+
resolveOutputDir,
|
|
9
|
+
uniquePath,
|
|
10
|
+
assertNotOverwritingInput,
|
|
11
|
+
displayPath,
|
|
12
|
+
} from '../lib/paths.js';
|
|
13
|
+
import { mergePdfs, splitPdf, pdfToImages } from '../lib/pdf.js';
|
|
14
|
+
import { imagesToPdf, IMAGE_EXTENSIONS } from '../lib/image.js';
|
|
15
|
+
import { buildResult, mdTable, summarize, formatBytes } from '../lib/result.js';
|
|
16
|
+
import { describeError, invalid } from '../lib/errors.js';
|
|
17
|
+
|
|
18
|
+
const OUTPUT_DIRECTORY = z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('输出目录。不填则默认写到第一个文件所在目录下的 file-toolkit-output 文件夹,原文件不会被改动。');
|
|
22
|
+
|
|
23
|
+
function safeName(name, fallback) {
|
|
24
|
+
const cleaned = String(name ?? '')
|
|
25
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '')
|
|
26
|
+
.trim();
|
|
27
|
+
return cleaned === '' ? fallback : cleaned;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerPdfTools(server) {
|
|
31
|
+
defineTool(server, {
|
|
32
|
+
name: 'pdf_split',
|
|
33
|
+
title: '拆分 PDF',
|
|
34
|
+
action: '拆分 PDF',
|
|
35
|
+
description:
|
|
36
|
+
'把一个 PDF 拆成多个。three 种方式:each 逐页拆成单独的 PDF;range 只保留指定页码(例如 "1-3,5");chunk 每 N 页存成一个文件。支持一次处理多个 PDF。合并 PDF 请用 pdf_merge。',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
input_files: z
|
|
39
|
+
.array(z.string())
|
|
40
|
+
.describe('要拆分的 PDF 路径,可以传文件或文件夹,支持一次多个。'),
|
|
41
|
+
mode: z
|
|
42
|
+
.enum(['each', 'range', 'chunk'])
|
|
43
|
+
.optional()
|
|
44
|
+
.describe(
|
|
45
|
+
'拆分方式。each=每一页拆成独立 PDF(用户说"每一页拆开"时用);range=只取指定页码;chunk=每 N 页一组。默认 each。',
|
|
46
|
+
),
|
|
47
|
+
pages: z
|
|
48
|
+
.string()
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('mode=range 时必填。页码表达式,如 "1-3,5"、"2-"(第2页到末页)、"-3"(前3页)。'),
|
|
51
|
+
chunk_size: z.number().optional().describe('mode=chunk 时每份包含的页数,默认 1。'),
|
|
52
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
53
|
+
},
|
|
54
|
+
handler: async (args) => {
|
|
55
|
+
const { input_files, mode = 'each', pages, chunk_size = 1, output_directory } = args;
|
|
56
|
+
|
|
57
|
+
if (mode === 'range' && (pages === undefined || String(pages).trim() === '')) {
|
|
58
|
+
throw invalid('按页码拆分时必须告诉我要哪些页', '例如 pages="1-3,5"。');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
62
|
+
extensions: ['pdf'],
|
|
63
|
+
label: 'PDF',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
67
|
+
|
|
68
|
+
const results = await runBatch(files, async (file) => {
|
|
69
|
+
const stem = safeName(path.basename(file, path.extname(file)), 'document');
|
|
70
|
+
const parts = await splitPdf(file, {
|
|
71
|
+
mode,
|
|
72
|
+
pages,
|
|
73
|
+
chunkSize: chunk_size,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const written = [];
|
|
77
|
+
for (const part of parts) {
|
|
78
|
+
const target = await uniquePath(outputDir, `${stem}-${part.name}.pdf`);
|
|
79
|
+
assertNotOverwritingInput(target, files);
|
|
80
|
+
await fs.writeFile(target, part.buffer);
|
|
81
|
+
written.push({
|
|
82
|
+
outPath: target,
|
|
83
|
+
bytes: part.buffer.length,
|
|
84
|
+
pageCount: part.pageCount,
|
|
85
|
+
fromPage: part.fromPage,
|
|
86
|
+
toPage: part.toPage,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return { parts: written, partCount: written.length };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const rows = [];
|
|
93
|
+
for (const r of results) {
|
|
94
|
+
if (!r.ok) {
|
|
95
|
+
rows.push([path.basename(r.file), '—', '—', describeError(r.error, { action: '拆分' })]);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
rows.push([
|
|
99
|
+
path.basename(r.file),
|
|
100
|
+
`${r.partCount} 份`,
|
|
101
|
+
r.parts.map((p) => `第${p.fromPage}${p.fromPage === p.toPage ? '' : `-${p.toPage}`}页`).join('、').slice(0, 40),
|
|
102
|
+
displayPath(path.dirname(r.parts[0]?.outPath ?? outputDir)),
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const notes = [];
|
|
107
|
+
if (mode === 'each') {
|
|
108
|
+
notes.push('已按每页一个文件拆分,文件名格式为「原名-page-页码.pdf」。');
|
|
109
|
+
} else if (mode === 'range') {
|
|
110
|
+
notes.push(`已按指定页码 ${pages} 提取。`);
|
|
111
|
+
} else {
|
|
112
|
+
notes.push(`已按每 ${chunk_size} 页一份拆分。`);
|
|
113
|
+
}
|
|
114
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
115
|
+
|
|
116
|
+
const totalParts = results.filter((r) => r.ok).reduce((sum, r) => sum + r.partCount, 0);
|
|
117
|
+
|
|
118
|
+
return buildResult({
|
|
119
|
+
title: `✂️ ${summarize('PDF 拆分', results)}共生成 ${totalParts} 个文件。`,
|
|
120
|
+
table: mdTable(['原文件', '拆出份数', '页码范围', '输出目录'], rows),
|
|
121
|
+
notes,
|
|
122
|
+
outputDir: displayPath(outputDir),
|
|
123
|
+
data: {
|
|
124
|
+
sourceFiles: results.length,
|
|
125
|
+
generatedFiles: totalParts,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
defineTool(server, {
|
|
132
|
+
name: 'pdf_merge',
|
|
133
|
+
title: '合并 PDF',
|
|
134
|
+
action: '合并 PDF',
|
|
135
|
+
description:
|
|
136
|
+
'把多个 PDF 按给定顺序合并成一个。input_files 的先后顺序就是要拼接的顺序,所以用户说"把 A 放在最前面"时,把 A 排在数组第一位。',
|
|
137
|
+
inputSchema: {
|
|
138
|
+
input_files: z
|
|
139
|
+
.array(z.string())
|
|
140
|
+
.describe('要合并的 PDF 路径列表,顺序即拼接顺序。至少 2 个。如果这里传的是文件夹,会按文件名顺序合并。'),
|
|
141
|
+
output_name: z
|
|
142
|
+
.string()
|
|
143
|
+
.optional()
|
|
144
|
+
.describe('输出的文件名,例如 "合同合集.pdf"。不填则叫 merged.pdf。'),
|
|
145
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
146
|
+
},
|
|
147
|
+
handler: async (args) => {
|
|
148
|
+
const { input_files, output_name, output_directory } = args;
|
|
149
|
+
|
|
150
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
151
|
+
extensions: ['pdf'],
|
|
152
|
+
label: 'PDF',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (files.length < 2) {
|
|
156
|
+
throw invalid(
|
|
157
|
+
`合并至少需要 2 个 PDF,现在只找到 ${files.length} 个`,
|
|
158
|
+
'请确认这些路径下都有 .pdf 文件。',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
163
|
+
|
|
164
|
+
const merged = await mergePdfs(files);
|
|
165
|
+
|
|
166
|
+
const requested = safeName(output_name, 'merged');
|
|
167
|
+
const fileName = requested.toLowerCase().endsWith('.pdf') ? requested : `${requested}.pdf`;
|
|
168
|
+
const target = await uniquePath(outputDir, fileName);
|
|
169
|
+
assertNotOverwritingInput(target, files);
|
|
170
|
+
await fs.writeFile(target, merged);
|
|
171
|
+
|
|
172
|
+
const totalBytes = files.reduce(async (accP, f) => {
|
|
173
|
+
const acc = await accP;
|
|
174
|
+
return acc + (await fs.stat(f)).size;
|
|
175
|
+
}, Promise.resolve(0));
|
|
176
|
+
|
|
177
|
+
const rows = files.map((f, i) => [String(i + 1), path.basename(f), displayPath(path.dirname(f))]);
|
|
178
|
+
|
|
179
|
+
const notes = [`合并顺序即上表顺序。合并后的文件:\`${displayPath(target)}\``];
|
|
180
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
181
|
+
|
|
182
|
+
return buildResult({
|
|
183
|
+
title: `📎 合并完成:${files.length} 个 PDF 已合成 1 个文件(${formatBytes(merged.length)})。`,
|
|
184
|
+
table: mdTable(['序号', '文件', '所在位置'], rows),
|
|
185
|
+
notes,
|
|
186
|
+
outputDir: displayPath(outputDir),
|
|
187
|
+
data: {
|
|
188
|
+
sourceCount: files.length,
|
|
189
|
+
outputPath: target,
|
|
190
|
+
outputBytes: merged.length,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
defineTool(server, {
|
|
197
|
+
name: 'pdf_to_image',
|
|
198
|
+
title: 'PDF 转图片',
|
|
199
|
+
action: 'PDF 转图片',
|
|
200
|
+
description:
|
|
201
|
+
'把 PDF 的页面渲染成 png 或 jpg 图片。可以只转指定页(例如"第 1、3、5 页"),也可以指定分辨率 dpi(72 屏幕预览、150 普通、300 印刷)。支持一次处理多个 PDF。反向操作用 image_to_pdf。',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
input_files: z.array(z.string()).describe('要转换的 PDF 路径,可以传文件或文件夹,支持多个。'),
|
|
204
|
+
pages: z
|
|
205
|
+
.string()
|
|
206
|
+
.optional()
|
|
207
|
+
.describe('只转这些页,例如 "1,3,5" 或 "1-3"。不填则转换全部页面。'),
|
|
208
|
+
dpi: z
|
|
209
|
+
.number()
|
|
210
|
+
.optional()
|
|
211
|
+
.describe('输出分辨率,默认 150。数值越大越清晰、文件越大(72 屏幕用、150 普通、300 印刷级)。'),
|
|
212
|
+
format: z.enum(['png', 'jpg']).optional().describe('输出图片格式,默认 png。'),
|
|
213
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
214
|
+
},
|
|
215
|
+
handler: async (args) => {
|
|
216
|
+
const { input_files, pages, dpi = 150, format = 'png', output_directory } = args;
|
|
217
|
+
|
|
218
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
219
|
+
extensions: ['pdf'],
|
|
220
|
+
label: 'PDF',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
224
|
+
const ext = format === 'jpg' ? 'jpg' : 'png';
|
|
225
|
+
|
|
226
|
+
const results = await runBatch(files, async (file) => {
|
|
227
|
+
const stem = safeName(path.basename(file, path.extname(file)), 'document');
|
|
228
|
+
const images = await pdfToImages(file, { pages: pages ?? null, dpi, format });
|
|
229
|
+
const written = [];
|
|
230
|
+
for (const image of images) {
|
|
231
|
+
const target = await uniquePath(outputDir, `${stem}-${image.name}.${ext}`);
|
|
232
|
+
assertNotOverwritingInput(target, files);
|
|
233
|
+
await fs.writeFile(target, image.buffer);
|
|
234
|
+
written.push({
|
|
235
|
+
outPath: target,
|
|
236
|
+
page: image.page,
|
|
237
|
+
width: image.width,
|
|
238
|
+
height: image.height,
|
|
239
|
+
bytes: image.buffer.length,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return { images: written };
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const rows = results.map((r) => {
|
|
246
|
+
if (!r.ok) {
|
|
247
|
+
return [path.basename(r.file), '—', '—', describeError(r.error, { action: '转换' })];
|
|
248
|
+
}
|
|
249
|
+
const first = r.images[0];
|
|
250
|
+
return [
|
|
251
|
+
path.basename(r.file),
|
|
252
|
+
`${r.images.length} 张`,
|
|
253
|
+
first ? `${first.width}×${first.height}` : '—',
|
|
254
|
+
displayPath(outputDir),
|
|
255
|
+
];
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const totalImages = results.filter((r) => r.ok).reduce((s, r) => s + r.images.length, 0);
|
|
259
|
+
const notes = [`输出为 ${ext.toUpperCase()},分辨率 ${dpi} dpi,文件名格式「原名-page-页码.${ext}」。`];
|
|
260
|
+
if (pages) notes.push(`只转换了指定页码:${pages}`);
|
|
261
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
262
|
+
|
|
263
|
+
return buildResult({
|
|
264
|
+
title: `🖼️ ${summarize('PDF 转图片', results)}共生成 ${totalImages} 张图片。`,
|
|
265
|
+
table: mdTable(['原文件', '生成张数', '首张尺寸', '输出目录'], rows),
|
|
266
|
+
notes,
|
|
267
|
+
outputDir: displayPath(outputDir),
|
|
268
|
+
data: {
|
|
269
|
+
sourceFiles: results.length,
|
|
270
|
+
generatedImages: totalImages,
|
|
271
|
+
dpi,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
defineTool(server, {
|
|
278
|
+
name: 'image_to_pdf',
|
|
279
|
+
title: '图片转 PDF',
|
|
280
|
+
action: '图片转 PDF',
|
|
281
|
+
description:
|
|
282
|
+
'把一张或多张图片合成 PDF。多张图片会按传入顺序依次成为 PDF 的每一页,所以用户说"按文件名顺序合并"时,把路径按顺序传进来。document 尺寸可选跟随图片或 A4。反向操作用 pdf_to_image。',
|
|
283
|
+
inputSchema: {
|
|
284
|
+
input_files: z
|
|
285
|
+
.array(z.string())
|
|
286
|
+
.describe('图片路径列表,顺序即 PDF 的页面顺序。可以传文件夹(会按文件名顺序排列)。'),
|
|
287
|
+
output_name: z.string().optional().describe('输出文件名,例如 "作品集.pdf"。单张图片时默认沿用图片名。'),
|
|
288
|
+
page_size: z
|
|
289
|
+
.enum(['fit', 'a4'])
|
|
290
|
+
.optional()
|
|
291
|
+
.describe('fit=页面大小跟随图片(默认);a4=统一放到 A4 页面上居中。'),
|
|
292
|
+
margin: z.number().optional().describe('页边距,单位像素,默认 0。'),
|
|
293
|
+
output_directory: OUTPUT_DIRECTORY,
|
|
294
|
+
},
|
|
295
|
+
handler: async (args) => {
|
|
296
|
+
const { input_files, output_name, page_size = 'fit', margin = 0, output_directory } = args;
|
|
297
|
+
|
|
298
|
+
const { files, warnings } = await expandInputs(input_files, {
|
|
299
|
+
extensions: IMAGE_EXTENSIONS,
|
|
300
|
+
label: '图片',
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const outputDir = await resolveOutputDir({ inputs: files, outputDirectory: output_directory });
|
|
304
|
+
|
|
305
|
+
const buffer = await imagesToPdf(files, { pageSize: page_size, margin });
|
|
306
|
+
|
|
307
|
+
const fallback = files.length === 1
|
|
308
|
+
? safeName(path.basename(files[0], path.extname(files[0])), 'document')
|
|
309
|
+
: 'merged';
|
|
310
|
+
const requested = safeName(output_name, fallback);
|
|
311
|
+
const fileName = requested.toLowerCase().endsWith('.pdf') ? requested : `${requested}.pdf`;
|
|
312
|
+
const target = await uniquePath(outputDir, fileName);
|
|
313
|
+
assertNotOverwritingInput(target, files);
|
|
314
|
+
await fs.writeFile(target, buffer);
|
|
315
|
+
|
|
316
|
+
const rows = files.map((f, i) => [String(i + 1), path.basename(f)]);
|
|
317
|
+
const notes = [
|
|
318
|
+
`${files.length} 张图片已合成 PDF,共 ${files.length} 页。页面尺寸:${page_size === 'a4' ? 'A4' : '跟随图片'}。`,
|
|
319
|
+
];
|
|
320
|
+
for (const w of warnings) notes.push(`· ${w}`);
|
|
321
|
+
|
|
322
|
+
return buildResult({
|
|
323
|
+
title: `📄 图片转 PDF 完成:${files.length} 张图 → 1 个文件(${formatBytes(buffer.length)})。`,
|
|
324
|
+
table: mdTable(['页码', '来源图片'], rows),
|
|
325
|
+
notes,
|
|
326
|
+
outputDir: displayPath(outputDir),
|
|
327
|
+
data: {
|
|
328
|
+
imageCount: files.length,
|
|
329
|
+
outputPath: target,
|
|
330
|
+
outputBytes: buffer.length,
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
}
|