xw-devtool-cli 1.0.41 → 1.0.42

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 CHANGED
@@ -13,6 +13,7 @@
13
13
  - **图片格式转换**:支持 PNG、JPG、WebP、ICO 格式互转,可调整压缩质量。生成 ICO 时支持自定义尺寸(逗号分隔),留空则默认生成 `256` 尺寸。输出文件名默认带时间戳,避免覆盖。
14
14
  - **图片 ↔ Base64**:支持图片转 Base64 字符串,以及 Base64 还原为图片文件。
15
15
  - **图片分割**:支持网格等分(自定义行/列数)或自定义分割线(像素/百分比),自动生成分割后的图片文件。
16
+ - **图片拼接**:支持“选择目录”或“选择多张图片”两种输入方式;在对话框中可一次多选多张图片,可选**水平拼接**或**垂直拼接**,输出支持选择指定目录(对话框或手动输入),默认输出文件名带时间戳并自动复制输出路径。
16
17
  - **图片主色识别**:提取图片主色调(Hex/RGB/HSL/HSV),默认复制 Hex 到剪贴板,支持保存详细信息到文件。
17
18
  - **颜色吸取**:从图片中吸取单像素颜色或区域平均颜色,支持 px/% 坐标输入,结果显示颜色条并自动复制 Hex/Hex8。
18
19
  - **屏幕取色**:实时显示鼠标所在位置的颜色值(Hex/RGB)。
package/README_EN.md CHANGED
@@ -13,6 +13,7 @@ Key features include: Base64 encoding/decoding, image format conversion, image <
13
13
  - **Format Conversion**: Convert between PNG, JPG, WebP, with adjustable compression quality.
14
14
  - **Image ↔ Base64**: Convert images to Base64 strings and vice versa.
15
15
  - **Image Splitter**: Split images into grid (rows/cols) or custom split lines (pixels/percentage).
16
+ - **Image Merger**: Supports two input modes (folder or multiple images); the dialog mode supports selecting multiple images at once. You can choose **horizontal** or **vertical** merge, and choose a specific output directory (dialog or manual input). Default output filename includes timestamp, and output path is copied to clipboard.
16
17
  - **Placeholder Image**: Quickly generate placeholder images with custom size, color, and text.
17
18
  - **Color Picker**: Pick a single pixel color or area average from an image; supports px/% coordinates; shows preview bar and auto-copies Hex/Hex8.
18
19
  - **Screen Picker**: Pick color directly from the screen at the mouse position; move the cursor and press Enter to sample; supports alpha preview.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xw-devtool-cli",
3
- "version": "1.0.41",
3
+ "version": "1.0.42",
4
4
  "type": "module",
5
5
  "description": "基于node的开发者助手cli",
6
6
  "main": "index.js",
@@ -54,6 +54,9 @@
54
54
  "screen-mark",
55
55
  "pixel-distance",
56
56
  "image-compress",
57
+ "image-merge",
58
+ "merge-images",
59
+ "image-stitch",
57
60
  "png-compress",
58
61
  "jpg-compress",
59
62
  "webp-compress",
@@ -0,0 +1,285 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import inquirer from 'inquirer';
4
+ import sharp from 'sharp';
5
+ import i18next from '../i18n.js';
6
+ import { selectFromMenu } from '../utils/menu.js';
7
+ import { selectFiles, selectFolder } from '../utils/fileDialog.js';
8
+ import { copy } from '../utils/clipboard.js';
9
+ import { defaultFileName } from '../utils/output.js';
10
+
11
+ const SUPPORTED_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tif', '.tiff', '.gif']);
12
+
13
+ function isImageFile(filePath) {
14
+ const ext = path.extname(filePath).toLowerCase();
15
+ return SUPPORTED_EXT.has(ext);
16
+ }
17
+
18
+ function listImagesInDir(dir) {
19
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
20
+ return entries
21
+ .filter((entry) => entry.isFile() && isImageFile(entry.name))
22
+ .map((entry) => path.join(dir, entry.name))
23
+ .sort((a, b) => path.basename(a).localeCompare(path.basename(b), undefined, { numeric: true, sensitivity: 'base' }));
24
+ }
25
+
26
+ function parseManualImagePaths(input) {
27
+ return Array.from(new Set(
28
+ input
29
+ .split(/[\r\n,]+/)
30
+ .map((p) => p.trim())
31
+ .filter(Boolean)
32
+ ));
33
+ }
34
+
35
+ async function pickFolderPath() {
36
+ const method = await selectFromMenu(
37
+ i18next.t('imgMerge.inputMethod'),
38
+ [
39
+ { name: i18next.t('imgMerge.folderDialog'), value: 'dialog' },
40
+ { name: i18next.t('imgMerge.folderManual'), value: 'manual' }
41
+ ],
42
+ true,
43
+ i18next.t('common.back')
44
+ );
45
+ if (method === '__BACK__') return null;
46
+
47
+ if (method === 'dialog') {
48
+ const selected = selectFolder();
49
+ if (!selected) {
50
+ console.log(i18next.t('imgMerge.dialogFail'));
51
+ return await pickFolderPath();
52
+ }
53
+ return selected;
54
+ }
55
+
56
+ const { folderPath } = await inquirer.prompt([
57
+ {
58
+ type: 'input',
59
+ name: 'folderPath',
60
+ message: i18next.t('imgMerge.enterFolderPath'),
61
+ validate: (input) => {
62
+ if (!fs.existsSync(input)) return i18next.t('imgMerge.notExist');
63
+ if (!fs.statSync(input).isDirectory()) return i18next.t('imgMerge.notDirectory');
64
+ return true;
65
+ }
66
+ }
67
+ ]);
68
+ return folderPath;
69
+ }
70
+
71
+ async function pickMultiImageFiles() {
72
+ const method = await selectFromMenu(
73
+ i18next.t('imgMerge.inputMethod'),
74
+ [
75
+ { name: i18next.t('imgMerge.multiDialog'), value: 'dialog' },
76
+ { name: i18next.t('imgMerge.multiManual'), value: 'manual' }
77
+ ],
78
+ true,
79
+ i18next.t('common.back')
80
+ );
81
+ if (method === '__BACK__') return null;
82
+
83
+ if (method === 'dialog') {
84
+ const files = selectFiles('Image Files|*.png;*.jpg;*.jpeg;*.webp;*.bmp;*.tif;*.tiff;*.gif|All Files|*.*');
85
+ if (!files || files.length === 0) {
86
+ console.log(i18next.t('imgMerge.dialogFail'));
87
+ return await pickMultiImageFiles();
88
+ }
89
+ return Array.from(new Set(files));
90
+ }
91
+
92
+ const { filesInput } = await inquirer.prompt([
93
+ {
94
+ type: 'input',
95
+ name: 'filesInput',
96
+ message: i18next.t('imgMerge.enterImagePaths')
97
+ }
98
+ ]);
99
+ return parseManualImagePaths(filesInput);
100
+ }
101
+
102
+ async function pickOutputPath(baseDir) {
103
+ const outputMethod = await selectFromMenu(
104
+ i18next.t('imgMerge.selectOutput'),
105
+ [
106
+ { name: i18next.t('imgMerge.outputDefault'), value: 'default' },
107
+ { name: i18next.t('imgMerge.outputFolderDialog'), value: 'folderDialog' },
108
+ { name: i18next.t('imgMerge.outputFolderManual'), value: 'folderManual' },
109
+ { name: i18next.t('imgMerge.outputFileManual'), value: 'fileManual' }
110
+ ],
111
+ true,
112
+ i18next.t('common.back')
113
+ );
114
+ if (outputMethod === '__BACK__') return null;
115
+
116
+ if (outputMethod === 'default') {
117
+ return path.join(baseDir, defaultFileName('img-merge', 'png'));
118
+ }
119
+
120
+ if (outputMethod === 'folderDialog') {
121
+ const selected = selectFolder();
122
+ if (!selected) {
123
+ console.log(i18next.t('imgMerge.dialogFail'));
124
+ return await pickOutputPath(baseDir);
125
+ }
126
+ return path.join(selected, defaultFileName('img-merge', 'png'));
127
+ }
128
+
129
+ if (outputMethod === 'folderManual') {
130
+ const { outputDir } = await inquirer.prompt([
131
+ {
132
+ type: 'input',
133
+ name: 'outputDir',
134
+ message: i18next.t('imgMerge.enterOutputDir'),
135
+ default: baseDir,
136
+ validate: (input) => {
137
+ const dir = input.trim();
138
+ if (!dir) return i18next.t('imgMerge.outputPathRequired');
139
+ if (fs.existsSync(dir) && !fs.statSync(dir).isDirectory()) {
140
+ return i18next.t('imgMerge.notDirectory');
141
+ }
142
+ return true;
143
+ }
144
+ }
145
+ ]);
146
+ return path.join(outputDir.trim(), defaultFileName('img-merge', 'png'));
147
+ }
148
+
149
+ const defaultPath = path.join(baseDir, defaultFileName('img-merge', 'png'));
150
+ const { outputPath } = await inquirer.prompt([
151
+ {
152
+ type: 'input',
153
+ name: 'outputPath',
154
+ message: i18next.t('imgMerge.outputPathPrompt'),
155
+ default: defaultPath,
156
+ validate: (input) => input.trim() ? true : i18next.t('imgMerge.outputPathRequired')
157
+ }
158
+ ]);
159
+ return outputPath.trim();
160
+ }
161
+
162
+ function validateImageFiles(files) {
163
+ if (!files || files.length < 2) {
164
+ return i18next.t('imgMerge.needAtLeastTwo');
165
+ }
166
+ for (const filePath of files) {
167
+ if (!fs.existsSync(filePath)) {
168
+ return `${i18next.t('imgMerge.notExist')}: ${filePath}`;
169
+ }
170
+ if (!fs.statSync(filePath).isFile()) {
171
+ return `${i18next.t('imgMerge.notFile')}: ${filePath}`;
172
+ }
173
+ if (!isImageFile(filePath)) {
174
+ return `${i18next.t('imgMerge.notImage')}: ${filePath}`;
175
+ }
176
+ }
177
+ return true;
178
+ }
179
+
180
+ async function mergeImages(files, direction, outputPath) {
181
+ const metas = await Promise.all(files.map(async (filePath) => {
182
+ const image = sharp(filePath).rotate();
183
+ const metadata = await image.metadata();
184
+ const buffer = await image.toBuffer();
185
+ return { filePath, metadata, buffer };
186
+ }));
187
+
188
+ const widths = metas.map((m) => m.metadata.width || 0);
189
+ const heights = metas.map((m) => m.metadata.height || 0);
190
+ if (widths.some((w) => w <= 0) || heights.some((h) => h <= 0)) {
191
+ throw new Error(i18next.t('imgMerge.invalidImageSize'));
192
+ }
193
+
194
+ const isHorizontal = direction === 'horizontal';
195
+ const canvasWidth = isHorizontal ? widths.reduce((sum, w) => sum + w, 0) : Math.max(...widths);
196
+ const canvasHeight = isHorizontal ? Math.max(...heights) : heights.reduce((sum, h) => sum + h, 0);
197
+
198
+ let offsetX = 0;
199
+ let offsetY = 0;
200
+ const composite = metas.map((meta, index) => {
201
+ const item = {
202
+ input: meta.buffer,
203
+ left: offsetX,
204
+ top: offsetY
205
+ };
206
+ if (isHorizontal) {
207
+ offsetX += widths[index];
208
+ } else {
209
+ offsetY += heights[index];
210
+ }
211
+ return item;
212
+ });
213
+
214
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
215
+
216
+ await sharp({
217
+ create: {
218
+ width: canvasWidth,
219
+ height: canvasHeight,
220
+ channels: 4,
221
+ background: { r: 255, g: 255, b: 255, alpha: 1 }
222
+ }
223
+ })
224
+ .composite(composite)
225
+ .png()
226
+ .toFile(outputPath);
227
+ }
228
+
229
+ export async function imgMergeHandler() {
230
+ const sourceType = await selectFromMenu(
231
+ i18next.t('imgMerge.selectSource'),
232
+ [
233
+ { name: i18next.t('imgMerge.sourceFolder'), value: 'folder' },
234
+ { name: i18next.t('imgMerge.sourceMulti'), value: 'multi' }
235
+ ],
236
+ true,
237
+ i18next.t('common.back')
238
+ );
239
+ if (sourceType === '__BACK__') return;
240
+
241
+ let files = [];
242
+ let baseDir = process.cwd();
243
+ if (sourceType === 'folder') {
244
+ const folderPath = await pickFolderPath();
245
+ if (!folderPath) return;
246
+ baseDir = folderPath;
247
+ files = listImagesInDir(folderPath);
248
+ } else {
249
+ const selectedFiles = await pickMultiImageFiles();
250
+ if (!selectedFiles) return;
251
+ files = selectedFiles;
252
+ if (files.length > 0) {
253
+ baseDir = path.dirname(files[0]);
254
+ }
255
+ }
256
+
257
+ const validationResult = validateImageFiles(files);
258
+ if (validationResult !== true) {
259
+ console.log(validationResult);
260
+ return;
261
+ }
262
+
263
+ const direction = await selectFromMenu(
264
+ i18next.t('imgMerge.selectDirection'),
265
+ [
266
+ { name: i18next.t('imgMerge.horizontal'), value: 'horizontal' },
267
+ { name: i18next.t('imgMerge.vertical'), value: 'vertical' }
268
+ ],
269
+ true,
270
+ i18next.t('common.back')
271
+ );
272
+ if (direction === '__BACK__') return;
273
+
274
+ const outputPath = await pickOutputPath(baseDir);
275
+ if (!outputPath) return;
276
+
277
+ try {
278
+ await mergeImages(files, direction, outputPath);
279
+ console.log(i18next.t('imgMerge.success', { outputPath }));
280
+ await copy(outputPath);
281
+ console.log(i18next.t('imgMerge.copied', { outputPath }));
282
+ } catch (error) {
283
+ console.error(i18next.t('imgMerge.error', { message: error.message }));
284
+ }
285
+ }
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ import { unicodeHandler } from './commands/unicode.js';
11
11
  import { imgBase64Handler } from './commands/imgBase64.js';
12
12
  import { imgConvertHandler } from './commands/imgConvert.js';
13
13
  import { imgSplitHandler } from './commands/imgSplit.js';
14
+ import { imgMergeHandler } from './commands/imgMerge.js';
14
15
  import { timeFormatHandler } from './commands/timeFormat.js';
15
16
  import { timeCalcHandler } from './commands/timeCalc.js';
16
17
  import { mockHandler } from './commands/mock.js';
@@ -58,6 +59,7 @@ function getFeatures() {
58
59
  { name: i18next.t('menu.features.imgBase64'), value: 'imgBase64' },
59
60
  { name: i18next.t('menu.features.imgConvert'), value: 'imgConvert' },
60
61
  { name: i18next.t('menu.features.imgSplit'), value: 'imgSplit' },
62
+ { name: i18next.t('menu.features.imgMerge'), value: 'imgMerge' },
61
63
  { name: i18next.t('menu.features.dominantColor'), value: 'dominantColor' },
62
64
  { name: i18next.t('menu.features.colorPick'), value: 'colorPick' },
63
65
  { name: i18next.t('menu.features.crosshair'), value: 'crosshair' },
@@ -282,6 +284,9 @@ async function handleAction(action) {
282
284
  case 'imgSplit':
283
285
  await imgSplitHandler();
284
286
  break;
287
+ case 'imgMerge':
288
+ await imgMergeHandler();
289
+ break;
285
290
  case 'dominantColor':
286
291
  await dominantColorHandler();
287
292
  break;
package/src/locales/en.js CHANGED
@@ -41,6 +41,7 @@ export default {
41
41
  imgBase64: 'Image <-> Base64',
42
42
  imgConvert: 'Image Format Convert',
43
43
  imgSplit: 'Image Splitter',
44
+ imgMerge: 'Image Merger',
44
45
  dominantColor: 'Image Dominant Color',
45
46
  colorPick: 'Color Picker',
46
47
  crosshair: 'Screen Crosshair',
@@ -156,6 +157,42 @@ export default {
156
157
  vLines: 'Vertical split lines (e.g. 10 20 30 or 10% 20%):',
157
158
  success: 'Successfully split image into {{count}} parts. Saved to {{path}}'
158
159
  },
160
+ imgMerge: {
161
+ selectSource: 'Select image source',
162
+ sourceFolder: 'Use images from a folder',
163
+ sourceMulti: 'Select multiple images',
164
+ inputMethod: 'Select input method',
165
+ folderDialog: 'Select folder (dialog)',
166
+ folderManual: 'Enter folder path manually',
167
+ multiDialog: 'Select multiple images (dialog)',
168
+ multiManual: 'Enter multiple image paths manually',
169
+ continueAdd: 'Continue adding images?',
170
+ yes: 'Yes',
171
+ no: 'No',
172
+ enterFolderPath: 'Enter image folder path:',
173
+ enterImagePaths: 'Enter image paths (comma or newline separated):',
174
+ notExist: 'Path does not exist',
175
+ notDirectory: 'The path is not a folder',
176
+ notFile: 'The path is not a file',
177
+ notImage: 'The file is not a supported image format',
178
+ needAtLeastTwo: 'At least 2 images are required for merging.',
179
+ dialogFail: 'Dialog is unavailable or canceled. Please retry or use manual input.',
180
+ selectDirection: 'Select merge direction',
181
+ horizontal: 'Horizontal merge (left to right)',
182
+ vertical: 'Vertical merge (top to bottom)',
183
+ selectOutput: 'Select output mode',
184
+ outputDefault: 'Default output to input directory',
185
+ outputFolderDialog: 'Select output directory (dialog)',
186
+ outputFolderManual: 'Enter output directory manually',
187
+ outputFileManual: 'Enter full output file path manually',
188
+ enterOutputDir: 'Enter output directory path:',
189
+ outputPathPrompt: 'Enter output file path:',
190
+ outputPathRequired: 'Output file path cannot be empty.',
191
+ invalidImageSize: 'One or more images have invalid dimensions.',
192
+ success: 'Merge completed. Output saved to: {{outputPath}}',
193
+ copied: 'Output path copied to clipboard: {{outputPath}}',
194
+ error: 'Merge failed: {{message}}'
195
+ },
159
196
  imgCompress: {
160
197
  selectSource: 'Select input method',
161
198
  sourceFileDialog: 'Select file (dialog)',
package/src/locales/zh.js CHANGED
@@ -41,6 +41,7 @@ export default {
41
41
  imgBase64: '图片 <-> Base64',
42
42
  imgConvert: '图片格式转换',
43
43
  imgSplit: '图片分割工具',
44
+ imgMerge: '图片拼接工具',
44
45
  dominantColor: '图片主色识别',
45
46
  colorPick: '颜色吸取',
46
47
  crosshair: '全屏十字辅助线',
@@ -156,6 +157,42 @@ export default {
156
157
  vLines: '垂直分割线 (例如 10 20 30 或 10% 20%):',
157
158
  success: '成功将图片分割为 {{count}} 份。保存至 {{path}}'
158
159
  },
160
+ imgMerge: {
161
+ selectSource: '选择图片来源',
162
+ sourceFolder: '选择目录中的图片',
163
+ sourceMulti: '选择多张图片',
164
+ inputMethod: '选择输入方式',
165
+ folderDialog: '选择文件夹(对话框)',
166
+ folderManual: '手动输入文件夹路径',
167
+ multiDialog: '多选图片(对话框)',
168
+ multiManual: '手动输入多张图片路径',
169
+ continueAdd: '是否继续添加图片',
170
+ yes: '是',
171
+ no: '否',
172
+ enterFolderPath: '请输入图片目录路径:',
173
+ enterImagePaths: '请输入图片路径(可用逗号或换行分隔):',
174
+ notExist: '路径不存在',
175
+ notDirectory: '输入路径不是文件夹',
176
+ notFile: '输入路径不是文件',
177
+ notImage: '文件不是支持的图片格式',
178
+ needAtLeastTwo: '至少需要 2 张图片才能拼接。',
179
+ dialogFail: '对话框不可用或已取消,请重试或改用手动输入。',
180
+ selectDirection: '选择拼接方向',
181
+ horizontal: '水平拼接(从左到右)',
182
+ vertical: '垂直拼接(从上到下)',
183
+ selectOutput: '选择输出方式',
184
+ outputDefault: '默认输出到输入目录',
185
+ outputFolderDialog: '选择输出目录(对话框)',
186
+ outputFolderManual: '手动输入输出目录',
187
+ outputFileManual: '手动输入完整输出文件路径',
188
+ enterOutputDir: '请输入输出目录路径:',
189
+ outputPathPrompt: '请输入输出文件路径:',
190
+ outputPathRequired: '输出文件路径不能为空。',
191
+ invalidImageSize: '存在无法读取尺寸的图片文件。',
192
+ success: '拼接完成,已输出到:{{outputPath}}',
193
+ copied: '输出路径已复制到剪贴板:{{outputPath}}',
194
+ error: '拼接失败:{{message}}'
195
+ },
159
196
  imgCompress: {
160
197
  selectSource: '选择输入方式',
161
198
  sourceFileDialog: '选择文件(对话框)',
@@ -16,6 +16,21 @@ export function selectFile(filter = 'All Files|*.*') {
16
16
  }
17
17
  }
18
18
 
19
+ export function selectFiles(filter = 'All Files|*.*') {
20
+ if (!isWindows()) return null;
21
+ try {
22
+ const cmd = `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; $fd = New-Object System.Windows.Forms.OpenFileDialog; $fd.Filter='${filter.replace(/"/g, '\\"')}'; $fd.Multiselect=$true; if($fd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){ $json = ConvertTo-Json @($fd.FileNames) -Compress; $bytes = [System.Text.Encoding]::UTF8.GetBytes($json); Write-Output ([Convert]::ToBase64String($bytes)) }"`;
23
+ const buf = execSync(cmd, { stdio: ['pipe', 'pipe', 'ignore'] });
24
+ const output = Buffer.isBuffer(buf) ? buf.toString('utf8').trim() : String(buf).trim();
25
+ if (!output) return null;
26
+ const parsed = JSON.parse(Buffer.from(output, 'base64').toString('utf8'));
27
+ if (!Array.isArray(parsed)) return null;
28
+ return parsed;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
19
34
  export function saveFile(defaultName = 'output.txt', filter = 'All Files|*.*') {
20
35
  if (!isWindows()) return null;
21
36
  try {