dsh-file-convert 0.4.0 → 0.4.1
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 +20 -1
- package/README.zh-CN.md +23 -2
- package/lib/core/binaries/download.d.ts +9 -0
- package/lib/core/binaries/download.js +34 -5
- package/lib/core/converters/data.js +2 -1
- package/lib/core/converters/image.js +18 -3
- package/lib/core/converters/office.js +18 -6
- package/lib/core/converters/pdf.js +39 -3
- package/lib/core/index.d.ts +2 -0
- package/lib/core/index.js +2 -0
- package/lib/core/router.js +42 -74
- package/lib/core/utils/path-guard.d.ts +13 -0
- package/lib/core/utils/path-guard.js +53 -0
- package/lib/core/utils/write-file.d.ts +6 -0
- package/lib/core/utils/write-file.js +25 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +3 -0
- package/lib/tools/install-media.js +5 -1
- package/lib/tools/optimize-file.js +9 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,12 @@ dsh plugin --profile default add github:zzy-12345678/dsh-file-convert
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Git installs may ask you to allow the build step in the profile's `pnpm-workspace.yaml`:
|
|
57
|
+
> **First-install note**: pnpm may also ask you to decide on a build script for `tesseract.js` (it only shows a donation notice — skipping is safe). Set it in the same file and re-run the install:
|
|
58
|
+
>
|
|
59
|
+
> ```yaml
|
|
60
|
+
> allowBuilds:
|
|
61
|
+
> tesseract.js: false
|
|
62
|
+
> ```
|
|
57
63
|
|
|
58
64
|
```yaml
|
|
59
65
|
allowBuilds:
|
|
@@ -163,6 +169,8 @@ All 26 conversions with their live availability on *this* machine — unavailabl
|
|
|
163
169
|
| `maxInputMb` | `2048` | Refuse inputs above this size (MB) |
|
|
164
170
|
| `maxPdfPages` | `200` | Full-document PDF rasterization refuses more pages; use `pages` for larger documents |
|
|
165
171
|
| `maxOutputPixels` | `16000000` | Clamp rasterized pixels per page (width × height) to this budget |
|
|
172
|
+
|
|
173
|
+
**Limit coverage**: `maxInputMb` applies to convert, batch, inspect and optimize alike; `maxPdfPages` applies to PDF rasterization and text extraction (both implicit full-document runs and explicit `pages` selections); `maxOutputPixels` applies to PDF rasterization, OCR rendering and SVG rasterization; `timeoutMs` truly cancels every conversion (the underlying work stops, it is not merely abandoned).
|
|
166
174
|
| `batchMaxFiles` | `500` | Max files examined per `batch_convert` run; beyond it the summary reports what was skipped instead of silently capping |
|
|
167
175
|
| `outputRoots` | `[]` | When non-empty, explicit `output` paths must resolve inside one of these directories (recommended for shared deployments; the default next-to-input output is always exempt) |
|
|
168
176
|
| `ffmpegPath` / `ffprobePath` | - | Explicit binary paths when ffmpeg is not on PATH (common on Windows) |
|
|
@@ -201,7 +209,7 @@ All 26 conversions with their live availability on *this* machine — unavailabl
|
|
|
201
209
|
```sh
|
|
202
210
|
npm install
|
|
203
211
|
npm run build # tsc -> lib/
|
|
204
|
-
npm test # vitest,
|
|
212
|
+
npm test # vitest, 63 tests (plus a few environment-gated suites)
|
|
205
213
|
npm run smoke # end-to-end against lib/
|
|
206
214
|
```
|
|
207
215
|
|
|
@@ -213,6 +221,17 @@ Add a conversion = add one capability row + implement it in a converter. Add a b
|
|
|
213
221
|
- **Cached ffmpeg builds**: the download convenience installs pinned sha256-verified FFmpeg 6.1.1 static builds. For untrusted media, a current system FFmpeg takes priority — prefer it in security-sensitive setups.
|
|
214
222
|
- **Not a sandbox**: `outputRoots` resolves symlinks and resource limits (`maxInputMb`, `maxPdfPages`, `maxOutputPixels`, `batchMaxFiles`) cap runaway jobs, but the default next-to-input output is intentionally exempt from roots, and an agent that may write files can always write somewhere. For hostile multi-tenant use, add OS-level isolation on top.
|
|
215
223
|
|
|
224
|
+
## Compatibility
|
|
225
|
+
|
|
226
|
+
| Component | Verified version |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| DeepSeek Harness | 0.1.1-rc.2 |
|
|
229
|
+
| @deepseek-ai/dsh-tools | 0.0.1-rc.1 |
|
|
230
|
+
| @deepseek-ai/cordis | 4.0.1 |
|
|
231
|
+
| Node.js | >= 20 (CI covers 22) |
|
|
232
|
+
|
|
233
|
+
DSH is a developer preview and its APIs will move - the plugin keeps all DSH imports inside a thin glue layer so adapting stays cheap.
|
|
234
|
+
|
|
216
235
|
## Roadmap
|
|
217
236
|
|
|
218
237
|
- ~~V0.2 — Media (FFmpeg)~~ **shipped**: MP4→GIF/MP3, WAV→MP3, MOV→MP4, plus `optimize_file` with target-size two-pass encoding.
|
package/README.zh-CN.md
CHANGED
|
@@ -54,6 +54,14 @@ dsh plugin --profile default add github:zzy-12345678/dsh-file-convert
|
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
Git 安装可能需要在 profile 的 `pnpm-workspace.yaml` 里放行构建步骤:
|
|
57
|
+
> **首次安装提示**:pnpm 还可能要求你决定 `tesseract.js` 的构建脚本是否执行(它只是捐赠提示,跳过毫无影响)。在同一个文件里加上:
|
|
58
|
+
>
|
|
59
|
+
> ```yaml
|
|
60
|
+
> allowBuilds:
|
|
61
|
+
> tesseract.js: false
|
|
62
|
+
> ```
|
|
63
|
+
>
|
|
64
|
+
> 然后重新执行安装命令即可。
|
|
57
65
|
|
|
58
66
|
```yaml
|
|
59
67
|
allowBuilds:
|
|
@@ -121,7 +129,7 @@ PDF 走 Ghostscript 三档预设(printer/ebook/screen)自动迭代:某档
|
|
|
121
129
|
|
|
122
130
|
下载固定的 FFmpeg 6.1.1 静态构建(ffmpeg + ffprobe 两个 ~28 MB 文件)到插件缓存(`~/.dsh-file-convert/bin`),校验钉死的 sha256,并真实执行一次二进制确认可用后才报告成功。npmmirror 二进制 CDN 优先,GitHub release 为字节一致的回退源。系统安装的 ffmpeg 优先于缓存。体积可观,调用前请先征得用户同意。
|
|
123
131
|
|
|
124
|
-
|
|
132
|
+
下载钉死 sha256 的 FFmpeg 6.1.1 静态构建(ffmpeg + ffprobe,各 ~28 MB)到插件缓存(`~/.dsh-file-convert/bin`),并真实执行一次二进制确认可用后才报告成功。npmmirror 二进制 CDN 优先,GitHub release 为字节一致的回退源。系统安装的 ffmpeg 优先于缓存。体积可观,调用前请先征得用户同意。
|
|
125
133
|
|
|
126
134
|
### `install_ocr_dependencies` —— 一键补齐 OCR 语言包
|
|
127
135
|
|
|
@@ -141,6 +149,8 @@ PDF 走 Ghostscript 三档预设(printer/ebook/screen)自动迭代:某档
|
|
|
141
149
|
| `maxInputMb` | `2048` | 拒绝超过此大小(MB)的输入 |
|
|
142
150
|
| `maxPdfPages` | `200` | 整本 PDF 光栅化超过此页数即拒绝;更大的文档请用 `pages` 选择 |
|
|
143
151
|
| `maxOutputPixels` | `16000000` | 每页光栅化像素(宽 × 高)钳制到此预算 |
|
|
152
|
+
|
|
153
|
+
**限制覆盖范围**:`maxInputMb` 作用于 convert / batch / inspect / optimize 全部入口;`maxPdfPages` 作用于 PDF 光栅化与文本提取(隐式整本与显式 `pages` 选择都受限);`maxOutputPixels` 作用于 PDF 光栅化、OCR 渲染和 SVG 光栅化;`timeoutMs` 对所有转换真取消(超时会中止底层工作,不只是提前返回)。
|
|
144
154
|
| `batchMaxFiles` | `500` | 每次 `batch_convert` 最多检查的文件数;超出时会在结果里明确报告跳过了多少,而不是静默截断 |
|
|
145
155
|
| `outputRoots` | `[]` | 非空时,显式指定的 `output` 路径必须落在这些目录之内(共享部署建议开启;默认写到输入文件旁的输出不受限) |
|
|
146
156
|
| `ffmpegPath` / `ffprobePath` | - | ffmpeg 不在 PATH 时(Windows 常见)手动指定二进制路径 |
|
|
@@ -179,7 +189,7 @@ PDF 走 Ghostscript 三档预设(printer/ebook/screen)自动迭代:某档
|
|
|
179
189
|
```sh
|
|
180
190
|
npm install
|
|
181
191
|
npm run build # tsc -> lib/
|
|
182
|
-
npm test # vitest,
|
|
192
|
+
npm test # vitest,63 项测试(另有个位数按环境门控的套件)
|
|
183
193
|
npm run smoke # 针对 lib/ 的端到端冒烟测试
|
|
184
194
|
```
|
|
185
195
|
|
|
@@ -191,6 +201,17 @@ npm run smoke # 针对 lib/ 的端到端冒烟测试
|
|
|
191
201
|
- **缓存版 ffmpeg**:便捷下载安装的是钉死 sha256 的 FFmpeg 6.1.1 静态构建。处理不可信媒体时,新版系统 FFmpeg 优先——安全敏感场景请优先系统安装。
|
|
192
202
|
- **不是沙箱**:`outputRoots` 会解析符号链接,资源上限(`maxInputMb`、`maxPdfPages`、`maxOutputPixels`、`batchMaxFiles`)能拦住失控任务,但默认"写到输入文件旁"的输出有意不受 roots 约束,且能写文件的 Agent 总能找到地方写。敌对多租户场景请在操作系统层再加隔离。
|
|
193
203
|
|
|
204
|
+
## 兼容性
|
|
205
|
+
|
|
206
|
+
| 组件 | 已验证版本 |
|
|
207
|
+
| --- | --- |
|
|
208
|
+
| DeepSeek Harness | 0.1.1-rc.2 |
|
|
209
|
+
| @deepseek-ai/dsh-tools | 0.0.1-rc.1 |
|
|
210
|
+
| @deepseek-ai/cordis | 4.0.1 |
|
|
211
|
+
| Node.js | ≥ 20(CI 覆盖 22) |
|
|
212
|
+
|
|
213
|
+
DSH 仍处于 developer preview,API 可能变化——插件把 DSH 依赖隔离在薄胶水层,适配成本被刻意压低。
|
|
214
|
+
|
|
194
215
|
## Roadmap
|
|
195
216
|
|
|
196
217
|
- ~~V0.2 —— 音视频(FFmpeg)~~ **已发布**:MP4→GIF/MP3、WAV→MP3、MOV→MP4,以及按目标体积两遍编码的 `optimize_file`。
|
|
@@ -19,3 +19,12 @@ export declare function downloadBinary(dep: BinaryDependency, opts: {
|
|
|
19
19
|
signal?: AbortSignal;
|
|
20
20
|
force?: boolean;
|
|
21
21
|
}): Promise<DownloadOutcome>;
|
|
22
|
+
export interface CacheManifestEntry {
|
|
23
|
+
version: string;
|
|
24
|
+
sha256: string;
|
|
25
|
+
bytes: number;
|
|
26
|
+
installedAt: string;
|
|
27
|
+
}
|
|
28
|
+
export type CacheManifest = Record<string, CacheManifestEntry>;
|
|
29
|
+
export declare function manifestPath(): string;
|
|
30
|
+
export declare function readCacheManifest(): Promise<CacheManifest>;
|
|
@@ -7,7 +7,8 @@ import { Readable } from 'node:stream';
|
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
8
|
import { createWriteStream } from 'node:fs';
|
|
9
9
|
import { execTool } from '../utils/exec.js';
|
|
10
|
-
import {
|
|
10
|
+
import { writeFileAtomic } from '../utils/write-file.js';
|
|
11
|
+
import { cachedBinaryPath, cacheDir } from './cache.js';
|
|
11
12
|
/**
|
|
12
13
|
* Where each external binary comes from when the machine has none: pinned
|
|
13
14
|
* single-binary FFmpeg 6.1.1 builds from the ffmpeg-static project, served by
|
|
@@ -88,12 +89,24 @@ export async function downloadBinary(dep, opts) {
|
|
|
88
89
|
const gzPath = path.join(tmpDir, 'bin.gz');
|
|
89
90
|
const bytes = await downloadTo(url, gzPath, opts.timeoutMs, opts.signal);
|
|
90
91
|
verifyDigest(await fs.readFile(gzPath), entry.sha256);
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
// Prove the binary inside the scratch dir first, then publish it into
|
|
93
|
+
// the cache - a failed download never leaves a half-written target.
|
|
94
|
+
const candidate = path.join(tmpDir, 'bin');
|
|
95
|
+
await fs.writeFile(candidate, gunzipSync(await fs.readFile(gzPath)));
|
|
93
96
|
if (process.platform !== 'win32')
|
|
94
|
-
await fs.chmod(
|
|
95
|
-
const { stdout, stderr } = await execTool(
|
|
97
|
+
await fs.chmod(candidate, 0o755);
|
|
98
|
+
const { stdout, stderr } = await execTool(candidate, ['-version'], { timeoutMs: 15_000, signal: opts.signal });
|
|
96
99
|
const versionLine = (stdout || stderr).split(/\r?\n/, 1)[0]?.trim() ?? '';
|
|
100
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
101
|
+
await fs.rename(candidate, target).catch(async () => {
|
|
102
|
+
await fs.copyFile(candidate, target);
|
|
103
|
+
});
|
|
104
|
+
await writeManifestEntry(dep.name, {
|
|
105
|
+
version: versionLine,
|
|
106
|
+
sha256: entry.sha256,
|
|
107
|
+
bytes,
|
|
108
|
+
installedAt: new Date().toISOString(),
|
|
109
|
+
});
|
|
97
110
|
return { path: target, bytes, versionLine };
|
|
98
111
|
}
|
|
99
112
|
catch (err) {
|
|
@@ -106,6 +119,22 @@ export async function downloadBinary(dep, opts) {
|
|
|
106
119
|
}
|
|
107
120
|
throw new DownloadError('network', `All mirrors failed for ${dep.name}: ${errors.join(' | ')}`);
|
|
108
121
|
}
|
|
122
|
+
export function manifestPath() {
|
|
123
|
+
return path.join(cacheDir(), 'manifest.json');
|
|
124
|
+
}
|
|
125
|
+
export async function readCacheManifest() {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(await fs.readFile(manifestPath(), 'utf8'));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function writeManifestEntry(name, entry) {
|
|
134
|
+
const manifest = await readCacheManifest();
|
|
135
|
+
manifest[name] = entry;
|
|
136
|
+
await writeFileAtomic(manifestPath(), JSON.stringify(manifest, null, 2));
|
|
137
|
+
}
|
|
109
138
|
async function downloadTo(url, target, timeoutMs, signal) {
|
|
110
139
|
let res;
|
|
111
140
|
try {
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import { parse as csvParse } from 'csv-parse/sync';
|
|
3
3
|
import { stringify as csvStringify } from 'csv-stringify/sync';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
|
+
import { writeFileAtomic } from '../utils/write-file.js';
|
|
5
6
|
import { convertError } from '../errors.js';
|
|
6
7
|
/**
|
|
7
8
|
* JSON / YAML / CSV share one intermediate representation (a plain JS value):
|
|
@@ -39,7 +40,7 @@ export class DataConverter {
|
|
|
39
40
|
const bytesIn = Buffer.byteLength(raw);
|
|
40
41
|
const value = this.read(from, raw, req.options.delimiter);
|
|
41
42
|
const text = this.write(to, value, req.options.indent);
|
|
42
|
-
await
|
|
43
|
+
await writeFileAtomic(req.output, text);
|
|
43
44
|
return {
|
|
44
45
|
ok: true,
|
|
45
46
|
input: req.input,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import sharp from 'sharp';
|
|
3
|
+
import { writeFileAtomic } from '../utils/write-file.js';
|
|
3
4
|
import { convertError } from '../errors.js';
|
|
4
5
|
const IMAGE_INPUTS = ['png', 'jpg', 'webp', 'svg'];
|
|
5
6
|
const IMAGE_OUTPUTS = ['png', 'jpg', 'webp'];
|
|
@@ -21,7 +22,20 @@ export class ImageConverter {
|
|
|
21
22
|
const bytesIn = (await fs.stat(req.input)).size;
|
|
22
23
|
// density only affects vector input (SVG): 72 renders at the SVG's own
|
|
23
24
|
// pixel size; a higher dpi option upscales. Rasters ignore it.
|
|
24
|
-
|
|
25
|
+
// A hostile SVG can declare enormous dimensions - clamp the raster to
|
|
26
|
+
// the pixel budget before decoding.
|
|
27
|
+
let density = req.options.dpi ?? 72;
|
|
28
|
+
const maxOutputPixels = ctx.limits?.maxOutputPixels;
|
|
29
|
+
if (req.from === 'svg' && maxOutputPixels) {
|
|
30
|
+
const meta = await sharp(req.input).metadata();
|
|
31
|
+
const factor = density / 72;
|
|
32
|
+
const pixels = (meta.width ?? 1000) * factor * ((meta.height ?? 1000) * factor);
|
|
33
|
+
if (pixels > maxOutputPixels) {
|
|
34
|
+
const shrink = Math.sqrt(maxOutputPixels / pixels);
|
|
35
|
+
density = Math.max(1, Math.floor(density * shrink));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const pipeline = sharp(req.input, { density });
|
|
25
39
|
const { quality, background } = req.options;
|
|
26
40
|
let out;
|
|
27
41
|
if (req.to === 'jpg') {
|
|
@@ -35,8 +49,9 @@ export class ImageConverter {
|
|
|
35
49
|
else {
|
|
36
50
|
out = pipeline.png();
|
|
37
51
|
}
|
|
38
|
-
await out.
|
|
39
|
-
|
|
52
|
+
const buffer = await out.toBuffer();
|
|
53
|
+
await writeFileAtomic(req.output, buffer);
|
|
54
|
+
const bytesOut = buffer.length;
|
|
40
55
|
return {
|
|
41
56
|
ok: true,
|
|
42
57
|
input: req.input,
|
|
@@ -174,12 +174,24 @@ export class PdfToDocxConverter {
|
|
|
174
174
|
const bytesIn = (await fs.stat(req.input)).size;
|
|
175
175
|
// pdf2docx has no __main__ module and its console script may not be on
|
|
176
176
|
// PATH - drive the library API directly through the resolved interpreter.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
177
|
+
// It writes its output directly, so use a scratch file in the output's
|
|
178
|
+
// own directory and only publish a complete document under the real name.
|
|
179
|
+
const scratch = path.join(path.dirname(req.output), `.${path.basename(req.output)}.${Date.now()}.tmp`);
|
|
180
|
+
try {
|
|
181
|
+
await execTool(python, [
|
|
182
|
+
'-c', 'import sys; from pdf2docx import parse; parse(sys.argv[1], sys.argv[2])',
|
|
183
|
+
req.input, scratch,
|
|
184
|
+
], { timeoutMs: ctx.timeoutMs, signal: ctx.signal });
|
|
185
|
+
if (!(await exists(scratch))) {
|
|
186
|
+
return fail(req, convertError('conversion_failed', 'pdf2docx reported success but produced no file.'));
|
|
187
|
+
}
|
|
188
|
+
await fs.rename(scratch, req.output).catch(async () => {
|
|
189
|
+
await fs.copyFile(scratch, req.output);
|
|
190
|
+
await fs.rm(scratch, { force: true });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await fs.rm(scratch, { force: true }).catch(() => undefined);
|
|
183
195
|
}
|
|
184
196
|
return {
|
|
185
197
|
ok: true,
|
|
@@ -8,6 +8,7 @@ import { createCanvas } from '@napi-rs/canvas';
|
|
|
8
8
|
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
|
9
9
|
import { convertError } from '../errors.js';
|
|
10
10
|
import { PageRangeError, parsePageRange } from '../utils/pages.js';
|
|
11
|
+
import { writeFileAtomic } from '../utils/write-file.js';
|
|
11
12
|
import { OcrLanguageMissingError, OCR_LANGUAGE_DATA, TESSERACT, ocrLanguagesCached, resolveOcrEngine } from '../ocr.js';
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
/**
|
|
@@ -109,6 +110,11 @@ export class PdfConverter {
|
|
|
109
110
|
hint: `Use pages (e.g. '1-${maxPdfPages}') to select, or raise 'maxPdfPages' in the plugin config.`,
|
|
110
111
|
}));
|
|
111
112
|
}
|
|
113
|
+
if (explicitSelection && maxPdfPages && selected.length > maxPdfPages) {
|
|
114
|
+
return failErr(req, convertError('invalid_input', `${selected.length} pages selected, above the ${maxPdfPages}-page rasterization limit.`, {
|
|
115
|
+
hint: `Narrow the pages selection or raise 'maxPdfPages' in the plugin config.`,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
112
118
|
const scale = (req.options.dpi ?? 150) / 72; // PDFs have no intrinsic pixel size; 150 is a sane default.
|
|
113
119
|
const maxOutputPixels = ctx.limits?.maxOutputPixels;
|
|
114
120
|
let scaleReduced = false;
|
|
@@ -142,10 +148,16 @@ export class PdfConverter {
|
|
|
142
148
|
to: req.to,
|
|
143
149
|
});
|
|
144
150
|
const out = selected.length === 1 ? req.output : withPageNumber(req.output, n, pad);
|
|
145
|
-
await
|
|
151
|
+
await writeFileAtomic(out, buffer);
|
|
146
152
|
bytesOut += buffer.byteLength;
|
|
147
153
|
outputs.push(out);
|
|
148
154
|
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
// all-or-nothing: a failed page removes the pages already written
|
|
157
|
+
for (const written of outputs)
|
|
158
|
+
await fs.rm(written, { force: true }).catch(() => undefined);
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
149
161
|
finally {
|
|
150
162
|
page.cleanup();
|
|
151
163
|
}
|
|
@@ -174,7 +186,19 @@ export class PdfConverter {
|
|
|
174
186
|
}
|
|
175
187
|
async toText(doc, req, ctx, bytesIn, started) {
|
|
176
188
|
const totalPages = doc.numPages;
|
|
189
|
+
const explicitSelection = Boolean(req.options.pages);
|
|
177
190
|
const selected = req.options.pages ? parsePageRange(req.options.pages, totalPages) : pageRange(totalPages);
|
|
191
|
+
const maxPdfPages = ctx.limits?.maxPdfPages;
|
|
192
|
+
if (!explicitSelection && maxPdfPages && totalPages > maxPdfPages) {
|
|
193
|
+
return failErr(req, convertError('invalid_input', `PDF has ${totalPages} pages, above the ${maxPdfPages}-page text-extraction limit.`, {
|
|
194
|
+
hint: `Use pages (e.g. '1-${maxPdfPages}') to select, or raise 'maxPdfPages' in the plugin config.`,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
if (explicitSelection && maxPdfPages && selected.length > maxPdfPages) {
|
|
198
|
+
return failErr(req, convertError('invalid_input', `${selected.length} pages selected, above the ${maxPdfPages}-page text-extraction limit.`, {
|
|
199
|
+
hint: `Narrow the pages selection or raise 'maxPdfPages' in the plugin config.`,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
178
202
|
const warnings = [];
|
|
179
203
|
const pageTexts = [];
|
|
180
204
|
for (const n of selected) {
|
|
@@ -215,6 +239,7 @@ export class PdfConverter {
|
|
|
215
239
|
}
|
|
216
240
|
// OCR needs legible pixels: default to a higher density than rasterization.
|
|
217
241
|
const scale = Math.max((req.options.dpi ?? 200) / 72, 200 / 72);
|
|
242
|
+
const maxOutputPixels = ctx.limits?.maxOutputPixels;
|
|
218
243
|
const ocrTexts = [];
|
|
219
244
|
for (const n of selected) {
|
|
220
245
|
if (ctx.signal?.aborted) {
|
|
@@ -222,7 +247,18 @@ export class PdfConverter {
|
|
|
222
247
|
}
|
|
223
248
|
const page = await doc.getPage(n);
|
|
224
249
|
try {
|
|
225
|
-
|
|
250
|
+
let renderScale = scale;
|
|
251
|
+
if (maxOutputPixels) {
|
|
252
|
+
const viewport = page.getViewport({ scale });
|
|
253
|
+
const pixels = viewport.width * viewport.height;
|
|
254
|
+
if (pixels > maxOutputPixels) {
|
|
255
|
+
// Recognition quality suffers on tiny rasters, so clamp gently
|
|
256
|
+
// (2x the pixel budget) and say so.
|
|
257
|
+
renderScale = scale * Math.sqrt((maxOutputPixels * 2) / pixels);
|
|
258
|
+
warnings.push('OCR render scale was reduced on some pages to fit the pixel budget; recognition quality may drop.');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const png = await renderPage(page, renderScale, { to: 'png', quality: 100 });
|
|
226
262
|
ocrTexts.push((await engine.recognizePng(png, ocrLang, ctx)).trim());
|
|
227
263
|
}
|
|
228
264
|
catch (err) {
|
|
@@ -245,7 +281,7 @@ export class PdfConverter {
|
|
|
245
281
|
warnings.push('No extractable text found - this may be a scanned PDF. Re-run with ocr: true.');
|
|
246
282
|
}
|
|
247
283
|
const text = pageTexts.join('\n\n').replace(/\n{4,}/g, '\n\n\n') + '\n';
|
|
248
|
-
await
|
|
284
|
+
await writeFileAtomic(req.output, text);
|
|
249
285
|
if (selected.length < totalPages) {
|
|
250
286
|
warnings.push(`Converted page(s) ${selected.join(', ')} of ${totalPages} (pages option).`);
|
|
251
287
|
}
|
package/lib/core/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export { ConversionRouter, type RouterDefaults, type ConvertFileRequest, type Co
|
|
|
8
8
|
export { detectFile, DetectError, type DetectOutcome } from './detect.js';
|
|
9
9
|
export { resolveBinary } from './binary.js';
|
|
10
10
|
export { defaultOutputPath, batchOutputPath } from './paths.js';
|
|
11
|
+
export { isInsideAnyRoot, isInsideRoot, isSameFile, realPathBestEffort } from './utils/path-guard.js';
|
|
12
|
+
export { writeFileAtomic } from './utils/write-file.js';
|
|
11
13
|
export { ImageConverter } from './converters/image.js';
|
|
12
14
|
export { PdfConverter } from './converters/pdf.js';
|
|
13
15
|
export { DataConverter } from './converters/data.js';
|
package/lib/core/index.js
CHANGED
|
@@ -8,6 +8,8 @@ export { ConversionRouter } from './router.js';
|
|
|
8
8
|
export { detectFile, DetectError } from './detect.js';
|
|
9
9
|
export { resolveBinary } from './binary.js';
|
|
10
10
|
export { defaultOutputPath, batchOutputPath } from './paths.js';
|
|
11
|
+
export { isInsideAnyRoot, isInsideRoot, isSameFile, realPathBestEffort } from './utils/path-guard.js';
|
|
12
|
+
export { writeFileAtomic } from './utils/write-file.js';
|
|
11
13
|
export { ImageConverter } from './converters/image.js';
|
|
12
14
|
export { PdfConverter } from './converters/pdf.js';
|
|
13
15
|
export { DataConverter } from './converters/data.js';
|
package/lib/core/router.js
CHANGED
|
@@ -5,6 +5,7 @@ import { formatCategory, FORMAT_IDS, parseFormatArg } from './formats.js';
|
|
|
5
5
|
import { detectFile, DetectError } from './detect.js';
|
|
6
6
|
import { inspectFile } from './inspect.js';
|
|
7
7
|
import { defaultOutputPath } from './paths.js';
|
|
8
|
+
import { isInsideAnyRoot, isSameFile } from './utils/path-guard.js';
|
|
8
9
|
import { resolveBinary } from './binary.js';
|
|
9
10
|
import { FFPROBE } from './converters/media.js';
|
|
10
11
|
const DEFAULTS = { quality: 85, dpi: 150, timeoutMs: 120_000 };
|
|
@@ -128,7 +129,7 @@ export class ConversionRouter {
|
|
|
128
129
|
}),
|
|
129
130
|
};
|
|
130
131
|
}
|
|
131
|
-
if (req.output !== undefined && !(await
|
|
132
|
+
if (req.output !== undefined && !(await isInsideAnyRoot(output, this.defaults.outputRoots))) {
|
|
132
133
|
return {
|
|
133
134
|
ok: false, input: req.input, from, to,
|
|
134
135
|
error: convertError('invalid_input', `Output path ${output} is outside every configured outputRoot.`, {
|
|
@@ -157,18 +158,41 @@ export class ConversionRouter {
|
|
|
157
158
|
ocrLang: req.ocrLang,
|
|
158
159
|
};
|
|
159
160
|
const request = { input: req.input, output, from, to, options };
|
|
161
|
+
// True cancellation: the timeout (or the caller) aborts an internal
|
|
162
|
+
// controller that converters actually observe, so work stops instead of
|
|
163
|
+
// being abandoned behind an already-returned promise.
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const onCallerAbort = () => controller.abort();
|
|
166
|
+
run.signal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
160
167
|
const ctx = {
|
|
161
168
|
logger: run.logger,
|
|
162
|
-
signal: run.signal,
|
|
169
|
+
signal: AbortSignal.any([controller.signal, ...(run.signal ? [run.signal] : [])]),
|
|
163
170
|
timeoutMs: this.defaults.timeoutMs,
|
|
164
171
|
limits: { maxPdfPages: this.defaults.maxPdfPages, maxOutputPixels: this.defaults.maxOutputPixels },
|
|
165
172
|
};
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
173
|
+
let timer;
|
|
174
|
+
try {
|
|
175
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
176
|
+
timer = setTimeout(() => {
|
|
177
|
+
controller.abort();
|
|
178
|
+
resolve({
|
|
179
|
+
ok: false,
|
|
180
|
+
input: req.input,
|
|
181
|
+
from,
|
|
182
|
+
to,
|
|
183
|
+
error: convertError('timeout', `Conversion exceeded ${Math.round(ctx.timeoutMs / 1000)}s and was cancelled.`),
|
|
184
|
+
});
|
|
185
|
+
}, ctx.timeoutMs);
|
|
186
|
+
});
|
|
187
|
+
const result = await Promise.race([converter.convert(request, ctx), timeoutPromise]);
|
|
188
|
+
if (result.ok)
|
|
189
|
+
result.warnings.unshift(...warnings);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
run.signal?.removeEventListener('abort', onCallerAbort);
|
|
195
|
+
}
|
|
172
196
|
}
|
|
173
197
|
catch (err) {
|
|
174
198
|
if (err instanceof DetectError) {
|
|
@@ -183,6 +207,16 @@ export class ConversionRouter {
|
|
|
183
207
|
}
|
|
184
208
|
}
|
|
185
209
|
async inspect(input) {
|
|
210
|
+
const inputStat = await fs.stat(input).catch(() => null);
|
|
211
|
+
if (inputStat?.isDirectory()) {
|
|
212
|
+
throw new DetectError(convertError('invalid_input', `Input is a directory, not a file: ${input}`));
|
|
213
|
+
}
|
|
214
|
+
const maxInputBytes = this.defaults.maxInputBytes ?? 2 * 1024 ** 3;
|
|
215
|
+
if (inputStat && inputStat.size > maxInputBytes) {
|
|
216
|
+
throw new DetectError(convertError('invalid_input', `Input is ${Math.round(inputStat.size / 1048576).toLocaleString('en-US')} MB, above the ${Math.round(maxInputBytes / 1048576).toLocaleString('en-US')} MB limit for inspection.`, {
|
|
217
|
+
hint: "Raise 'maxInputMb' in the plugin config.",
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
186
220
|
const { detection } = await detectFile(input);
|
|
187
221
|
const bytes = (await fs.stat(input)).size;
|
|
188
222
|
let media;
|
|
@@ -199,51 +233,6 @@ export class ConversionRouter {
|
|
|
199
233
|
const CATEGORY_RANK = new Map([...FORMAT_IDS]
|
|
200
234
|
.sort((a, b) => formatCategory(a).localeCompare(formatCategory(b)))
|
|
201
235
|
.map((format, index) => [format, index]));
|
|
202
|
-
/**
|
|
203
|
-
* realpath the deepest EXISTING ancestor of a path and rejoin the remainder:
|
|
204
|
-
* symlinks anywhere in the existing part are resolved, which is what
|
|
205
|
-
* outputRoots confinement needs (a symlink inside a root can point outside).
|
|
206
|
-
*/
|
|
207
|
-
async function realPathBestEffort(p) {
|
|
208
|
-
let current = path.resolve(p);
|
|
209
|
-
const tail = [];
|
|
210
|
-
for (;;) {
|
|
211
|
-
try {
|
|
212
|
-
return path.join(await fs.realpath(current), ...tail.reverse());
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
/* segment does not exist yet - walk up */
|
|
216
|
-
}
|
|
217
|
-
const parent = path.dirname(current);
|
|
218
|
-
if (parent === current)
|
|
219
|
-
return path.resolve(p);
|
|
220
|
-
tail.push(path.basename(current));
|
|
221
|
-
current = parent;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
async function isSameFile(a, b) {
|
|
225
|
-
const ra = await realPathBestEffort(a);
|
|
226
|
-
const rb = await realPathBestEffort(b);
|
|
227
|
-
if (ra === rb)
|
|
228
|
-
return true;
|
|
229
|
-
// Windows paths are case-insensitive; also fold / vs \.
|
|
230
|
-
return process.platform === 'win32' && ra.replace(/\\/g, '/').toLowerCase() === rb.replace(/\\/g, '/').toLowerCase();
|
|
231
|
-
}
|
|
232
|
-
async function isInsideRoots(output, roots) {
|
|
233
|
-
if (!roots || roots.length === 0)
|
|
234
|
-
return true;
|
|
235
|
-
const candidate = await realPathBestEffort(output);
|
|
236
|
-
const normalized = process.platform === 'win32' ? candidate.replace(/\\/g, '/').toLowerCase() : candidate;
|
|
237
|
-
for (const root of roots) {
|
|
238
|
-
const prefix = await realPathBestEffort(path.resolve(root));
|
|
239
|
-
const normalizedPrefix = process.platform === 'win32' ? prefix.replace(/\\/g, '/').toLowerCase() : prefix;
|
|
240
|
-
if (normalized === normalizedPrefix ||
|
|
241
|
-
normalized.startsWith(normalizedPrefix.endsWith('/') ? normalizedPrefix : normalizedPrefix + '/')) {
|
|
242
|
-
return true;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
236
|
async function missingDeps(converter, overrides) {
|
|
248
237
|
const missing = [];
|
|
249
238
|
for (const dep of converter.binaryDeps) {
|
|
@@ -264,25 +253,4 @@ async function exists(path) {
|
|
|
264
253
|
return false;
|
|
265
254
|
}
|
|
266
255
|
}
|
|
267
|
-
/** Cooperative deadline: long-running converters also observe ctx.signal. */
|
|
268
|
-
async function withTimeout(promise, timeoutMs, meta) {
|
|
269
|
-
let timer;
|
|
270
|
-
const timeout = new Promise((resolve) => {
|
|
271
|
-
timer = setTimeout(() => {
|
|
272
|
-
resolve({
|
|
273
|
-
ok: false,
|
|
274
|
-
input: meta.input,
|
|
275
|
-
from: meta.from,
|
|
276
|
-
to: meta.to,
|
|
277
|
-
error: convertError('timeout', `Conversion exceeded ${Math.round(timeoutMs / 1000)}s and was abandoned.`),
|
|
278
|
-
});
|
|
279
|
-
}, timeoutMs);
|
|
280
|
-
});
|
|
281
|
-
try {
|
|
282
|
-
return await Promise.race([promise, timeout]);
|
|
283
|
-
}
|
|
284
|
-
finally {
|
|
285
|
-
clearTimeout(timer);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
256
|
const NULL_LOGGER = { debug() { }, info() { }, warn() { }, error() { } };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* realpath the deepest EXISTING ancestor of a path and rejoin the remainder:
|
|
3
|
+
* symlinks anywhere in the existing part are resolved, which is what output
|
|
4
|
+
* confinement and same-file checks need (a symlink inside a root can point
|
|
5
|
+
* outside, and two different-looking paths can be the same file).
|
|
6
|
+
*/
|
|
7
|
+
export declare function realPathBestEffort(p: string): Promise<string>;
|
|
8
|
+
/** Same file on disk, comparing real paths (symlink/case/separator aware). */
|
|
9
|
+
export declare function isSameFile(a: string, b: string): Promise<boolean>;
|
|
10
|
+
/** True when `candidate` is `root` itself or lives inside it (real paths). */
|
|
11
|
+
export declare function isInsideRoot(candidate: string, root: string): Promise<boolean>;
|
|
12
|
+
/** True when the candidate is inside at least one of the roots. */
|
|
13
|
+
export declare function isInsideAnyRoot(candidate: string, roots: string[] | undefined): Promise<boolean>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* realpath the deepest EXISTING ancestor of a path and rejoin the remainder:
|
|
5
|
+
* symlinks anywhere in the existing part are resolved, which is what output
|
|
6
|
+
* confinement and same-file checks need (a symlink inside a root can point
|
|
7
|
+
* outside, and two different-looking paths can be the same file).
|
|
8
|
+
*/
|
|
9
|
+
export async function realPathBestEffort(p) {
|
|
10
|
+
let current = path.resolve(p);
|
|
11
|
+
const tail = [];
|
|
12
|
+
for (;;) {
|
|
13
|
+
try {
|
|
14
|
+
return path.join(await fs.realpath(current), ...tail.reverse());
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
/* segment does not exist yet - walk up */
|
|
18
|
+
}
|
|
19
|
+
const parent = path.dirname(current);
|
|
20
|
+
if (parent === current)
|
|
21
|
+
return path.resolve(p);
|
|
22
|
+
tail.push(path.basename(current));
|
|
23
|
+
current = parent;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Same file on disk, comparing real paths (symlink/case/separator aware). */
|
|
27
|
+
export async function isSameFile(a, b) {
|
|
28
|
+
const ra = await realPathBestEffort(a);
|
|
29
|
+
const rb = await realPathBestEffort(b);
|
|
30
|
+
if (ra === rb)
|
|
31
|
+
return true;
|
|
32
|
+
// Windows paths are case-insensitive; also fold / vs \.
|
|
33
|
+
return process.platform === 'win32' && ra.replace(/\\/g, '/').toLowerCase() === rb.replace(/\\/g, '/').toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
function normalizeForCompare(p) {
|
|
36
|
+
return process.platform === 'win32' ? p.replace(/\\/g, '/').toLowerCase() : p;
|
|
37
|
+
}
|
|
38
|
+
/** True when `candidate` is `root` itself or lives inside it (real paths). */
|
|
39
|
+
export async function isInsideRoot(candidate, root) {
|
|
40
|
+
const realCandidate = normalizeForCompare(await realPathBestEffort(candidate));
|
|
41
|
+
const realRoot = normalizeForCompare(await realPathBestEffort(path.resolve(root)));
|
|
42
|
+
return realCandidate === realRoot || realCandidate.startsWith(realRoot.endsWith('/') ? realRoot : realRoot + '/');
|
|
43
|
+
}
|
|
44
|
+
/** True when the candidate is inside at least one of the roots. */
|
|
45
|
+
export async function isInsideAnyRoot(candidate, roots) {
|
|
46
|
+
if (!roots || roots.length === 0)
|
|
47
|
+
return true;
|
|
48
|
+
for (const root of roots) {
|
|
49
|
+
if (await isInsideRoot(candidate, root))
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write `data` to `target` atomically: the bytes land in a sibling temp file
|
|
3
|
+
* first and are renamed into place only when complete, so an interrupted run
|
|
4
|
+
* never leaves a half-written output behind.
|
|
5
|
+
*/
|
|
6
|
+
export declare function writeFileAtomic(target: string, data: Buffer | string): Promise<void>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Write `data` to `target` atomically: the bytes land in a sibling temp file
|
|
5
|
+
* first and are renamed into place only when complete, so an interrupted run
|
|
6
|
+
* never leaves a half-written output behind.
|
|
7
|
+
*/
|
|
8
|
+
export async function writeFileAtomic(target, data) {
|
|
9
|
+
const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
|
|
10
|
+
try {
|
|
11
|
+
await fs.writeFile(tmp, data);
|
|
12
|
+
try {
|
|
13
|
+
await fs.rename(tmp, target);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// cross-device or platform quirk: fall back to a full copy
|
|
17
|
+
await fs.copyFile(tmp, target);
|
|
18
|
+
await fs.rm(tmp, { force: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
await fs.rm(tmp, { force: true }).catch(() => undefined);
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
2
|
import { type Config as ConvertConfig } from './config.js';
|
|
3
3
|
export { Config } from './config.js';
|
|
4
|
+
export * from './core/index.js';
|
|
4
5
|
export declare const name = "dsh-file-convert";
|
|
5
6
|
export declare const inject: string[];
|
|
6
7
|
export declare function apply(ctx: Context, config: ConvertConfig): void;
|
package/lib/index.js
CHANGED
|
@@ -7,6 +7,9 @@ import { createOptimizeFileTool } from './tools/optimize-file.js';
|
|
|
7
7
|
import { createInstallMediaTool } from './tools/install-media.js';
|
|
8
8
|
import { createInstallOcrTool } from './tools/install-ocr.js';
|
|
9
9
|
export { Config } from './config.js';
|
|
10
|
+
// Library consumers (CLI, MCP wrapper, tests) can import the whole core
|
|
11
|
+
// surface from the package root; the DSH loader only reads name/inject/apply.
|
|
12
|
+
export * from './core/index.js';
|
|
10
13
|
export const name = 'dsh-file-convert';
|
|
11
14
|
export const inject = ['tools'];
|
|
12
15
|
const CONSOLE_LOGGER = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { FFMPEG, FFPROBE, resolveBinary } from '../core/index.js';
|
|
3
|
-
import { downloadBinary } from '../core/binaries/download.js';
|
|
3
|
+
import { downloadBinary, readCacheManifest } from '../core/binaries/download.js';
|
|
4
4
|
import { formatBytes } from '../format.js';
|
|
5
5
|
/**
|
|
6
6
|
* One explicit, user-approved path to media support: downloads pinned static
|
|
@@ -31,10 +31,14 @@ export function createInstallMediaTool(config, logger) {
|
|
|
31
31
|
if (config.ffprobePath)
|
|
32
32
|
overrides.ffprobePath = config.ffprobePath;
|
|
33
33
|
const lines = [];
|
|
34
|
+
const manifest = await readCacheManifest();
|
|
34
35
|
for (const dep of [FFMPEG, FFPROBE]) {
|
|
35
36
|
const existing = await resolveBinary(dep, overrides, logger);
|
|
36
37
|
if (existing && args.force !== true) {
|
|
37
38
|
lines.push(`= ${dep.name}: already available at ${existing}`);
|
|
39
|
+
if (!manifest[dep.name]) {
|
|
40
|
+
lines.push(`~ ${dep.name}: the cached copy predates the manifest - run with force: true once to upgrade to a sha256-verified build`);
|
|
41
|
+
}
|
|
38
42
|
continue;
|
|
39
43
|
}
|
|
40
44
|
logger.info(`downloading ${dep.name} into the plugin cache...`);
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import { DetectError, optimizeFile, resolveBinary } from '../core/index.js';
|
|
5
5
|
import { canonicalExtension } from '../core/index.js';
|
|
6
|
+
import { isInsideAnyRoot } from '../core/index.js';
|
|
6
7
|
import { formatBytes, formatDuration, formatFailure } from '../format.js';
|
|
7
8
|
export function createOptimizeFileTool(router, config, logger) {
|
|
8
9
|
return defineTool({
|
|
@@ -25,6 +26,13 @@ export function createOptimizeFileTool(router, config, logger) {
|
|
|
25
26
|
isConcurrencySafe: () => true,
|
|
26
27
|
async execute(args, exec) {
|
|
27
28
|
const targetBytes = Math.max(1, Math.round(args.target_size_mb * 1024 * 1024));
|
|
29
|
+
const inputStat = await fs.stat(args.input).catch(() => null);
|
|
30
|
+
if (!inputStat)
|
|
31
|
+
throw new Error(`Input file not found: ${args.input}`);
|
|
32
|
+
const maxBytes = config.maxInputMb * 1024 * 1024;
|
|
33
|
+
if (inputStat.size > maxBytes) {
|
|
34
|
+
throw new Error(`Input is ${Math.round(inputStat.size / 1048576).toLocaleString('en-US')} MB, above the ${config.maxInputMb} MB limit. Raise 'maxInputMb' in the plugin config if this file is intentional.`);
|
|
35
|
+
}
|
|
28
36
|
let detection;
|
|
29
37
|
try {
|
|
30
38
|
detection = (await router.detect(args.input)).detection;
|
|
@@ -42,7 +50,7 @@ export function createOptimizeFileTool(router, config, logger) {
|
|
|
42
50
|
if (path.resolve(output) === path.resolve(args.input)) {
|
|
43
51
|
throw new Error('Output path equals the input path; optimizing would destroy the source. Use the default -min output name or pick another path.');
|
|
44
52
|
}
|
|
45
|
-
if (args.output !== undefined && config.outputRoots.length > 0 && !
|
|
53
|
+
if (args.output !== undefined && config.outputRoots.length > 0 && !(await isInsideAnyRoot(args.output, config.outputRoots))) {
|
|
46
54
|
throw new Error(`Output path is outside every configured outputRoot (${config.outputRoots.join(', ')}).`);
|
|
47
55
|
}
|
|
48
56
|
if (args.overwrite !== true && (await exists(output))) {
|
|
@@ -71,15 +79,6 @@ export function createOptimizeFileTool(router, config, logger) {
|
|
|
71
79
|
},
|
|
72
80
|
});
|
|
73
81
|
}
|
|
74
|
-
function isInsideRoots(output, roots) {
|
|
75
|
-
const resolved = path.resolve(output);
|
|
76
|
-
const candidate = process.platform === 'win32' ? resolved.replace(/\\/g, '/').toLowerCase() : resolved;
|
|
77
|
-
return roots.some((root) => {
|
|
78
|
-
const rr = path.resolve(root);
|
|
79
|
-
const prefix = process.platform === 'win32' ? rr.replace(/\\/g, '/').toLowerCase() : rr;
|
|
80
|
-
return candidate === prefix || candidate.startsWith(prefix.endsWith('/') ? prefix : prefix + '/');
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
82
|
async function exists(p) {
|
|
84
83
|
try {
|
|
85
84
|
await fs.access(p);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-file-convert",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Local-first file conversion for DeepSeek Harness - images, PDF, data, audio/video and office documents. No API keys, no uploads, no token cost.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|