jsmdcui 0.5.0 → 0.7.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/CHANGELOG.md +228 -0
- package/README.md +305 -25
- package/clean.sh +9 -0
- package/demo.resized.jpg +0 -0
- package/demos/image-processor.md +361 -0
- package/demos/image-processor.zh-TW.md +361 -0
- package/demos/select.md +63 -0
- package/demos/todo-zh.md +92 -0
- package/demos/todo.md +93 -0
- package/package.json +1 -1
- package/runmd.mjs +110 -17
- package/runtime/help/help.md +305 -25
- package/single-exe/packAssets.sh +1 -1
- package/src/cui/id-collision.mjs +151 -0
- package/src/cui/kitty-debug.mjs +25 -0
- package/src/cui/kitty-images.mjs +190 -0
- package/src/cui/rpc.mjs +227 -1
- package/src/cui/server.mjs +1 -1
- package/src/cui/task-checkbox.mjs +60 -0
- package/src/index.js +297 -23
- package/src/plugins/js-bridge.js +346 -4
- package/src/screen/screen.js +108 -1
- package/tests/cat-markdown.test.js +6 -5
- package/tests/demo.test.js +203 -4
- package/tests/heading-list-selector.test.js +346 -0
- package/tests/id-collision.test.js +147 -0
- package/tests/kitty-demo.md +184 -0
- package/tests/kitty-images.test.js +147 -0
- package/tests/task-checkbox.test.js +33 -0
- package/tests/wui-responsive-images.test.js +35 -0
- package/tests/wui.test.js +30 -0
- package/tui +4 -2
- package/wui +6 -2
package/demo.resized.jpg
ADDED
|
Binary file
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env jsmdcui
|
|
2
|
+
|
|
3
|
+
# Bun.Image Processor
|
|
4
|
+
|
|
5
|
+
Paste the path to a local image below (for example, `/home/me/photo.jpg`). The output file will be written next to the source as `original.resized.jpg` or `original.resized.png`; the source image will not be overwritten.
|
|
6
|
+
|
|
7
|
+
```text#image-path
|
|
8
|
+
demo.jpg
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- [Read image metadata](javascript:readMetadata())
|
|
12
|
+
|
|
13
|
+
```text#image-metadata
|
|
14
|
+
Metadata has not been read
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Dimensions
|
|
18
|
+
|
|
19
|
+
Width (required, positive integer):
|
|
20
|
+
|
|
21
|
+
```text#resize-width
|
|
22
|
+
800
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Height (leave blank to preserve the source aspect ratio):
|
|
26
|
+
|
|
27
|
+
```text#resize-height
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Select Fit
|
|
32
|
+
|
|
33
|
+
- [ ] fill (stretch to the exact width and height; may distort the image)
|
|
34
|
+
- [x] inside (preserve the aspect ratio and fit within the dimensions)
|
|
35
|
+
|
|
36
|
+
## Select Filter
|
|
37
|
+
|
|
38
|
+
- [x] lanczos3 (general-purpose and sharp for photos; default)
|
|
39
|
+
- [ ] lanczos2 (slightly softer with fewer ringing artifacts)
|
|
40
|
+
- [ ] mitchell (smooth gradients)
|
|
41
|
+
- [ ] cubic (sharper, but may produce ringing)
|
|
42
|
+
- [ ] mks2013 (Magic Kernel Sharp)
|
|
43
|
+
- [ ] mks2021 (Magic Kernel Sharp)
|
|
44
|
+
- [ ] bilinear (fast and soft)
|
|
45
|
+
- [ ] linear (fast and soft)
|
|
46
|
+
- [ ] box (area-average; useful for large integer downscales)
|
|
47
|
+
- [ ] nearest (pixel art and hard edges)
|
|
48
|
+
|
|
49
|
+
## Select Without Enlargement
|
|
50
|
+
|
|
51
|
+
- [x] yes (do not enlarge a smaller source image)
|
|
52
|
+
- [ ] no (allow enlargement)
|
|
53
|
+
|
|
54
|
+
## Orientation and mirroring
|
|
55
|
+
|
|
56
|
+
## Select Auto Orient
|
|
57
|
+
|
|
58
|
+
- [x] yes (apply the JPEG EXIF orientation automatically)
|
|
59
|
+
- [ ] no
|
|
60
|
+
|
|
61
|
+
## Select Rotate
|
|
62
|
+
|
|
63
|
+
- [x] 0°
|
|
64
|
+
- [ ] 90°
|
|
65
|
+
- [ ] 180°
|
|
66
|
+
- [ ] 270°
|
|
67
|
+
|
|
68
|
+
## Select Flip
|
|
69
|
+
|
|
70
|
+
- [ ] yes (mirror vertically)
|
|
71
|
+
- [x] no
|
|
72
|
+
|
|
73
|
+
## Select Flop
|
|
74
|
+
|
|
75
|
+
- [ ] yes (mirror horizontally)
|
|
76
|
+
- [x] no
|
|
77
|
+
|
|
78
|
+
## Color
|
|
79
|
+
|
|
80
|
+
Brightness multiplier (`1` leaves brightness unchanged):
|
|
81
|
+
|
|
82
|
+
```text#brightness
|
|
83
|
+
1
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Saturation multiplier (`0` produces greyscale, `1` is unchanged, and values above `1` increase saturation):
|
|
87
|
+
|
|
88
|
+
```text#saturation
|
|
89
|
+
1
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Select Output Format
|
|
93
|
+
|
|
94
|
+
- [x] JPEG (.jpg)
|
|
95
|
+
- [ ] PNG (.png)
|
|
96
|
+
|
|
97
|
+
## JPEG Options
|
|
98
|
+
|
|
99
|
+
Quality (`1`–`100`; default `80`):
|
|
100
|
+
|
|
101
|
+
```text#jpeg-quality
|
|
102
|
+
80
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Select Progressive JPEG
|
|
106
|
+
|
|
107
|
+
- [ ] yes
|
|
108
|
+
- [x] no
|
|
109
|
+
|
|
110
|
+
## PNG Options
|
|
111
|
+
|
|
112
|
+
Compression level (`0`–`9`; default `6`):
|
|
113
|
+
|
|
114
|
+
```text#png-compression
|
|
115
|
+
6
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Select PNG Palette
|
|
119
|
+
|
|
120
|
+
- [ ] yes (indexed-color PNG)
|
|
121
|
+
- [x] no (full-color PNG)
|
|
122
|
+
|
|
123
|
+
Palette color count (used when Palette is enabled; `2`–`256`):
|
|
124
|
+
|
|
125
|
+
```text#png-colors
|
|
126
|
+
256
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Select PNG Dither
|
|
130
|
+
|
|
131
|
+
- [x] yes
|
|
132
|
+
- [ ] no
|
|
133
|
+
|
|
134
|
+
- [Process and write image](javascript:resizeAndWrite())
|
|
135
|
+
|
|
136
|
+
## Processing Result
|
|
137
|
+
|
|
138
|
+
```text#write-status
|
|
139
|
+
Not started
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```js front
|
|
143
|
+
function firstWord(value) {
|
|
144
|
+
return String(value ?? '').trim().split(/\s+/)[0].toLowerCase();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function includesChoice(id, choices, fallback) {
|
|
148
|
+
const value = String($('#' + id).val() ?? '').toLowerCase();
|
|
149
|
+
return choices.find(choice => value.includes(choice)) ?? fallback;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function yes(id) {
|
|
153
|
+
return String($('#' + id).val() ?? '').toLowerCase().includes('yes');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function numberText(id) {
|
|
157
|
+
return $('#' + id).val().trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function describeError(error) {
|
|
161
|
+
if (error == null) return 'Unknown error (no error details were returned)';
|
|
162
|
+
if (typeof error === 'string') return error;
|
|
163
|
+
const details = [];
|
|
164
|
+
if (error?.code) details.push(`Code: ${error.code}`);
|
|
165
|
+
if (error?.name && error.name !== 'Error') details.push(`Type: ${error.name}`);
|
|
166
|
+
if (error?.message) details.push(`Message: ${error.message}`);
|
|
167
|
+
if (error?.error && error.error !== error) details.push(`Error: ${describeError(error.error)}`);
|
|
168
|
+
if (error?.cause && error.cause !== error) details.push(`Cause: ${describeError(error.cause)}`);
|
|
169
|
+
if (error?.stack && String(error.stack) !== String(error.message ?? ''))
|
|
170
|
+
details.push(`Stack: ${error.stack}`);
|
|
171
|
+
if (details.length) return details.join('\n');
|
|
172
|
+
try {
|
|
173
|
+
const json = JSON.stringify(error, null, 2);
|
|
174
|
+
if (json && json !== '{}') return json;
|
|
175
|
+
} catch {}
|
|
176
|
+
return String(error);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function readMetadata() {
|
|
180
|
+
const output = $('#image-metadata');
|
|
181
|
+
output.val('Reading metadata…');
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const inputPath = $('#image-path').val().trim();
|
|
185
|
+
const result = await rpc.readImageMetadata(inputPath);
|
|
186
|
+
if (!result || typeof result !== 'object' || result.ok !== true) {
|
|
187
|
+
const error = result && typeof result === 'object' && 'error' in result
|
|
188
|
+
? result.error
|
|
189
|
+
: result;
|
|
190
|
+
output.val(`Metadata read failed:\n${describeError(error)}`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
output.val(`Metadata for: ${result.inputPath}\n${JSON.stringify(result.metadata, null, 2)}`);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
output.val(`Metadata read failed:\n${describeError(error)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function resizeAndWrite() {
|
|
200
|
+
const status = $('#write-status');
|
|
201
|
+
status.val('Processing…');
|
|
202
|
+
let options;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
options = {
|
|
206
|
+
inputPath: $('#image-path').val().trim(),
|
|
207
|
+
width: numberText('resize-width'),
|
|
208
|
+
height: numberText('resize-height'),
|
|
209
|
+
fit: includesChoice('select-fit', ['fill', 'inside'], 'inside'),
|
|
210
|
+
filter: includesChoice('select-filter', [
|
|
211
|
+
'nearest', 'box', 'bilinear', 'cubic', 'mitchell',
|
|
212
|
+
'lanczos2', 'lanczos3', 'mks2013', 'mks2021', 'linear',
|
|
213
|
+
], 'lanczos3'),
|
|
214
|
+
withoutEnlargement: yes('select-without-enlargement'),
|
|
215
|
+
autoOrient: yes('select-auto-orient'),
|
|
216
|
+
rotate: Number.parseInt(firstWord($('#select-rotate').val()), 10),
|
|
217
|
+
flip: yes('select-flip'),
|
|
218
|
+
flop: yes('select-flop'),
|
|
219
|
+
brightness: numberText('brightness'),
|
|
220
|
+
saturation: numberText('saturation'),
|
|
221
|
+
format: includesChoice('select-output-format', ['png', 'jpeg'], 'jpeg'),
|
|
222
|
+
jpegQuality: numberText('jpeg-quality'),
|
|
223
|
+
progressive: yes('select-progressive-jpeg'),
|
|
224
|
+
pngCompression: numberText('png-compression'),
|
|
225
|
+
pngPalette: yes('select-png-palette'),
|
|
226
|
+
pngColors: numberText('png-colors'),
|
|
227
|
+
pngDither: yes('select-png-dither'),
|
|
228
|
+
};
|
|
229
|
+
const result = await rpc.resizeImage(options);
|
|
230
|
+
|
|
231
|
+
if (!result || typeof result !== 'object' || result.ok !== true) {
|
|
232
|
+
const error = result && typeof result === 'object' && 'error' in result
|
|
233
|
+
? result.error
|
|
234
|
+
: result;
|
|
235
|
+
status.val(`Write failed:\n${describeError(error)}\nOptions read:\n${JSON.stringify(options, null, 2)}`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
status.val(`Successfully wrote: ${result.outputPath}\n${result.width}×${result.height}, ${result.bytes} bytes\nOptions read:\n${JSON.stringify(options, null, 2)}`);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const optionText = options ? `\nOptions read:\n${JSON.stringify(options, null, 2)}` : '';
|
|
242
|
+
status.val(`Write failed:\n${describeError(error)}${optionText}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
```js back
|
|
248
|
+
import { dirname, extname, join, basename, resolve } from 'node:path';
|
|
249
|
+
|
|
250
|
+
function integer(value, name, { min = 1, max = Number.MAX_SAFE_INTEGER, optional = false } = {}) {
|
|
251
|
+
if (optional && String(value ?? '').trim() === '') return undefined;
|
|
252
|
+
const result = Number(value);
|
|
253
|
+
if (!Number.isInteger(result) || result < min || result > max)
|
|
254
|
+
throw new Error(`${name} must be an integer from ${min} to ${max}`);
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function finite(value, name, { min = 0 } = {}) {
|
|
259
|
+
const result = Number(value);
|
|
260
|
+
if (!Number.isFinite(result) || result < min)
|
|
261
|
+
throw new Error(`${name} must be a number greater than or equal to ${min}`);
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function describeBackendError(error) {
|
|
266
|
+
if (error == null) return 'Unknown error (no error details were returned)';
|
|
267
|
+
if (typeof error === 'string') return error;
|
|
268
|
+
const details = [];
|
|
269
|
+
if (error?.code) details.push(`[${error.code}]`);
|
|
270
|
+
if (error?.name && error.name !== 'Error') details.push(error.name);
|
|
271
|
+
if (error?.message) details.push(error.message);
|
|
272
|
+
if (error?.cause && error.cause !== error)
|
|
273
|
+
details.push(`Cause: ${describeBackendError(error.cause)}`);
|
|
274
|
+
if (error?.stack) details.push(`Stack:\n${error.stack}`);
|
|
275
|
+
if (details.length) return details.join('\n');
|
|
276
|
+
try {
|
|
277
|
+
const json = JSON.stringify(error, null, 2);
|
|
278
|
+
if (json && json !== '{}') return json;
|
|
279
|
+
} catch {}
|
|
280
|
+
return String(error);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function readImageMetadata(inputText) {
|
|
284
|
+
try {
|
|
285
|
+
const pathText = String(inputText ?? '').trim();
|
|
286
|
+
if (!pathText) throw new Error('Paste an image path first');
|
|
287
|
+
|
|
288
|
+
const inputPath = resolve(pathText);
|
|
289
|
+
const inputFile = Bun.file(inputPath);
|
|
290
|
+
if (!await inputFile.exists()) throw new Error(`Input file not found: ${inputPath}`);
|
|
291
|
+
|
|
292
|
+
const metadata = await new Bun.Image(inputFile).metadata();
|
|
293
|
+
return { ok: true, inputPath, metadata };
|
|
294
|
+
} catch (error) {
|
|
295
|
+
return { ok: false, error: describeBackendError(error) };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function resizeImage(options = {}) {
|
|
300
|
+
try {
|
|
301
|
+
const inputText = String(options.inputPath ?? '').trim();
|
|
302
|
+
if (!inputText) throw new Error('Paste an image path first');
|
|
303
|
+
|
|
304
|
+
const inputPath = resolve(inputText);
|
|
305
|
+
const inputFile = Bun.file(inputPath);
|
|
306
|
+
if (!await inputFile.exists()) throw new Error(`Input file not found: ${inputPath}`);
|
|
307
|
+
|
|
308
|
+
const width = integer(options.width, 'Width');
|
|
309
|
+
const height = integer(options.height, 'Height', { optional: true });
|
|
310
|
+
const fit = options.fit === 'fill' ? 'fill' : 'inside';
|
|
311
|
+
const filters = new Set([
|
|
312
|
+
'nearest', 'box', 'bilinear', 'linear', 'cubic', 'mitchell',
|
|
313
|
+
'lanczos2', 'lanczos3', 'mks2013', 'mks2021',
|
|
314
|
+
]);
|
|
315
|
+
const filter = filters.has(options.filter) ? options.filter : 'lanczos3';
|
|
316
|
+
const rotate = [0, 90, 180, 270].includes(options.rotate) ? options.rotate : 0;
|
|
317
|
+
const brightness = finite(options.brightness, 'Brightness');
|
|
318
|
+
const saturation = finite(options.saturation, 'Saturation');
|
|
319
|
+
const format = options.format === 'png' ? 'png' : 'jpeg';
|
|
320
|
+
|
|
321
|
+
let image = new Bun.Image(inputFile, { autoOrient: options.autoOrient !== false });
|
|
322
|
+
if (rotate) image = image.rotate(rotate);
|
|
323
|
+
if (options.flip) image = image.flip();
|
|
324
|
+
if (options.flop) image = image.flop();
|
|
325
|
+
image = image.resize(width, height, {
|
|
326
|
+
fit,
|
|
327
|
+
filter,
|
|
328
|
+
withoutEnlargement: Boolean(options.withoutEnlargement),
|
|
329
|
+
});
|
|
330
|
+
if (brightness !== 1 || saturation !== 1)
|
|
331
|
+
image = image.modulate({ brightness, saturation });
|
|
332
|
+
|
|
333
|
+
const originalName = basename(inputPath, extname(inputPath));
|
|
334
|
+
const outputPath = join(dirname(inputPath), `${originalName}.resized.${format === 'jpeg' ? 'jpg' : 'png'}`);
|
|
335
|
+
|
|
336
|
+
if (format === 'jpeg') {
|
|
337
|
+
image = image.jpeg({
|
|
338
|
+
quality: integer(options.jpegQuality, 'JPEG quality', { min: 1, max: 100 }),
|
|
339
|
+
progressive: Boolean(options.progressive),
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
const palette = Boolean(options.pngPalette);
|
|
343
|
+
const pngOptions = {
|
|
344
|
+
compressionLevel: integer(options.pngCompression, 'PNG compression level', { min: 0, max: 9 }),
|
|
345
|
+
palette,
|
|
346
|
+
};
|
|
347
|
+
if (palette) {
|
|
348
|
+
pngOptions.colors = integer(options.pngColors, 'PNG palette color count', { min: 2, max: 256 });
|
|
349
|
+
pngOptions.dither = Boolean(options.pngDither);
|
|
350
|
+
}
|
|
351
|
+
image = image.png(pngOptions);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const bytes = await image.write(outputPath);
|
|
355
|
+
const metadata = await new Bun.Image(outputPath).metadata();
|
|
356
|
+
return { ok: true, outputPath, bytes, width: metadata.width, height: metadata.height };
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return { ok: false, error: describeBackendError(error) };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
```
|