jsmdcui 0.4.0 → 0.6.3

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,360 @@
1
+ #!/usr/bin/env jsmdcui
2
+ # Bun.Image Processor
3
+
4
+ 先把本機圖片路徑貼到下方(例如 `/home/me/photo.jpg`)。輸出檔會放在原圖旁邊,檔名為 `original.resized.jpg` 或 `original.resized.png`;原圖不會被覆寫。
5
+
6
+ ```text#image-path
7
+ demo.jpg
8
+ ```
9
+
10
+ - [讀取圖片 metadata](javascript:readMetadata())
11
+
12
+ ```text#image-metadata
13
+ 尚未讀取 metadata
14
+ ```
15
+
16
+ ## 尺寸
17
+
18
+ 寬度(必填,正整數):
19
+
20
+ ```text#resize-width
21
+ 800
22
+ ```
23
+
24
+ 高度(留白會保持原始長寬比):
25
+
26
+ ```text#resize-height
27
+
28
+ ```
29
+
30
+ ## Select Fit
31
+
32
+ - [ ] fill(精確填滿寬高,可能變形)
33
+ - [x] inside(保持比例,縮放到指定範圍內)
34
+
35
+ ## Select Filter
36
+
37
+ - [x] lanczos3(照片通用,預設)
38
+ - [ ] lanczos2(較柔和、較少光暈)
39
+ - [ ] mitchell(平滑漸層)
40
+ - [ ] cubic(較銳利)
41
+ - [ ] mks2013(Magic Kernel Sharp)
42
+ - [ ] mks2021(Magic Kernel Sharp)
43
+ - [ ] bilinear(快速、柔和)
44
+ - [ ] linear(快速、柔和)
45
+ - [ ] box(大倍率整數縮小)
46
+ - [ ] nearest(像素圖、硬邊緣)
47
+
48
+ ## Select Without Enlargement
49
+
50
+ - [x] yes(不放大較小的原圖)
51
+ - [ ] no(允許放大)
52
+
53
+ ## 方向與翻轉
54
+
55
+ ## Select Auto Orient
56
+
57
+ - [x] yes(依 JPEG EXIF 自動校正方向)
58
+ - [ ] no
59
+
60
+ ## Select Rotate
61
+
62
+ - [x] 0°
63
+ - [ ] 90°
64
+ - [ ] 180°
65
+ - [ ] 270°
66
+
67
+ ## Select Flip
68
+
69
+ - [ ] yes(上下翻轉)
70
+ - [x] no
71
+
72
+ ## Select Flop
73
+
74
+ - [ ] yes(左右翻轉)
75
+ - [x] no
76
+
77
+ ## 色彩
78
+
79
+ 亮度倍率(`1` 不變):
80
+
81
+ ```text#brightness
82
+ 1
83
+ ```
84
+
85
+ 飽和度倍率(`0` 灰階、`1` 不變、大於 `1` 增豔):
86
+
87
+ ```text#saturation
88
+ 1
89
+ ```
90
+
91
+ ## Select Output Format
92
+
93
+ - [x] JPEG (.jpg)
94
+ - [ ] PNG (.png)
95
+
96
+ ## JPEG 選項
97
+
98
+ 品質(`1`–`100`,預設 `80`):
99
+
100
+ ```text#jpeg-quality
101
+ 80
102
+ ```
103
+
104
+ ## Select Progressive JPEG
105
+
106
+ - [ ] yes
107
+ - [x] no
108
+
109
+ ## PNG 選項
110
+
111
+ 壓縮等級(`0`–`9`,預設 `6`):
112
+
113
+ ```text#png-compression
114
+ 6
115
+ ```
116
+
117
+ ## Select PNG Palette
118
+
119
+ - [ ] yes(索引色 PNG)
120
+ - [x] no(全彩 PNG)
121
+
122
+ 調色盤色數(啟用 Palette 時使用,`2`–`256`):
123
+
124
+ ```text#png-colors
125
+ 256
126
+ ```
127
+
128
+ ## Select PNG Dither
129
+
130
+ - [x] yes
131
+ - [ ] no
132
+
133
+ - [Resize and write image](javascript:resizeAndWrite())
134
+
135
+ ## 寫入狀態
136
+
137
+ ```text#write-status
138
+ 尚未執行
139
+ ```
140
+
141
+ ```js front
142
+ function firstWord(value) {
143
+ return String(value ?? '').trim().split(/\s+/)[0].toLowerCase();
144
+ }
145
+
146
+ function includesChoice(id, choices, fallback) {
147
+ const value = String($('#' + id).val() ?? '').toLowerCase();
148
+ return choices.find(choice => value.includes(choice)) ?? fallback;
149
+ }
150
+
151
+ function yes(id) {
152
+ return String($('#' + id).val() ?? '').toLowerCase().includes('yes');
153
+ }
154
+
155
+ function numberText(id) {
156
+ return $('#' + id).val().trim();
157
+ }
158
+
159
+ function describeError(error) {
160
+ if (error == null) return '未知錯誤(沒有錯誤內容)';
161
+ if (typeof error === 'string') return error;
162
+ const details = [];
163
+ if (error?.code) details.push(`錯誤代碼:${error.code}`);
164
+ if (error?.name && error.name !== 'Error') details.push(`類型:${error.name}`);
165
+ if (error?.message) details.push(`訊息:${error.message}`);
166
+ if (error?.error && error.error !== error) details.push(`錯誤:${describeError(error.error)}`);
167
+ if (error?.cause && error.cause !== error) details.push(`原因:${describeError(error.cause)}`);
168
+ if (error?.stack && String(error.stack) !== String(error.message ?? ''))
169
+ details.push(`Stack:${error.stack}`);
170
+ if (details.length) return details.join('\n');
171
+ try {
172
+ const json = JSON.stringify(error, null, 2);
173
+ if (json && json !== '{}') return json;
174
+ } catch {}
175
+ return String(error);
176
+ }
177
+
178
+ export async function readMetadata() {
179
+ const output = $('#image-metadata');
180
+ output.val('正在讀取 metadata…');
181
+
182
+ try {
183
+ const inputPath = $('#image-path').val().trim();
184
+ const result = await rpc.readImageMetadata(inputPath);
185
+ if (!result || typeof result !== 'object' || result.ok !== true) {
186
+ const error = result && typeof result === 'object' && 'error' in result
187
+ ? result.error
188
+ : result;
189
+ output.val(`讀取 metadata 失敗:\n${describeError(error)}`);
190
+ return;
191
+ }
192
+ output.val(`圖片路徑:${result.inputPath}\n${JSON.stringify(result.metadata, null, 2)}`);
193
+ } catch (error) {
194
+ output.val(`讀取 metadata 失敗:\n${describeError(error)}`);
195
+ }
196
+ }
197
+
198
+ export async function resizeAndWrite() {
199
+ const status = $('#write-status');
200
+ status.val('處理中…');
201
+ let options;
202
+
203
+ try {
204
+ options = {
205
+ inputPath: $('#image-path').val().trim(),
206
+ width: numberText('resize-width'),
207
+ height: numberText('resize-height'),
208
+ fit: includesChoice('select-fit', ['fill', 'inside'], 'inside'),
209
+ filter: includesChoice('select-filter', [
210
+ 'nearest', 'box', 'bilinear', 'cubic', 'mitchell',
211
+ 'lanczos2', 'lanczos3', 'mks2013', 'mks2021', 'linear',
212
+ ], 'lanczos3'),
213
+ withoutEnlargement: yes('select-without-enlargement'),
214
+ autoOrient: yes('select-auto-orient'),
215
+ rotate: Number.parseInt(firstWord($('#select-rotate').val()), 10),
216
+ flip: yes('select-flip'),
217
+ flop: yes('select-flop'),
218
+ brightness: numberText('brightness'),
219
+ saturation: numberText('saturation'),
220
+ format: includesChoice('select-output-format', ['png', 'jpeg'], 'jpeg'),
221
+ jpegQuality: numberText('jpeg-quality'),
222
+ progressive: yes('select-progressive-jpeg'),
223
+ pngCompression: numberText('png-compression'),
224
+ pngPalette: yes('select-png-palette'),
225
+ pngColors: numberText('png-colors'),
226
+ pngDither: yes('select-png-dither'),
227
+ };
228
+ const result = await rpc.resizeImage(options);
229
+
230
+ if (!result || typeof result !== 'object' || result.ok !== true) {
231
+ const error = result && typeof result === 'object' && 'error' in result
232
+ ? result.error
233
+ : result;
234
+ status.val(`寫入失敗:\n${describeError(error)}\n讀取選項:\n${JSON.stringify(options, null, 2)}`);
235
+ return;
236
+ }
237
+
238
+ status.val(`成功寫入:${result.outputPath}\n${result.width}×${result.height},${result.bytes} bytes\n讀取選項:\n${JSON.stringify(options, null, 2)}`);
239
+ } catch (error) {
240
+ const optionText = options ? `\n讀取選項:\n${JSON.stringify(options, null, 2)}` : '';
241
+ status.val(`寫入失敗:\n${describeError(error)}${optionText}`);
242
+ }
243
+ }
244
+ ```
245
+
246
+ ```js back
247
+ import { dirname, extname, join, basename, resolve } from 'node:path';
248
+
249
+ function integer(value, name, { min = 1, max = Number.MAX_SAFE_INTEGER, optional = false } = {}) {
250
+ if (optional && String(value ?? '').trim() === '') return undefined;
251
+ const result = Number(value);
252
+ if (!Number.isInteger(result) || result < min || result > max)
253
+ throw new Error(`${name} 必須是 ${min}–${max} 的整數`);
254
+ return result;
255
+ }
256
+
257
+ function finite(value, name, { min = 0 } = {}) {
258
+ const result = Number(value);
259
+ if (!Number.isFinite(result) || result < min)
260
+ throw new Error(`${name} 必須是至少 ${min} 的數字`);
261
+ return result;
262
+ }
263
+
264
+ function describeBackendError(error) {
265
+ if (error == null) return '未知錯誤(沒有錯誤內容)';
266
+ if (typeof error === 'string') return error;
267
+ const details = [];
268
+ if (error?.code) details.push(`[${error.code}]`);
269
+ if (error?.name && error.name !== 'Error') details.push(error.name);
270
+ if (error?.message) details.push(error.message);
271
+ if (error?.cause && error.cause !== error)
272
+ details.push(`原因:${describeBackendError(error.cause)}`);
273
+ if (error?.stack) details.push(`Stack:\n${error.stack}`);
274
+ if (details.length) return details.join('\n');
275
+ try {
276
+ const json = JSON.stringify(error, null, 2);
277
+ if (json && json !== '{}') return json;
278
+ } catch {}
279
+ return String(error);
280
+ }
281
+
282
+ export async function readImageMetadata(inputText) {
283
+ try {
284
+ const pathText = String(inputText ?? '').trim();
285
+ if (!pathText) throw new Error('請先貼上圖片路徑');
286
+
287
+ const inputPath = resolve(pathText);
288
+ const inputFile = Bun.file(inputPath);
289
+ if (!await inputFile.exists()) throw new Error(`找不到輸入檔:${inputPath}`);
290
+
291
+ const metadata = await new Bun.Image(inputFile).metadata();
292
+ return { ok: true, inputPath, metadata };
293
+ } catch (error) {
294
+ return { ok: false, error: describeBackendError(error) };
295
+ }
296
+ }
297
+
298
+ export async function resizeImage(options = {}) {
299
+ try {
300
+ const inputText = String(options.inputPath ?? '').trim();
301
+ if (!inputText) throw new Error('請先貼上圖片路徑');
302
+
303
+ const inputPath = resolve(inputText);
304
+ const inputFile = Bun.file(inputPath);
305
+ if (!await inputFile.exists()) throw new Error(`找不到輸入檔:${inputPath}`);
306
+
307
+ const width = integer(options.width, '寬度');
308
+ const height = integer(options.height, '高度', { optional: true });
309
+ const fit = options.fit === 'fill' ? 'fill' : 'inside';
310
+ const filters = new Set([
311
+ 'nearest', 'box', 'bilinear', 'linear', 'cubic', 'mitchell',
312
+ 'lanczos2', 'lanczos3', 'mks2013', 'mks2021',
313
+ ]);
314
+ const filter = filters.has(options.filter) ? options.filter : 'lanczos3';
315
+ const rotate = [0, 90, 180, 270].includes(options.rotate) ? options.rotate : 0;
316
+ const brightness = finite(options.brightness, '亮度');
317
+ const saturation = finite(options.saturation, '飽和度');
318
+ const format = options.format === 'png' ? 'png' : 'jpeg';
319
+
320
+ let image = new Bun.Image(inputFile, { autoOrient: options.autoOrient !== false });
321
+ if (rotate) image = image.rotate(rotate);
322
+ if (options.flip) image = image.flip();
323
+ if (options.flop) image = image.flop();
324
+ image = image.resize(width, height, {
325
+ fit,
326
+ filter,
327
+ withoutEnlargement: Boolean(options.withoutEnlargement),
328
+ });
329
+ if (brightness !== 1 || saturation !== 1)
330
+ image = image.modulate({ brightness, saturation });
331
+
332
+ const originalName = basename(inputPath, extname(inputPath));
333
+ const outputPath = join(dirname(inputPath), `${originalName}.resized.${format === 'jpeg' ? 'jpg' : 'png'}`);
334
+
335
+ if (format === 'jpeg') {
336
+ image = image.jpeg({
337
+ quality: integer(options.jpegQuality, 'JPEG 品質', { min: 1, max: 100 }),
338
+ progressive: Boolean(options.progressive),
339
+ });
340
+ } else {
341
+ const palette = Boolean(options.pngPalette);
342
+ const pngOptions = {
343
+ compressionLevel: integer(options.pngCompression, 'PNG 壓縮等級', { min: 0, max: 9 }),
344
+ palette,
345
+ };
346
+ if (palette) {
347
+ pngOptions.colors = integer(options.pngColors, 'PNG 調色盤色數', { min: 2, max: 256 });
348
+ pngOptions.dither = Boolean(options.pngDither);
349
+ }
350
+ image = image.png(pngOptions);
351
+ }
352
+
353
+ const bytes = await image.write(outputPath);
354
+ const metadata = await new Bun.Image(outputPath).metadata();
355
+ return { ok: true, outputPath, bytes, width: metadata.width, height: metadata.height };
356
+ } catch (error) {
357
+ return { ok: false, error: describeBackendError(error) };
358
+ }
359
+ }
360
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsmdcui",
3
- "version": "0.4.0",
3
+ "version": "0.6.3",
4
4
  "description": "Markdown as a Common UI for Terminals and Web Browsers",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/runmd.mjs CHANGED
@@ -27,16 +27,23 @@ async function readTemplate(pathname)
27
27
  await Bun.file(path.join(REPO_ROOT, pathname)).text()
28
28
  }
29
29
 
30
- async function readMarkdownInput(mdpath)
30
+ export async function readMarkdownInput(mdpath)
31
31
  {
32
32
  const file = Bun.file(mdpath);
33
33
  if (await file.exists()) return await file.text();
34
34
  const assetName = path.basename(mdpath);
35
35
  const internalText = readInternalAssetText(assetName);
36
- if (internalText != null) return internalText;
36
+ if (internalText != null) {
37
+ await Bun.write(mdpath, internalText);
38
+ return internalText;
39
+ }
37
40
  const fallbackPath = path.join(REPO_ROOT, assetName);
38
41
  const fallback = Bun.file(fallbackPath);
39
- if (await fallback.exists()) return await fallback.text();
42
+ if (await fallback.exists()) {
43
+ const fallbackText = await fallback.text();
44
+ await Bun.write(mdpath, fallbackText);
45
+ return fallbackText;
46
+ }
40
47
  throw new Error(`md file not found: ${mdpath}`);
41
48
  }
42
49
 
@@ -189,6 +196,110 @@ export function createTui(md,TERMINAL_WIDTH=30) // ANSI Colors
189
196
  )
190
197
  }
191
198
 
199
+ function parseWuiControlIdentity(info)
200
+ {
201
+ const match = String(info ?? "").match(/^([A-Za-z_][\w:-]*)(?:#([A-Za-z_][\w:-]*))?((?:\.[A-Za-z_][\w:-]*)*)$/);
202
+ if (!match || !["text", "textarea"].includes(match[1])) return null;
203
+ return {
204
+ tag: match[1],
205
+ id: match[2] || "",
206
+ classes: match[3] ? match[3].slice(1).split(".") : [],
207
+ };
208
+ }
209
+
210
+ function escapeHtmlAttribute(value)
211
+ {
212
+ return String(value)
213
+ .replaceAll("&", "&amp;")
214
+ .replaceAll('"', "&quot;")
215
+ .replaceAll("<", "&lt;")
216
+ .replaceAll(">", "&gt;");
217
+ }
218
+
219
+ export function convertWuiTextareas(html)
220
+ {
221
+ return String(html).replace(
222
+ /<pre><code class="language-([^"]+)">([^]*?)<\/code><\/pre>/g,
223
+ (whole, info, content) => {
224
+ const identity = parseWuiControlIdentity(info);
225
+ if (!identity) return whole;
226
+ const value = content.replace(/\n$/, "");
227
+ const contentLines = value.split("\n");
228
+ const cols = Math.max(1, ...contentLines.map(line => [...line].length));
229
+ const attrs = [
230
+ `data-mdcui-tag="${identity.tag}"`,
231
+ `data-mdcui-language="${escapeHtmlAttribute(info)}"`,
232
+ identity.id ? `id="${escapeHtmlAttribute(identity.id)}"` : "",
233
+ `class="${escapeHtmlAttribute([
234
+ `language-${info}`,
235
+ ...identity.classes,
236
+ ].join(" "))}"`,
237
+ `rows="${Math.max(1, contentLines.length)}"`,
238
+ `cols="${cols}"`,
239
+ 'wrap="soft"',
240
+ 'style="box-sizing:border-box;max-width:100%;width:100%;resize:vertical;overflow-y:hidden"',
241
+ ].filter(Boolean).join(" ");
242
+ return `<textarea ${attrs}>${value}</textarea>`;
243
+ },
244
+ );
245
+ }
246
+
247
+ export function wrapWuiHeadingSections(html)
248
+ {
249
+ const input = String(html);
250
+ const bodyStart = input.match(/<body\b[^>]*>/i);
251
+ if (bodyStart?.index != null) {
252
+ const contentStart = bodyStart.index + bodyStart[0].length;
253
+ const bodyEnd = input.slice(contentStart).search(/<\/body\s*>/i);
254
+ if (bodyEnd >= 0) {
255
+ const contentEnd = contentStart + bodyEnd;
256
+ return input.slice(0, contentStart) +
257
+ wrapWuiHeadingSections(input.slice(contentStart, contentEnd)) +
258
+ input.slice(contentEnd);
259
+ }
260
+ }
261
+
262
+ let markerPrefix = "MDCUI_HEADING_OPAQUE_";
263
+ while (input.includes(markerPrefix)) markerPrefix = "_" + markerPrefix;
264
+ const opaqueHtml = [];
265
+ const searchable = input.replace(
266
+ /<!--[^]*?-->|<(script|style|pre|code|textarea|template)\b[^>]*>[^]*?<\/\1\s*>/gi,
267
+ (whole) => {
268
+ const marker = `\0${markerPrefix}${opaqueHtml.length}\0`;
269
+ opaqueHtml.push({ marker, html: whole });
270
+ return marker;
271
+ }
272
+ );
273
+
274
+ const heading = /<h([1-6])\b[^>]*>[^]*?<\/h\1\s*>/gi;
275
+ const openLevels = [];
276
+ let output = "";
277
+ let cursor = 0;
278
+ let match;
279
+
280
+ while ((match = heading.exec(searchable)) !== null) {
281
+ const level = Number(match[1]);
282
+ output += searchable.slice(cursor, match.index);
283
+ while (openLevels.length && openLevels.at(-1) >= level) {
284
+ output += "</section>\n";
285
+ openLevels.pop();
286
+ }
287
+ output += `<section>\n${match[0]}`;
288
+ openLevels.push(level);
289
+ cursor = heading.lastIndex;
290
+ }
291
+
292
+ output += searchable.slice(cursor);
293
+ while (openLevels.length) {
294
+ output += "</section>\n";
295
+ openLevels.pop();
296
+ }
297
+ for (const opaque of opaqueHtml) {
298
+ output = output.replace(opaque.marker, opaque.html);
299
+ }
300
+ return output;
301
+ }
302
+
192
303
  export async function createWui(md,mdpath) // HTML
193
304
  {
194
305
 
@@ -209,16 +320,42 @@ export async function createWui(md,mdpath) // HTML
209
320
  'class="task-list-item-checkbox" disabled',
210
321
  'class="task-list-item-checkbox"'
211
322
  )
323
+
324
+ const taskItemStart = '(<li\\b(?=[^>]*\\bclass="[^"]*\\btask-list-item\\b[^"]*")[^>]*>\\s*)'
325
+ const taskCheckbox = '(<input\\b(?=[^>]*\\btype="checkbox")[^>]*>)'
326
+ md = md.replace(
327
+ new RegExp(taskItemStart + taskCheckbox + '([^<]*(?:<p>[^]*?<\\/p>[^<]*)*)', 'g'),
328
+ (whole, itemStart, checkbox, content) => {
329
+ if (!content.trim()) return whole
330
+ return `${itemStart}<label>${checkbox}${content}</label>`
331
+ }
332
+ )
333
+
334
+ md = convertWuiTextareas(md)
335
+ md = wrapWuiHeadingSections(md)
212
336
 
213
337
  const mdb = path.basename(mdpath);
214
338
 
215
-
216
- md += `
217
-
218
- <scr`+`ipt type="module" src="./${mdb}.front.js">
219
- </scr`+`ipt>
220
-
221
- `;
339
+ const moduleScript = `<scr`+`ipt type="module" src="./${mdb}.front.js"></scr`+`ipt>`;
340
+ if (!/^\s*<!doctype html>/i.test(md)) {
341
+ md = `<!doctype html>
342
+ <html lang="zh-TW">
343
+ <head>
344
+ <meta charset="utf-8">
345
+ <meta name="viewport" content="width=device-width, initial-scale=1">
346
+ <title>${escapeHtmlAttribute(mdb)}</title>
347
+ </head>
348
+ <body>
349
+ ${md}
350
+ ${moduleScript}
351
+ </body>
352
+ </html>
353
+ `;
354
+ } else if (/<\/body\s*>/i.test(md)) {
355
+ md = md.replace(/<\/body\s*>/i, `${moduleScript}\n</body>`);
356
+ } else {
357
+ md += `\n${moduleScript}\n`;
358
+ }
222
359
 
223
360
 
224
361
  await Bun.write(mdpath+".html",md)