dsh-file-convert 0.4.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 +28 -0
- package/README.md +225 -0
- package/README.zh-CN.md +203 -0
- package/cordis.patch.yml +3 -0
- package/lib/config.d.ts +36 -0
- package/lib/config.js +17 -0
- package/lib/core/binaries/cache.d.ts +13 -0
- package/lib/core/binaries/cache.js +137 -0
- package/lib/core/binaries/download.d.ts +21 -0
- package/lib/core/binaries/download.js +140 -0
- package/lib/core/binary.d.ts +7 -0
- package/lib/core/binary.js +29 -0
- package/lib/core/converters/data.d.ts +14 -0
- package/lib/core/converters/data.js +145 -0
- package/lib/core/converters/image.d.ts +12 -0
- package/lib/core/converters/image.js +64 -0
- package/lib/core/converters/media.d.ts +21 -0
- package/lib/core/converters/media.js +135 -0
- package/lib/core/converters/office.d.ts +33 -0
- package/lib/core/converters/office.js +206 -0
- package/lib/core/converters/pdf-env.d.ts +1 -0
- package/lib/core/converters/pdf-env.js +8 -0
- package/lib/core/converters/pdf.d.ts +22 -0
- package/lib/core/converters/pdf.js +316 -0
- package/lib/core/detect.d.ts +16 -0
- package/lib/core/detect.js +121 -0
- package/lib/core/errors.d.ts +5 -0
- package/lib/core/errors.js +16 -0
- package/lib/core/formats.d.ts +11 -0
- package/lib/core/formats.js +60 -0
- package/lib/core/index.d.ts +25 -0
- package/lib/core/index.js +52 -0
- package/lib/core/inspect.d.ts +13 -0
- package/lib/core/inspect.js +134 -0
- package/lib/core/ocr.d.ts +38 -0
- package/lib/core/ocr.js +152 -0
- package/lib/core/optimizers.d.ts +29 -0
- package/lib/core/optimizers.js +275 -0
- package/lib/core/paths.d.ts +11 -0
- package/lib/core/paths.js +19 -0
- package/lib/core/router.d.ts +64 -0
- package/lib/core/router.js +288 -0
- package/lib/core/types.d.ts +199 -0
- package/lib/core/types.js +8 -0
- package/lib/core/utils/exec.d.ts +38 -0
- package/lib/core/utils/exec.js +89 -0
- package/lib/core/utils/pages.d.ts +12 -0
- package/lib/core/utils/pages.js +46 -0
- package/lib/format.d.ts +23 -0
- package/lib/format.js +84 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +51 -0
- package/lib/tools/batch-convert.d.ts +5 -0
- package/lib/tools/batch-convert.js +155 -0
- package/lib/tools/convert-file.d.ts +3 -0
- package/lib/tools/convert-file.js +57 -0
- package/lib/tools/inspect-file.d.ts +3 -0
- package/lib/tools/inspect-file.js +29 -0
- package/lib/tools/install-media.d.ts +9 -0
- package/lib/tools/install-media.js +55 -0
- package/lib/tools/install-ocr.d.ts +7 -0
- package/lib/tools/install-ocr.js +43 -0
- package/lib/tools/list-conversions.d.ts +2 -0
- package/lib/tools/list-conversions.js +18 -0
- package/lib/tools/optimize-file.d.ts +3 -0
- package/lib/tools/optimize-file.js +91 -0
- package/package.json +66 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/** Persistent per-user cache for downloaded external binaries. */
|
|
5
|
+
export function cacheDir() {
|
|
6
|
+
return path.join(os.homedir(), '.dsh-file-convert', 'bin');
|
|
7
|
+
}
|
|
8
|
+
export function cachedBinaryPath(name) {
|
|
9
|
+
return path.join(cacheDir(), name + (process.platform === 'win32' ? '.exe' : ''));
|
|
10
|
+
}
|
|
11
|
+
async function exists(p) {
|
|
12
|
+
try {
|
|
13
|
+
await fs.access(p);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function platformKey() {
|
|
21
|
+
return process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux';
|
|
22
|
+
}
|
|
23
|
+
/** Probe results are memoized per (dependency, path) for the process lifetime. */
|
|
24
|
+
const probeCache = new Map();
|
|
25
|
+
function probeDependency(dep, resolved) {
|
|
26
|
+
if (!dep.probe)
|
|
27
|
+
return Promise.resolve(true);
|
|
28
|
+
const key = `${dep.name}:${resolved}`;
|
|
29
|
+
let pending = probeCache.get(key);
|
|
30
|
+
if (!pending) {
|
|
31
|
+
pending = dep.probe(resolved).catch(() => false);
|
|
32
|
+
probeCache.set(key, pending);
|
|
33
|
+
}
|
|
34
|
+
return pending;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Locate the first existing entry. Patterns may carry `*` in ANY segment
|
|
38
|
+
* (e.g. `C:\Program Files\gs\gs*\bin\gswin64c.exe`); wildcard segments match
|
|
39
|
+
* one path level, preferring the highest-sorted match so newer versions win.
|
|
40
|
+
*/
|
|
41
|
+
async function firstExisting(paths) {
|
|
42
|
+
for (const candidate of paths) {
|
|
43
|
+
if (!candidate.includes('*')) {
|
|
44
|
+
if (await exists(candidate))
|
|
45
|
+
return candidate;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const expanded = await expandPattern(candidate);
|
|
49
|
+
for (const full of expanded) {
|
|
50
|
+
if (await exists(full))
|
|
51
|
+
return full;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
function wildcardRegex(segment) {
|
|
57
|
+
return new RegExp('^' + segment.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^\\\\/:]*') + '$', 'i');
|
|
58
|
+
}
|
|
59
|
+
/** Expand a path pattern segment by segment; [] when nothing matches. */
|
|
60
|
+
export async function expandPattern(pattern) {
|
|
61
|
+
const absolute = /^[\\/]/.test(pattern);
|
|
62
|
+
const segments = pattern.split(/[\\/]+/).filter((seg, index) => !(index === 0 && seg === ''));
|
|
63
|
+
let current = [absolute ? path.sep : ''];
|
|
64
|
+
for (const seg of segments) {
|
|
65
|
+
if (!seg.includes('*')) {
|
|
66
|
+
current = current.map((prefix) => {
|
|
67
|
+
if (prefix === '')
|
|
68
|
+
return seg;
|
|
69
|
+
// path.join('C:', 'x') drops the drive separator on Windows
|
|
70
|
+
if (/^[A-Za-z]:$/.test(prefix))
|
|
71
|
+
return prefix + path.sep + seg;
|
|
72
|
+
return path.join(prefix, seg);
|
|
73
|
+
});
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const regex = wildcardRegex(seg);
|
|
77
|
+
const next = [];
|
|
78
|
+
for (const prefix of current) {
|
|
79
|
+
let entries = [];
|
|
80
|
+
try {
|
|
81
|
+
entries = await fs.readdir(prefix === '' ? '.' : prefix);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const matches = entries
|
|
87
|
+
.filter((entry) => regex.test(entry))
|
|
88
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
89
|
+
for (const entry of matches)
|
|
90
|
+
next.push(path.join(prefix, entry));
|
|
91
|
+
}
|
|
92
|
+
if (next.length === 0)
|
|
93
|
+
return [];
|
|
94
|
+
current = next;
|
|
95
|
+
}
|
|
96
|
+
return current;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolution order: explicit config override → system PATH → known install
|
|
100
|
+
* locations (Windows soffice is rarely on PATH) → plugin cache. Every
|
|
101
|
+
* candidate must pass the dependency's probe when it declares one — a config
|
|
102
|
+
* override pointing at a python without pdf2docx counts as missing.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveBinaryCached(dep, overrides, which, logger) {
|
|
105
|
+
const accept = async (resolved) => {
|
|
106
|
+
if (!(await probeDependency(dep, resolved))) {
|
|
107
|
+
logger.debug(`binary ${dep.name}: ${resolved} resolved but failed its probe`);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
logger.debug(`binary ${dep.name}: using ${resolved}`);
|
|
111
|
+
return resolved;
|
|
112
|
+
};
|
|
113
|
+
const override = dep.configKey ? overrides[dep.configKey] : undefined;
|
|
114
|
+
if (override && (await exists(override)))
|
|
115
|
+
return accept(override);
|
|
116
|
+
for (const command of dep.commands) {
|
|
117
|
+
const found = await which(command);
|
|
118
|
+
if (found) {
|
|
119
|
+
const accepted = await accept(found);
|
|
120
|
+
if (accepted)
|
|
121
|
+
return accepted;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const located = await firstExisting(dep.extraPaths?.[platformKey()] ?? []);
|
|
125
|
+
if (located) {
|
|
126
|
+
const accepted = await accept(located);
|
|
127
|
+
if (accepted)
|
|
128
|
+
return accepted;
|
|
129
|
+
}
|
|
130
|
+
const cached = cachedBinaryPath(dep.name);
|
|
131
|
+
if (await exists(cached)) {
|
|
132
|
+
const accepted = await accept(cached);
|
|
133
|
+
if (accepted)
|
|
134
|
+
return accepted;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BinaryDependency } from '../types.js';
|
|
2
|
+
export declare class DownloadError extends Error {
|
|
3
|
+
readonly code: 'unsupported_platform' | 'integrity' | 'network' | 'verify_failed';
|
|
4
|
+
constructor(code: 'unsupported_platform' | 'integrity' | 'network' | 'verify_failed', message: string);
|
|
5
|
+
}
|
|
6
|
+
export interface DownloadOutcome {
|
|
7
|
+
path: string;
|
|
8
|
+
bytes: number;
|
|
9
|
+
/** First line of `<binary> -version`, e.g. "ffmpeg version 6.1.1 ...". */
|
|
10
|
+
versionLine: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Download one external binary into the plugin cache. Mirrors are tried in
|
|
14
|
+
* order; the gzip is sha256-verified before decompression, and the binary is
|
|
15
|
+
* proven by actually running `-version` once before success is reported.
|
|
16
|
+
*/
|
|
17
|
+
export declare function downloadBinary(dep: BinaryDependency, opts: {
|
|
18
|
+
timeoutMs: number;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
force?: boolean;
|
|
21
|
+
}): Promise<DownloadOutcome>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { gunzipSync } from 'node:zlib';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { createWriteStream } from 'node:fs';
|
|
9
|
+
import { execTool } from '../utils/exec.js';
|
|
10
|
+
import { cachedBinaryPath } from './cache.js';
|
|
11
|
+
/**
|
|
12
|
+
* Where each external binary comes from when the machine has none: pinned
|
|
13
|
+
* single-binary FFmpeg 6.1.1 builds from the ffmpeg-static project, served by
|
|
14
|
+
* the npmmirror binary CDN first (fast and reachable where GitHub-style hosts
|
|
15
|
+
* may not be) with the GitHub release as fallback. Both mirrors serve
|
|
16
|
+
* byte-identical files validated against the pinned sha256 below. Bump the
|
|
17
|
+
* version deliberately and re-pin the hashes; never resolve to "latest".
|
|
18
|
+
*/
|
|
19
|
+
const FFMPEG_STATIC_VERSION = 'b6.1.1'; // FFmpeg 6.1.1
|
|
20
|
+
const FFMPEG_STATIC_MIRRORS = [
|
|
21
|
+
(asset) => `https://registry.npmmirror.com/-/binary/ffmpeg-static/${FFMPEG_STATIC_VERSION}/${asset}.gz`,
|
|
22
|
+
(asset) => `https://github.com/eugeneware/ffmpeg-static/releases/download/${FFMPEG_STATIC_VERSION}/${asset}.gz`,
|
|
23
|
+
];
|
|
24
|
+
/** asset name (without .gz) and its pinned sha256, per binary per platform.
|
|
25
|
+
* Hashes were computed from the npmmirror-mirrored b6.1.1 assets; the GitHub
|
|
26
|
+
* release fallback serves byte-identical files. */
|
|
27
|
+
const STATIC_BINS = {
|
|
28
|
+
'ffmpeg:win32-x64': {
|
|
29
|
+
asset: 'ffmpeg-win32-x64',
|
|
30
|
+
sha256: '8883a3dffbd0a16cf4ef95206ea05283f78908dbfb118f73c83f4951dcc06d77',
|
|
31
|
+
},
|
|
32
|
+
'ffprobe:win32-x64': {
|
|
33
|
+
asset: 'ffprobe-win32-x64',
|
|
34
|
+
sha256: 'f309e6223ad89d2fe54bccd420a7709b66fd27540674e92309578ed491a43c8d',
|
|
35
|
+
},
|
|
36
|
+
'ffmpeg:darwin-arm64': {
|
|
37
|
+
asset: 'ffmpeg-darwin-arm64',
|
|
38
|
+
sha256: '8923876afa8db5585022d7860ec7e589af192f441c56793971276d450ed3bbfa',
|
|
39
|
+
},
|
|
40
|
+
'ffprobe:darwin-arm64': {
|
|
41
|
+
asset: 'ffprobe-darwin-arm64',
|
|
42
|
+
sha256: 'd986a8ec7b030899fe66a8a288ed809a3543338705a3ce178cfb85869c5d80be',
|
|
43
|
+
},
|
|
44
|
+
'ffmpeg:darwin-x64': {
|
|
45
|
+
asset: 'ffmpeg-darwin-x64',
|
|
46
|
+
sha256: '929b375c1182d956c51f7ac25e0b2b0411fb01f6f407aa15c9758efeb4242106',
|
|
47
|
+
},
|
|
48
|
+
'ffprobe:darwin-x64': {
|
|
49
|
+
asset: 'ffprobe-darwin-x64',
|
|
50
|
+
sha256: 'd4da574d6e2e197bd259b47d69cf262df9e312af24ad960444f6d806d3d4c186',
|
|
51
|
+
},
|
|
52
|
+
'ffmpeg:linux-x64': {
|
|
53
|
+
asset: 'ffmpeg-linux-x64',
|
|
54
|
+
sha256: 'bfe8a8fc511530457b528c48d77b5737527b504a3797a9bc4866aeca69c2dffa',
|
|
55
|
+
},
|
|
56
|
+
'ffprobe:linux-x64': {
|
|
57
|
+
asset: 'ffprobe-linux-x64',
|
|
58
|
+
sha256: '25d9b6ccb05e3d9de9e04e31e2506d8dd7f9f0418981965ac6df12e8d3afd067',
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
export class DownloadError extends Error {
|
|
62
|
+
code;
|
|
63
|
+
constructor(code, message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.code = code;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Download one external binary into the plugin cache. Mirrors are tried in
|
|
70
|
+
* order; the gzip is sha256-verified before decompression, and the binary is
|
|
71
|
+
* proven by actually running `-version` once before success is reported.
|
|
72
|
+
*/
|
|
73
|
+
export async function downloadBinary(dep, opts) {
|
|
74
|
+
const target = cachedBinaryPath(dep.name);
|
|
75
|
+
if (!opts.force && (await fileExists(target))) {
|
|
76
|
+
return { path: target, bytes: (await fs.stat(target)).size, versionLine: 'already cached' };
|
|
77
|
+
}
|
|
78
|
+
const platformKey = `${process.platform}-${process.arch}`;
|
|
79
|
+
const entry = STATIC_BINS[`${dep.name}:${platformKey}`];
|
|
80
|
+
if (!entry) {
|
|
81
|
+
throw new DownloadError('unsupported_platform', `No prebuilt ${dep.name} for ${platformKey}; install it manually.`);
|
|
82
|
+
}
|
|
83
|
+
const errors = [];
|
|
84
|
+
for (const mirror of FFMPEG_STATIC_MIRRORS) {
|
|
85
|
+
const url = mirror(entry.asset);
|
|
86
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-file-convert-dl-'));
|
|
87
|
+
try {
|
|
88
|
+
const gzPath = path.join(tmpDir, 'bin.gz');
|
|
89
|
+
const bytes = await downloadTo(url, gzPath, opts.timeoutMs, opts.signal);
|
|
90
|
+
verifyDigest(await fs.readFile(gzPath), entry.sha256);
|
|
91
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
92
|
+
await fs.writeFile(target, gunzipSync(await fs.readFile(gzPath)));
|
|
93
|
+
if (process.platform !== 'win32')
|
|
94
|
+
await fs.chmod(target, 0o755);
|
|
95
|
+
const { stdout, stderr } = await execTool(target, ['-version'], { timeoutMs: 15_000, signal: opts.signal });
|
|
96
|
+
const versionLine = (stdout || stderr).split(/\r?\n/, 1)[0]?.trim() ?? '';
|
|
97
|
+
return { path: target, bytes, versionLine };
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
errors.push(`${new URL(url).host}: ${err instanceof Error ? err.message : String(err)}`);
|
|
101
|
+
// either mirror may be unreachable; the pinned digest protects us either way
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
throw new DownloadError('network', `All mirrors failed for ${dep.name}: ${errors.join(' | ')}`);
|
|
108
|
+
}
|
|
109
|
+
async function downloadTo(url, target, timeoutMs, signal) {
|
|
110
|
+
let res;
|
|
111
|
+
try {
|
|
112
|
+
res = await fetch(url, { redirect: 'follow', signal: AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(signal ? [signal] : [])]) });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw new DownloadError('network', `Download failed (${url}): ${err instanceof Error ? err.message : String(err)}`);
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok || !res.body) {
|
|
118
|
+
throw new DownloadError('network', `Download returned HTTP ${res.status} for ${url}`);
|
|
119
|
+
}
|
|
120
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(target));
|
|
121
|
+
return (await fs.stat(target)).size;
|
|
122
|
+
}
|
|
123
|
+
function verifyDigest(data, expectedSha256Hex) {
|
|
124
|
+
const actual = createHash('sha256').update(data).digest('hex').toLowerCase();
|
|
125
|
+
const wanted = expectedSha256Hex.toLowerCase();
|
|
126
|
+
const a = Buffer.from(actual);
|
|
127
|
+
const b = Buffer.from(wanted);
|
|
128
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
129
|
+
throw new DownloadError('integrity', `Checksum mismatch: downloaded binary does not match its pinned sha256.`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function fileExists(p) {
|
|
133
|
+
try {
|
|
134
|
+
await fs.access(p);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BinaryDependency, Logger } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve an external binary: plugin config override → system PATH → plugin
|
|
4
|
+
* cache (populated by install_media_dependencies). No auto-install happens
|
|
5
|
+
* here — downloading is an explicit, user-approved tool call.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveBinary(dep: BinaryDependency, overrides: Record<string, string | undefined>, logger: Logger): Promise<string | null>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { resolveBinaryCached } from './binaries/cache.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve an external binary: plugin config override → system PATH → plugin
|
|
4
|
+
* cache (populated by install_media_dependencies). No auto-install happens
|
|
5
|
+
* here — downloading is an explicit, user-approved tool call.
|
|
6
|
+
*/
|
|
7
|
+
export async function resolveBinary(dep, overrides, logger) {
|
|
8
|
+
return resolveBinaryCached(dep, overrides, which, logger);
|
|
9
|
+
}
|
|
10
|
+
const whichCache = new Map();
|
|
11
|
+
async function which(command) {
|
|
12
|
+
const cached = whichCache.get(command);
|
|
13
|
+
if (cached !== undefined)
|
|
14
|
+
return cached;
|
|
15
|
+
let result = null;
|
|
16
|
+
try {
|
|
17
|
+
const { execFile } = await import('node:child_process');
|
|
18
|
+
const { promisify } = await import('node:util');
|
|
19
|
+
const run = promisify(execFile);
|
|
20
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
21
|
+
const { stdout } = await run(cmd, [command], { timeout: 5000 });
|
|
22
|
+
result = stdout.split(/\r?\n/)[0]?.trim() || null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
result = null;
|
|
26
|
+
}
|
|
27
|
+
whichCache.set(command, result);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ConvertContext, ConvertRequest, ConvertResult, Converter, ConversionCapability } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* JSON / YAML / CSV share one intermediate representation (a plain JS value):
|
|
4
|
+
* three readers + three writers cover every pair, including YAML↔CSV.
|
|
5
|
+
*/
|
|
6
|
+
export declare class DataConverter implements Converter {
|
|
7
|
+
readonly id = "data";
|
|
8
|
+
readonly concurrency = 4;
|
|
9
|
+
readonly binaryDeps: never[];
|
|
10
|
+
readonly capabilities: ConversionCapability[];
|
|
11
|
+
convert(req: ConvertRequest, ctx: ConvertContext): Promise<ConvertResult>;
|
|
12
|
+
private read;
|
|
13
|
+
private write;
|
|
14
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { parse as csvParse } from 'csv-parse/sync';
|
|
3
|
+
import { stringify as csvStringify } from 'csv-stringify/sync';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import { convertError } from '../errors.js';
|
|
6
|
+
/**
|
|
7
|
+
* JSON / YAML / CSV share one intermediate representation (a plain JS value):
|
|
8
|
+
* three readers + three writers cover every pair, including YAML↔CSV.
|
|
9
|
+
*/
|
|
10
|
+
export class DataConverter {
|
|
11
|
+
id = 'data';
|
|
12
|
+
concurrency = 4;
|
|
13
|
+
binaryDeps = [];
|
|
14
|
+
capabilities = [
|
|
15
|
+
{ from: 'json', to: 'yaml' },
|
|
16
|
+
{ from: 'yaml', to: 'json' },
|
|
17
|
+
{ from: 'json', to: 'csv' },
|
|
18
|
+
{ from: 'csv', to: 'json' },
|
|
19
|
+
{ from: 'yaml', to: 'csv' },
|
|
20
|
+
{ from: 'csv', to: 'yaml' },
|
|
21
|
+
];
|
|
22
|
+
async convert(req, ctx) {
|
|
23
|
+
const started = Date.now();
|
|
24
|
+
const from = req.from;
|
|
25
|
+
const to = req.to;
|
|
26
|
+
const readable = from === 'json' || from === 'yaml' || from === 'csv';
|
|
27
|
+
const writable = to === 'json' || to === 'yaml' || to === 'csv';
|
|
28
|
+
if (!readable || !writable) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
input: req.input,
|
|
32
|
+
from,
|
|
33
|
+
to,
|
|
34
|
+
error: convertError('unsupported_conversion', `DataConverter handles json/yaml/csv only, got ${from} -> ${to}.`),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const raw = await fs.readFile(req.input, 'utf8');
|
|
39
|
+
const bytesIn = Buffer.byteLength(raw);
|
|
40
|
+
const value = this.read(from, raw, req.options.delimiter);
|
|
41
|
+
const text = this.write(to, value, req.options.indent);
|
|
42
|
+
await fs.writeFile(req.output, text, 'utf8');
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
input: req.input,
|
|
46
|
+
output: req.output,
|
|
47
|
+
from: req.from,
|
|
48
|
+
to: req.to,
|
|
49
|
+
bytesIn,
|
|
50
|
+
bytesOut: Buffer.byteLength(text),
|
|
51
|
+
durationMs: Date.now() - started,
|
|
52
|
+
warnings: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
// Reader/parser problems are invalid_input; everything else is backend failure.
|
|
57
|
+
const code = err instanceof ParseError ? 'invalid_input' : 'conversion_failed';
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
input: req.input,
|
|
61
|
+
from: req.from,
|
|
62
|
+
to: req.to,
|
|
63
|
+
error: convertError(code, `Failed to convert ${req.from} → ${req.to}`, {
|
|
64
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
read(from, raw, delimiter) {
|
|
70
|
+
const text = raw.replace(/^\uFEFF/, ''); // strip UTF-8 BOM (Excel exports)
|
|
71
|
+
try {
|
|
72
|
+
if (from === 'json')
|
|
73
|
+
return JSON.parse(text);
|
|
74
|
+
if (from === 'yaml')
|
|
75
|
+
return yaml.load(text);
|
|
76
|
+
return csvParse(text, {
|
|
77
|
+
bom: true,
|
|
78
|
+
columns: true,
|
|
79
|
+
skip_empty_lines: true,
|
|
80
|
+
delimiter: delimiter ?? sniffDelimiter(text),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
throw new ParseError(err instanceof Error ? err.message : String(err));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
write(to, value, indent) {
|
|
88
|
+
if (to === 'json')
|
|
89
|
+
return JSON.stringify(value, null, indent ?? 2) + '\n';
|
|
90
|
+
if (to === 'yaml') {
|
|
91
|
+
return yaml.dump(value, { indent: indent ?? 2, lineWidth: 1000, noRefs: true });
|
|
92
|
+
}
|
|
93
|
+
const records = toRecords(value);
|
|
94
|
+
return csvStringify(records, { header: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
class ParseError extends Error {
|
|
98
|
+
}
|
|
99
|
+
/** CSV needs an array of flat objects; anything else is normalized into one. */
|
|
100
|
+
function toRecords(value) {
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
return value.map((item) => (isPlainItem(item) ? flattenRecord(item) : { value: item }));
|
|
103
|
+
}
|
|
104
|
+
if (value !== null && typeof value === 'object') {
|
|
105
|
+
return [flattenRecord(value)];
|
|
106
|
+
}
|
|
107
|
+
return [{ value }];
|
|
108
|
+
}
|
|
109
|
+
function isPlainItem(item) {
|
|
110
|
+
return item !== null && typeof item === 'object' && !Array.isArray(item);
|
|
111
|
+
}
|
|
112
|
+
/** Nested objects become dot-notation columns; arrays are JSON-encoded cells. */
|
|
113
|
+
function flattenRecord(record, prefix = '') {
|
|
114
|
+
const out = {};
|
|
115
|
+
for (const [key, val] of Object.entries(record)) {
|
|
116
|
+
const column = prefix ? `${prefix}.${key}` : key;
|
|
117
|
+
if (isPlainItem(val)) {
|
|
118
|
+
Object.assign(out, flattenRecord(val, column));
|
|
119
|
+
}
|
|
120
|
+
else if (Array.isArray(val)) {
|
|
121
|
+
out[column] = JSON.stringify(val);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
out[column] = val;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Pick the dominant delimiter among , ; \t from the first two lines.
|
|
131
|
+
* Semicolon-separated exports are common in European Excel locales.
|
|
132
|
+
*/
|
|
133
|
+
function sniffDelimiter(text) {
|
|
134
|
+
const head = text.split(/\r?\n/, 2).join('\n');
|
|
135
|
+
let best = ',';
|
|
136
|
+
let bestCount = 0;
|
|
137
|
+
for (const candidate of [',', ';', '\t']) {
|
|
138
|
+
const count = head.split(candidate).length - 1;
|
|
139
|
+
if (count > bestCount) {
|
|
140
|
+
best = candidate;
|
|
141
|
+
bestCount = count;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return best;
|
|
145
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ConvertContext, ConvertRequest, ConvertResult, Converter, ConversionCapability } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* All raster↔raster pairs plus SVG rasterization, backed by sharp/libvips
|
|
4
|
+
* (prebuilt npm binaries - no system dependencies).
|
|
5
|
+
*/
|
|
6
|
+
export declare class ImageConverter implements Converter {
|
|
7
|
+
readonly id = "image";
|
|
8
|
+
readonly concurrency = 4;
|
|
9
|
+
readonly binaryDeps: never[];
|
|
10
|
+
readonly capabilities: ConversionCapability[];
|
|
11
|
+
convert(req: ConvertRequest, ctx: ConvertContext): Promise<ConvertResult>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
import { convertError } from '../errors.js';
|
|
4
|
+
const IMAGE_INPUTS = ['png', 'jpg', 'webp', 'svg'];
|
|
5
|
+
const IMAGE_OUTPUTS = ['png', 'jpg', 'webp'];
|
|
6
|
+
/**
|
|
7
|
+
* All raster↔raster pairs plus SVG rasterization, backed by sharp/libvips
|
|
8
|
+
* (prebuilt npm binaries - no system dependencies).
|
|
9
|
+
*/
|
|
10
|
+
export class ImageConverter {
|
|
11
|
+
id = 'image';
|
|
12
|
+
concurrency = 4;
|
|
13
|
+
binaryDeps = [];
|
|
14
|
+
capabilities = IMAGE_INPUTS.flatMap((from) => IMAGE_OUTPUTS.filter((to) => to !== from).map((to) => ({ from, to })));
|
|
15
|
+
async convert(req, ctx) {
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
if (ctx.signal?.aborted) {
|
|
18
|
+
return { ok: false, input: req.input, from: req.from, to: req.to, error: convertError('cancelled', 'Conversion cancelled.') };
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const bytesIn = (await fs.stat(req.input)).size;
|
|
22
|
+
// density only affects vector input (SVG): 72 renders at the SVG's own
|
|
23
|
+
// pixel size; a higher dpi option upscales. Rasters ignore it.
|
|
24
|
+
const pipeline = sharp(req.input, { density: req.options.dpi ?? 72 });
|
|
25
|
+
const { quality, background } = req.options;
|
|
26
|
+
let out;
|
|
27
|
+
if (req.to === 'jpg') {
|
|
28
|
+
out = pipeline
|
|
29
|
+
.flatten({ background: background ?? '#ffffff' }) // jpg has no alpha channel
|
|
30
|
+
.jpeg({ quality: quality ?? 85 });
|
|
31
|
+
}
|
|
32
|
+
else if (req.to === 'webp') {
|
|
33
|
+
out = pipeline.webp({ quality: quality ?? 85 });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
out = pipeline.png();
|
|
37
|
+
}
|
|
38
|
+
await out.toFile(req.output);
|
|
39
|
+
const bytesOut = (await fs.stat(req.output)).size;
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
input: req.input,
|
|
43
|
+
output: req.output,
|
|
44
|
+
from: req.from,
|
|
45
|
+
to: req.to,
|
|
46
|
+
bytesIn,
|
|
47
|
+
bytesOut,
|
|
48
|
+
durationMs: Date.now() - started,
|
|
49
|
+
warnings: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
input: req.input,
|
|
56
|
+
from: req.from,
|
|
57
|
+
to: req.to,
|
|
58
|
+
error: convertError('conversion_failed', `Failed to convert ${req.from} → ${req.to}`, {
|
|
59
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { BinaryDependency, ConvertContext, ConvertRequest, ConvertResult, Converter, ConversionCapability } from '../types.js';
|
|
2
|
+
export declare const FFMPEG: BinaryDependency;
|
|
3
|
+
export declare const FFPROBE: BinaryDependency;
|
|
4
|
+
/**
|
|
5
|
+
* Audio/video conversions, delegated to a locally installed FFmpeg.
|
|
6
|
+
* The converter declares its dependencies; the router refuses to run (and
|
|
7
|
+
* list_conversions reports what is missing) until the binaries resolve.
|
|
8
|
+
*/
|
|
9
|
+
export declare class MediaConverter implements Converter {
|
|
10
|
+
private readonly resolve;
|
|
11
|
+
readonly id = "media";
|
|
12
|
+
readonly concurrency = 2;
|
|
13
|
+
readonly binaryDeps: BinaryDependency[];
|
|
14
|
+
readonly capabilities: ConversionCapability[];
|
|
15
|
+
constructor(resolve: (dep: BinaryDependency) => Promise<string | null>);
|
|
16
|
+
convert(req: ConvertRequest, ctx: ConvertContext): Promise<ConvertResult>;
|
|
17
|
+
/** MOV→MP4: try a lossless container swap first, fall back to re-encoding. */
|
|
18
|
+
private movToMp4;
|
|
19
|
+
private required;
|
|
20
|
+
private execOpts;
|
|
21
|
+
}
|