@edmund32/edx-cli 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { fail, ok } from '../utils/result.mjs';
5
+ import { mergeImages } from '../image/merge.mjs';
6
+ import { splitGrid, splitRegion } from '../image/split.mjs';
7
+
8
+ const PROJECT_DIRS = ['1.src', '2.pick', '3.cleaned', '4.deepfake', '5.picMark', '6.dst'];
9
+
10
+ function help() {
11
+ return [
12
+ '--- edx-image 可用的指令集列表 ---',
13
+ 'edx-image 是 Edmund 常用的图片对象处理指令集,主要用于图片项目目录创建、图片分割和图片拼接。',
14
+ '',
15
+ '1. edx-image mkdir <项目名>',
16
+ ' 说明: 创建 Edmund 图片处理项目目录结构,自动生成 1.src、2.pick、3.cleaned 等常用阶段目录。',
17
+ '',
18
+ '2. edx-image split grid <target> --rows <n> --cols <n>',
19
+ ' 说明: 按行列把图片切成多个小图,适合九宫格、四宫格或其他固定网格分割。',
20
+ ' 输出: 在 target 同级生成 target_output/,每张原图会生成一个同名文件夹保存切分结果。',
21
+ '',
22
+ '3. edx-image split region <target> --from <x,y> --to <x,y>',
23
+ ' 说明: 按左下角比例坐标裁切指定区域,坐标范围是 0-1。',
24
+ ' 示例: --from 0.5,0 --to 1,1 表示裁切右半边。',
25
+ '',
26
+ '4. edx-image merge <target> --rows <n> [--cols <n>] [--cellw <px> --cellh <px>]',
27
+ ' 说明: 把文件夹图片按网格拼接成大图,支持递归处理子文件夹。',
28
+ ' 输出: 在 target 同级生成 target_merged/,每个含图片的文件夹输出一张 png。',
29
+ '--------------------------',
30
+ ].join('\n');
31
+ }
32
+
33
+ function createProject(args) {
34
+ if (args.length !== 1) {
35
+ return fail('用法: edx-image mkdir <项目名>');
36
+ }
37
+
38
+ const project = args[0];
39
+ fs.mkdirSync(project, { recursive: true });
40
+
41
+ for (const dir of PROJECT_DIRS) {
42
+ fs.mkdirSync(path.join(project, dir), { recursive: true });
43
+ }
44
+
45
+ return ok(`项目目录 '${path.basename(project)}' 已创建,包含以下子目录:\n${PROJECT_DIRS.join('\n')}`);
46
+ }
47
+
48
+ function parseOptions(args) {
49
+ const positionals = [];
50
+ const flags = {};
51
+
52
+ for (let i = 0; i < args.length; i++) {
53
+ const arg = args[i];
54
+
55
+ if (arg.startsWith('--')) {
56
+ const key = arg.slice(2);
57
+ const value = args[i + 1];
58
+
59
+ if (!value || value.startsWith('--')) {
60
+ throw new Error(`缺少参数: ${arg}`);
61
+ }
62
+
63
+ flags[key] = value;
64
+ i += 1;
65
+ } else {
66
+ positionals.push(arg);
67
+ }
68
+ }
69
+
70
+ return {
71
+ positionals,
72
+ flags,
73
+ };
74
+ }
75
+
76
+ function splitSummary(label, result) {
77
+ const lines = [
78
+ `开始: ${label}`,
79
+ `输出目录: ${result.outputRoot}`,
80
+ ...result.logs,
81
+ `${label} 完成`,
82
+ `输出文件: ${result.outputs.length}`,
83
+ ];
84
+
85
+ if (result.errors.length > 0) {
86
+ lines.push(`失败文件: ${result.errors.length}`);
87
+ for (const error of result.errors) {
88
+ lines.push(`- ${error.filePath}: ${error.message}`);
89
+ }
90
+ }
91
+
92
+ return lines.join('\n');
93
+ }
94
+
95
+ function mergeSummary(result) {
96
+ const lines = [
97
+ '开始: merge 拼接',
98
+ `输出目录: ${result.outputRoot}`,
99
+ ...result.logs,
100
+ 'merge 拼接 完成',
101
+ `输出文件: ${result.outputs.length}`,
102
+ ];
103
+
104
+ if (result.errors.length > 0) {
105
+ lines.push(`失败文件夹: ${result.errors.length}`);
106
+ for (const error of result.errors) {
107
+ lines.push(`- ${error.filePath}: ${error.message}`);
108
+ }
109
+ }
110
+
111
+ return lines.join('\n');
112
+ }
113
+
114
+ async function runSplitCommand(args) {
115
+ const [mode, ...rest] = args;
116
+
117
+ if (!mode || mode === '-h' || mode === '--help' || mode === 'help') {
118
+ return ok([
119
+ '用法:',
120
+ ' edx-image split grid <target> --rows <n> --cols <n>',
121
+ ' edx-image split region <target> --from <x,y> --to <x,y>',
122
+ '',
123
+ '说明:',
124
+ ' region 坐标使用 0-1 比例,原点在左下角。',
125
+ ' 右半边裁切可写为: edx-image split region <target> --from 0.5,0 --to 1,1',
126
+ ].join('\n'));
127
+ }
128
+
129
+ try {
130
+ const { positionals, flags } = parseOptions(rest);
131
+ const target = positionals[0];
132
+
133
+ if (!target) {
134
+ return fail('错误: 缺少 target 路径');
135
+ }
136
+
137
+ if (mode === 'grid') {
138
+ const result = await splitGrid(target, {
139
+ rows: flags.rows,
140
+ cols: flags.cols,
141
+ });
142
+
143
+ return ok(splitSummary('grid 分割', result));
144
+ }
145
+
146
+ if (mode === 'region') {
147
+ const result = await splitRegion(target, {
148
+ from: flags.from,
149
+ to: flags.to,
150
+ });
151
+
152
+ return ok(splitSummary('region 裁切', result));
153
+ }
154
+
155
+ return fail(`错误: 未知 split 模式 '${mode}'`);
156
+ } catch (error) {
157
+ return fail(error instanceof Error ? error.message : String(error));
158
+ }
159
+ }
160
+
161
+ async function runMergeCommand(args) {
162
+ if (args.length === 0 || args[0] === '-h' || args[0] === '--help' || args[0] === 'help') {
163
+ return ok([
164
+ '用法:',
165
+ ' edx-image merge <target> --rows <n> [--cols <n>] [--cellw <px> --cellh <px>]',
166
+ '',
167
+ '说明:',
168
+ ' 处理 target 文件夹及其递归子文件夹中的图片。',
169
+ ' 每个含图片的文件夹会拼接成一张 png,统一输出到 target 同级的 target_merged/。',
170
+ ' 输出目录只有一层,例如 target/A/*.png -> target_merged/A.png。',
171
+ ' --cols 省略时按图片数量和 --rows 自动计算列数。',
172
+ ' 图片不足固定网格时用黑色占位;图片超出固定网格时按名称排序取前面的图片。',
173
+ ].join('\n'));
174
+ }
175
+
176
+ try {
177
+ const { positionals, flags } = parseOptions(args);
178
+ const target = positionals[0];
179
+
180
+ if (!target) {
181
+ return fail('错误: 缺少 target 路径');
182
+ }
183
+
184
+ const result = await mergeImages(target, {
185
+ rows: flags.rows,
186
+ cols: flags.cols,
187
+ cellw: flags.cellw,
188
+ cellh: flags.cellh,
189
+ });
190
+
191
+ return ok(mergeSummary(result));
192
+ } catch (error) {
193
+ return fail(error instanceof Error ? error.message : String(error));
194
+ }
195
+ }
196
+
197
+ export async function runImageCommand(args) {
198
+ const [command, ...rest] = args;
199
+
200
+ if (!command || command === '-a' || command === '--help' || command === 'help') {
201
+ return ok(help());
202
+ }
203
+
204
+ if (command === 'mkdir') {
205
+ return createProject(rest);
206
+ }
207
+
208
+ if (command === 'split') {
209
+ return runSplitCommand(rest);
210
+ }
211
+
212
+ if (command === 'merge') {
213
+ return runMergeCommand(rest);
214
+ }
215
+
216
+ return fail(`错误: 未知动作 '${command}'\n${help()}`);
217
+ }
@@ -0,0 +1,91 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { fail, ok } from '../utils/result.mjs';
5
+
6
+ function usage() {
7
+ return '用法: edx-md copy <旧.md> <新.md>';
8
+ }
9
+
10
+ function escapeRegExp(value) {
11
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
+ }
13
+
14
+ function copyMarkdownAssets(oldMd, newMd) {
15
+ if (!fs.existsSync(oldMd) || !fs.statSync(oldMd).isFile()) {
16
+ return fail(`错误: 旧文件 '${oldMd}' 不存在。`);
17
+ }
18
+ if (!fs.existsSync(newMd) || !fs.statSync(newMd).isFile()) {
19
+ return fail(`错误: 新文件 '${newMd}' 不存在(请先手动复制文字内容)。`);
20
+ }
21
+
22
+ const oldBase = oldMd.replace(/\.md$/i, '');
23
+ const newBase = newMd.replace(/\.md$/i, '');
24
+ const oldAssets = `${oldBase}.assets`;
25
+ const newAssets = `${newBase}.assets`;
26
+ const oldAssetsName = path.basename(oldAssets);
27
+ const newAssetsName = path.basename(newAssets);
28
+
29
+ if (!fs.existsSync(oldAssets) || !fs.statSync(oldAssets).isDirectory()) {
30
+ return fail(`错误: 旧资源目录 '${oldAssets}' 不存在。`);
31
+ }
32
+
33
+ const markdown = fs.readFileSync(newMd, 'utf8');
34
+ const assetPattern = new RegExp(`(?:\\.\\/)?${escapeRegExp(oldAssetsName)}\\/[^\\s"'\\)]+`, 'g');
35
+ const references = [...new Set(markdown.match(assetPattern) ?? [])];
36
+
37
+ if (references.length === 0) {
38
+ return ok('未找到任何引用旧资源的图片,无需处理。');
39
+ }
40
+
41
+ fs.mkdirSync(newAssets, { recursive: true });
42
+
43
+ const copied = [];
44
+ const missing = [];
45
+
46
+ for (const reference of references) {
47
+ const normalized = reference.replace(/^\.\//, '');
48
+ const fileName = path.basename(normalized);
49
+ const source = path.join(oldAssets, fileName);
50
+ const target = path.join(newAssets, fileName);
51
+
52
+ if (!fs.existsSync(source)) {
53
+ missing.push(source);
54
+ continue;
55
+ }
56
+
57
+ if (!fs.existsSync(target)) {
58
+ fs.copyFileSync(source, target);
59
+ }
60
+ copied.push(fileName);
61
+ }
62
+
63
+ const rewritten = markdown.replaceAll(`${oldAssetsName}/`, `${newAssetsName}/`);
64
+ fs.writeFileSync(newMd, rewritten);
65
+
66
+ const lines = [
67
+ `旧文件: ${oldMd} -> 新文件: ${newMd}`,
68
+ `旧资源: ${oldAssets} -> 新资源: ${newAssets}`,
69
+ `已处理 ${copied.length} 个资源引用。`,
70
+ ];
71
+
72
+ for (const item of missing) {
73
+ lines.push(`源文件缺失,跳过: ${item}`);
74
+ }
75
+
76
+ return ok(lines.join('\n'));
77
+ }
78
+
79
+ export async function runMdCommand(args) {
80
+ const [command, oldMd, newMd] = args;
81
+
82
+ if (!command || command === '-a' || command === '--help' || command === 'help') {
83
+ return ok(usage());
84
+ }
85
+
86
+ if (command !== 'copy' || args.length !== 3) {
87
+ return fail(usage());
88
+ }
89
+
90
+ return copyMarkdownAssets(oldMd, newMd);
91
+ }
@@ -0,0 +1,233 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import sharp from 'sharp';
4
+
5
+ const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
6
+
7
+ function isImageFile(filePath) {
8
+ return IMAGE_EXTS.has(path.extname(filePath).toLowerCase());
9
+ }
10
+
11
+ function sortNames(a, b) {
12
+ return a.localeCompare(b, 'zh-Hans-CN', { numeric: true });
13
+ }
14
+
15
+ function parsePositiveInteger(value, name) {
16
+ const parsed = Number(value);
17
+
18
+ if (!Number.isInteger(parsed) || parsed < 1) {
19
+ throw new Error(`${name} 必须是正整数`);
20
+ }
21
+
22
+ return parsed;
23
+ }
24
+
25
+ function optionalPositiveInteger(value, name) {
26
+ if (value === undefined) {
27
+ return undefined;
28
+ }
29
+
30
+ return parsePositiveInteger(value, name);
31
+ }
32
+
33
+ function buildMergeOutputRoot(targetPath) {
34
+ const absoluteTarget = path.resolve(targetPath);
35
+ const parent = path.dirname(absoluteTarget);
36
+ const name = path.basename(absoluteTarget);
37
+
38
+ return path.join(parent, `${name}_merged`);
39
+ }
40
+
41
+ function collectImageGroups(targetPath) {
42
+ const absoluteTarget = path.resolve(targetPath);
43
+
44
+ if (!fs.existsSync(absoluteTarget)) {
45
+ throw new Error(`找不到 target: ${targetPath}`);
46
+ }
47
+
48
+ const targetStat = fs.statSync(absoluteTarget);
49
+
50
+ if (!targetStat.isDirectory()) {
51
+ throw new Error(`target 必须是文件夹: ${targetPath}`);
52
+ }
53
+
54
+ const groups = [];
55
+
56
+ function walk(currentDir) {
57
+ const entries = fs.readdirSync(currentDir).sort(sortNames);
58
+ const files = [];
59
+ const dirs = [];
60
+
61
+ for (const entry of entries) {
62
+ const fullPath = path.join(currentDir, entry);
63
+ const stat = fs.statSync(fullPath);
64
+
65
+ if (stat.isDirectory()) {
66
+ dirs.push(fullPath);
67
+ } else if (stat.isFile() && isImageFile(fullPath)) {
68
+ files.push(fullPath);
69
+ }
70
+ }
71
+
72
+ if (files.length > 0) {
73
+ groups.push({
74
+ dirPath: currentDir,
75
+ name: path.basename(currentDir),
76
+ files,
77
+ });
78
+ }
79
+
80
+ for (const dir of dirs) {
81
+ walk(dir);
82
+ }
83
+ }
84
+
85
+ walk(absoluteTarget);
86
+ return groups;
87
+ }
88
+
89
+ function uniqueOutputPath(outputRoot, name, usedNames) {
90
+ let index = 1;
91
+
92
+ while (true) {
93
+ const suffix = index === 1 ? '' : `_${index}`;
94
+ const fileName = `${name}${suffix}.png`;
95
+
96
+ if (!usedNames.has(fileName)) {
97
+ usedNames.add(fileName);
98
+ return path.join(outputRoot, fileName);
99
+ }
100
+
101
+ index += 1;
102
+ }
103
+ }
104
+
105
+ async function resolveCellSize(files, options) {
106
+ if (options.cellw && options.cellh) {
107
+ return {
108
+ width: options.cellw,
109
+ height: options.cellh,
110
+ };
111
+ }
112
+
113
+ const meta = await sharp(files[0]).metadata();
114
+
115
+ if (!meta.width || !meta.height) {
116
+ throw new Error('无法读取图片尺寸');
117
+ }
118
+
119
+ return {
120
+ width: options.cellw ?? options.cellh ?? meta.width,
121
+ height: options.cellh ?? options.cellw ?? meta.height,
122
+ };
123
+ }
124
+
125
+ async function composeGroup(group, outputRoot, options, current, total, usedNames) {
126
+ const rows = options.rows;
127
+ const cols = options.cols ?? Math.ceil(group.files.length / rows);
128
+ const totalCells = rows * cols;
129
+ const selected = group.files.slice(0, totalCells);
130
+ const placeholders = totalCells - selected.length;
131
+ const cell = await resolveCellSize(selected, options);
132
+ const width = cols * cell.width;
133
+ const height = rows * cell.height;
134
+ const composites = [];
135
+
136
+ for (let index = 0; index < selected.length; index++) {
137
+ const row = Math.floor(index / cols);
138
+ const col = index % cols;
139
+ const input = await sharp(selected[index])
140
+ .resize(cell.width, cell.height, { fit: 'fill' })
141
+ .png()
142
+ .toBuffer();
143
+
144
+ composites.push({
145
+ input,
146
+ left: col * cell.width,
147
+ top: row * cell.height,
148
+ });
149
+ }
150
+
151
+ const outputPath = uniqueOutputPath(outputRoot, group.name, usedNames);
152
+ await sharp({
153
+ create: {
154
+ width,
155
+ height,
156
+ channels: 4,
157
+ background: '#000000',
158
+ },
159
+ })
160
+ .composite(composites)
161
+ .png()
162
+ .toFile(outputPath);
163
+
164
+ return {
165
+ outputPath,
166
+ logs: [
167
+ `处理 ${current}/${total}: ${group.name}`,
168
+ `选中图片: ${selected.length}/${group.files.length}`,
169
+ `占位: ${placeholders}`,
170
+ `完成 ${current}/${total}: ${group.name} -> ${path.basename(outputPath)}`,
171
+ ],
172
+ };
173
+ }
174
+
175
+ export async function mergeImages(targetPath, options) {
176
+ const rows = parsePositiveInteger(options.rows, '--rows');
177
+ const cols = optionalPositiveInteger(options.cols, '--cols');
178
+ const rawCellW = optionalPositiveInteger(options.cellw, '--cellw');
179
+ const rawCellH = optionalPositiveInteger(options.cellh, '--cellh');
180
+ const cellw = rawCellW ?? rawCellH;
181
+ const cellh = rawCellH ?? rawCellW;
182
+ const groups = collectImageGroups(targetPath);
183
+ const outputRoot = buildMergeOutputRoot(targetPath);
184
+ const outputs = [];
185
+ const errors = [];
186
+ const logs = [];
187
+ const usedNames = new Set();
188
+
189
+ fs.mkdirSync(outputRoot, { recursive: true });
190
+
191
+ if (groups.length === 0) {
192
+ logs.push('未找到可处理图片文件夹');
193
+ }
194
+
195
+ for (let index = 0; index < groups.length; index++) {
196
+ const group = groups[index];
197
+ const current = index + 1;
198
+
199
+ try {
200
+ const result = await composeGroup(
201
+ group,
202
+ outputRoot,
203
+ {
204
+ rows,
205
+ cols,
206
+ cellw,
207
+ cellh,
208
+ },
209
+ current,
210
+ groups.length,
211
+ usedNames,
212
+ );
213
+
214
+ outputs.push(result.outputPath);
215
+ logs.push(...result.logs);
216
+ } catch (error) {
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ errors.push({
219
+ filePath: group.dirPath,
220
+ message,
221
+ });
222
+ logs.push(`处理 ${current}/${groups.length}: ${group.name}`);
223
+ logs.push(`失败 ${current}/${groups.length}: ${group.name} -> ${message}`);
224
+ }
225
+ }
226
+
227
+ return {
228
+ outputRoot,
229
+ outputs,
230
+ errors,
231
+ logs,
232
+ };
233
+ }