forgedock 1.0.13 → 1.0.14
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/bin/forgedock.mjs +485 -410
- package/bin/tui.mjs +400 -0
- package/package.json +1 -1
- package/scripts/gen-logo.mjs +391 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/gen-logo.mjs — ForgeDock PNG → half-block ANSI art converter
|
|
4
|
+
*
|
|
5
|
+
* Zero-dependency PNG parser that uses Node.js built-in zlib to decompress
|
|
6
|
+
* PNG IDAT chunks and extract RGBA pixel data. Converts each pair of vertical
|
|
7
|
+
* pixel rows into a Unicode ▀ (U+2580 UPPER HALF BLOCK) with 24-bit ANSI
|
|
8
|
+
* color (fg = upper pixel, bg = lower pixel), producing embedded art suitable
|
|
9
|
+
* for pasting into the LOGO_ART_TRUECOLOR constant in bin/tui.mjs.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/gen-logo.mjs <input.png> [--width <cols>] [--brand-r R --brand-g G --brand-b B]
|
|
13
|
+
*
|
|
14
|
+
* Options:
|
|
15
|
+
* <input.png> Path to source PNG (recommended: 40×40 or 80×80 px)
|
|
16
|
+
* --width <cols> Output width in terminal columns (default: 20)
|
|
17
|
+
* --brand-r <0-255> Override brand color red channel (default: 88)
|
|
18
|
+
* --brand-g <0-255> Override brand color green channel (default: 166)
|
|
19
|
+
* --brand-b <0-255> Override brand color blue channel (default: 255)
|
|
20
|
+
* --threshold <0-255> Pixel brightness threshold for fg/bg (default: 128)
|
|
21
|
+
* --help Show this help
|
|
22
|
+
*
|
|
23
|
+
* Limitations:
|
|
24
|
+
* - Supports RGB and RGBA PNG files (bit depth 8). Interlaced PNGs are not supported.
|
|
25
|
+
* - Palette (indexed) PNGs are not supported.
|
|
26
|
+
* - Grayscale PNGs are supported (treated as single-channel brightness).
|
|
27
|
+
* - The script re-colorizes pixels: bright pixels → brand blue, dark pixels → background.
|
|
28
|
+
* For a multi-color logo, remove the re-colorize step and use original pixel RGB values.
|
|
29
|
+
*
|
|
30
|
+
* Output:
|
|
31
|
+
* Prints a JavaScript snippet with the LOGO_ART_TRUECOLOR array contents to stdout.
|
|
32
|
+
* Redirect to a file and paste into bin/tui.mjs to update the embedded art.
|
|
33
|
+
*
|
|
34
|
+
* Example:
|
|
35
|
+
* node scripts/gen-logo.mjs assets/logo.png --width 24 > /tmp/logo-art.js
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
39
|
+
import { inflateSync } from "node:zlib";
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Argument parsing
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const args = process.argv.slice(2);
|
|
46
|
+
|
|
47
|
+
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
48
|
+
process.stdout.write(`
|
|
49
|
+
Usage: node scripts/gen-logo.mjs <input.png> [options]
|
|
50
|
+
|
|
51
|
+
Options:
|
|
52
|
+
--width <cols> Output width in terminal columns (default: 20)
|
|
53
|
+
--brand-r <0-255> Brand color red channel (default: 88)
|
|
54
|
+
--brand-g <0-255> Brand color green channel (default: 166)
|
|
55
|
+
--brand-b <0-255> Brand color blue channel (default: 255)
|
|
56
|
+
--bg-r <0-255> Background color red channel (default: 13)
|
|
57
|
+
--bg-g <0-255> Background color green channel (default: 17)
|
|
58
|
+
--bg-b <0-255> Background color blue channel (default: 23)
|
|
59
|
+
--threshold <0-255> Brightness cutoff fg/bg (default: 128)
|
|
60
|
+
--help Show this help
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
node scripts/gen-logo.mjs assets/logo.png --width 24
|
|
64
|
+
`);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getArg(name, defaultValue) {
|
|
69
|
+
const idx = args.indexOf(name);
|
|
70
|
+
if (idx === -1 || idx + 1 >= args.length) return defaultValue;
|
|
71
|
+
return Number(args[idx + 1]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const inputPath = args.find((a) => !a.startsWith("--"));
|
|
75
|
+
if (!inputPath) {
|
|
76
|
+
process.stderr.write("Error: no input PNG path provided.\n");
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const outputWidth = getArg("--width", 20);
|
|
81
|
+
const brandR = getArg("--brand-r", 88);
|
|
82
|
+
const brandG = getArg("--brand-g", 166);
|
|
83
|
+
const brandB = getArg("--brand-b", 255);
|
|
84
|
+
const bgR = getArg("--bg-r", 13);
|
|
85
|
+
const bgG = getArg("--bg-g", 17);
|
|
86
|
+
const bgB = getArg("--bg-b", 23);
|
|
87
|
+
const threshold = getArg("--threshold", 128);
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// PNG parser
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Read a 4-byte big-endian unsigned int from a Buffer at offset.
|
|
95
|
+
* @param {Buffer} buf
|
|
96
|
+
* @param {number} offset
|
|
97
|
+
* @returns {number}
|
|
98
|
+
*/
|
|
99
|
+
function readUint32BE(buf, offset) {
|
|
100
|
+
return (
|
|
101
|
+
((buf[offset] & 0xff) << 24) |
|
|
102
|
+
((buf[offset + 1] & 0xff) << 16) |
|
|
103
|
+
((buf[offset + 2] & 0xff) << 8) |
|
|
104
|
+
(buf[offset + 3] & 0xff)
|
|
105
|
+
) >>> 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse a PNG file and return an object with { width, height, pixels }.
|
|
110
|
+
* pixels is a Uint8Array of RGBA values (width * height * 4 bytes, row-major).
|
|
111
|
+
*
|
|
112
|
+
* @param {Buffer} data - Raw PNG file bytes
|
|
113
|
+
* @returns {{ width: number, height: number, pixels: Uint8Array }}
|
|
114
|
+
*/
|
|
115
|
+
function parsePng(data) {
|
|
116
|
+
// Verify PNG signature: 8 bytes
|
|
117
|
+
const PNG_SIG = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
118
|
+
for (let i = 0; i < PNG_SIG.length; i++) {
|
|
119
|
+
if (data[i] !== PNG_SIG[i]) throw new Error("Not a valid PNG file.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let width = 0, height = 0;
|
|
123
|
+
let bitDepth = 0, colorType = 0;
|
|
124
|
+
const idatChunks = [];
|
|
125
|
+
let pos = 8; // after signature
|
|
126
|
+
|
|
127
|
+
// Parse chunks
|
|
128
|
+
while (pos < data.length) {
|
|
129
|
+
const length = readUint32BE(data, pos);
|
|
130
|
+
const type = data.slice(pos + 4, pos + 8).toString("ascii");
|
|
131
|
+
const chunkData = data.slice(pos + 8, pos + 8 + length);
|
|
132
|
+
pos += 12 + length; // 4 (length) + 4 (type) + length + 4 (crc)
|
|
133
|
+
|
|
134
|
+
if (type === "IHDR") {
|
|
135
|
+
width = readUint32BE(chunkData, 0);
|
|
136
|
+
height = readUint32BE(chunkData, 4);
|
|
137
|
+
bitDepth = chunkData[8];
|
|
138
|
+
colorType = chunkData[9];
|
|
139
|
+
const interlace = chunkData[12];
|
|
140
|
+
|
|
141
|
+
if (bitDepth !== 8) {
|
|
142
|
+
throw new Error(`Unsupported PNG bit depth: ${bitDepth}. Only 8-bit PNGs are supported.`);
|
|
143
|
+
}
|
|
144
|
+
if (interlace !== 0) {
|
|
145
|
+
throw new Error("Interlaced PNGs are not supported. Save as non-interlaced.");
|
|
146
|
+
}
|
|
147
|
+
// colorType: 0=grayscale, 2=RGB, 3=palette, 4=grayscale+alpha, 6=RGBA
|
|
148
|
+
if (colorType === 3) {
|
|
149
|
+
throw new Error("Palette (indexed) PNGs are not supported. Convert to RGB or RGBA.");
|
|
150
|
+
}
|
|
151
|
+
} else if (type === "IDAT") {
|
|
152
|
+
idatChunks.push(chunkData);
|
|
153
|
+
} else if (type === "IEND") {
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!width || !height) throw new Error("PNG IHDR chunk not found or invalid.");
|
|
159
|
+
if (idatChunks.length === 0) throw new Error("No IDAT chunks found in PNG.");
|
|
160
|
+
|
|
161
|
+
// Decompress all IDAT chunks concatenated
|
|
162
|
+
const compressed = Buffer.concat(idatChunks);
|
|
163
|
+
const raw = inflateSync(compressed);
|
|
164
|
+
|
|
165
|
+
// Channels per pixel
|
|
166
|
+
let channels;
|
|
167
|
+
switch (colorType) {
|
|
168
|
+
case 0: channels = 1; break; // grayscale
|
|
169
|
+
case 2: channels = 3; break; // RGB
|
|
170
|
+
case 4: channels = 2; break; // grayscale+alpha
|
|
171
|
+
case 6: channels = 4; break; // RGBA
|
|
172
|
+
default: throw new Error(`Unsupported PNG color type: ${colorType}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const bytesPerRow = 1 + width * channels; // 1 filter byte + pixel data
|
|
176
|
+
const pixels = new Uint8Array(width * height * 4); // output is always RGBA
|
|
177
|
+
|
|
178
|
+
// PNG filter reconstruction (method 0 only — per-row adaptive filtering)
|
|
179
|
+
// Prior row for Up/Average/Paeth filters
|
|
180
|
+
const prevRow = new Uint8Array(width * channels);
|
|
181
|
+
|
|
182
|
+
for (let y = 0; y < height; y++) {
|
|
183
|
+
const rowStart = y * bytesPerRow;
|
|
184
|
+
const filterType = raw[rowStart];
|
|
185
|
+
const scanline = raw.slice(rowStart + 1, rowStart + 1 + width * channels);
|
|
186
|
+
const recon = new Uint8Array(width * channels);
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < scanline.length; i++) {
|
|
189
|
+
const a = i >= channels ? recon[i - channels] : 0; // left
|
|
190
|
+
const b = prevRow[i]; // above
|
|
191
|
+
const c = i >= channels ? prevRow[i - channels] : 0; // above-left
|
|
192
|
+
|
|
193
|
+
let val;
|
|
194
|
+
switch (filterType) {
|
|
195
|
+
case 0: // None
|
|
196
|
+
val = scanline[i];
|
|
197
|
+
break;
|
|
198
|
+
case 1: // Sub
|
|
199
|
+
val = (scanline[i] + a) & 0xff;
|
|
200
|
+
break;
|
|
201
|
+
case 2: // Up
|
|
202
|
+
val = (scanline[i] + b) & 0xff;
|
|
203
|
+
break;
|
|
204
|
+
case 3: // Average
|
|
205
|
+
val = (scanline[i] + Math.floor((a + b) / 2)) & 0xff;
|
|
206
|
+
break;
|
|
207
|
+
case 4: // Paeth
|
|
208
|
+
val = (scanline[i] + paethPredictor(a, b, c)) & 0xff;
|
|
209
|
+
break;
|
|
210
|
+
default:
|
|
211
|
+
throw new Error(`Unknown PNG filter type: ${filterType} at row ${y}`);
|
|
212
|
+
}
|
|
213
|
+
recon[i] = val;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
prevRow.set(recon);
|
|
217
|
+
|
|
218
|
+
// Convert to RGBA
|
|
219
|
+
for (let x = 0; x < width; x++) {
|
|
220
|
+
const srcIdx = x * channels;
|
|
221
|
+
const dstIdx = (y * width + x) * 4;
|
|
222
|
+
|
|
223
|
+
switch (colorType) {
|
|
224
|
+
case 0: // grayscale
|
|
225
|
+
pixels[dstIdx] = recon[srcIdx];
|
|
226
|
+
pixels[dstIdx + 1] = recon[srcIdx];
|
|
227
|
+
pixels[dstIdx + 2] = recon[srcIdx];
|
|
228
|
+
pixels[dstIdx + 3] = 255;
|
|
229
|
+
break;
|
|
230
|
+
case 2: // RGB
|
|
231
|
+
pixels[dstIdx] = recon[srcIdx];
|
|
232
|
+
pixels[dstIdx + 1] = recon[srcIdx + 1];
|
|
233
|
+
pixels[dstIdx + 2] = recon[srcIdx + 2];
|
|
234
|
+
pixels[dstIdx + 3] = 255;
|
|
235
|
+
break;
|
|
236
|
+
case 4: // grayscale+alpha
|
|
237
|
+
pixels[dstIdx] = recon[srcIdx];
|
|
238
|
+
pixels[dstIdx + 1] = recon[srcIdx];
|
|
239
|
+
pixels[dstIdx + 2] = recon[srcIdx];
|
|
240
|
+
pixels[dstIdx + 3] = recon[srcIdx + 1];
|
|
241
|
+
break;
|
|
242
|
+
case 6: // RGBA
|
|
243
|
+
pixels[dstIdx] = recon[srcIdx];
|
|
244
|
+
pixels[dstIdx + 1] = recon[srcIdx + 1];
|
|
245
|
+
pixels[dstIdx + 2] = recon[srcIdx + 2];
|
|
246
|
+
pixels[dstIdx + 3] = recon[srcIdx + 3];
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { width, height, pixels };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Paeth predictor for PNG filter type 4.
|
|
257
|
+
*/
|
|
258
|
+
function paethPredictor(a, b, c) {
|
|
259
|
+
const p = a + b - c;
|
|
260
|
+
const pa = Math.abs(p - a);
|
|
261
|
+
const pb = Math.abs(p - b);
|
|
262
|
+
const pc = Math.abs(p - c);
|
|
263
|
+
if (pa <= pb && pa <= pc) return a;
|
|
264
|
+
if (pb <= pc) return b;
|
|
265
|
+
return c;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// Image → half-block ANSI art
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Sample RGBA pixels from a source image at a given (x, y) in [0..1] space,
|
|
274
|
+
* using nearest-neighbor sampling.
|
|
275
|
+
*
|
|
276
|
+
* @param {Uint8Array} pixels - RGBA pixel data
|
|
277
|
+
* @param {number} srcW - Source image width
|
|
278
|
+
* @param {number} srcH - Source image height
|
|
279
|
+
* @param {number} u - Normalized x in [0..1]
|
|
280
|
+
* @param {number} v - Normalized y in [0..1]
|
|
281
|
+
* @returns {{ r: number, g: number, b: number, a: number }}
|
|
282
|
+
*/
|
|
283
|
+
function samplePixel(pixels, srcW, srcH, u, v) {
|
|
284
|
+
const x = Math.min(Math.floor(u * srcW), srcW - 1);
|
|
285
|
+
const y = Math.min(Math.floor(v * srcH), srcH - 1);
|
|
286
|
+
const idx = (y * srcW + x) * 4;
|
|
287
|
+
return {
|
|
288
|
+
r: pixels[idx],
|
|
289
|
+
g: pixels[idx + 1],
|
|
290
|
+
b: pixels[idx + 2],
|
|
291
|
+
a: pixels[idx + 3],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Compute perceptual brightness of an RGB pixel (ITU-R BT.601 luma).
|
|
297
|
+
*/
|
|
298
|
+
function brightness({ r, g, b, a }) {
|
|
299
|
+
// Transparent pixels count as background
|
|
300
|
+
if (a < 32) return 0;
|
|
301
|
+
return 0.299 * r + 0.587 * g + 0.114 * b;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Convert a parsed PNG to half-block ANSI art lines.
|
|
306
|
+
*
|
|
307
|
+
* The source image is scaled to outputWidth × (outputWidth * aspect) using
|
|
308
|
+
* nearest-neighbor sampling. Each pair of vertical pixel rows maps to one
|
|
309
|
+
* terminal row via ▀ (upper pixel = fg, lower pixel = bg).
|
|
310
|
+
*
|
|
311
|
+
* Pixels brighter than `threshold` are re-colorized to brand blue;
|
|
312
|
+
* darker pixels become the background color.
|
|
313
|
+
*
|
|
314
|
+
* @param {{ width: number, height: number, pixels: Uint8Array }} png
|
|
315
|
+
* @returns {string[]} Array of ANSI-decorated terminal lines
|
|
316
|
+
*/
|
|
317
|
+
function pngToHalfBlockLines(png) {
|
|
318
|
+
const { width: srcW, height: srcH, pixels } = png;
|
|
319
|
+
|
|
320
|
+
// Terminal cells are roughly 2:1 height:width in most fonts, so
|
|
321
|
+
// each half-block row represents 2 pixel rows. The output height
|
|
322
|
+
// (in half-block rows) maintains the aspect ratio.
|
|
323
|
+
const outputHeight = Math.round((outputWidth * srcH) / srcW);
|
|
324
|
+
// Round up to even so every row has a lower partner
|
|
325
|
+
const pixelRows = outputHeight % 2 === 0 ? outputHeight : outputHeight + 1;
|
|
326
|
+
|
|
327
|
+
const lines = [];
|
|
328
|
+
|
|
329
|
+
for (let row = 0; row < pixelRows; row += 2) {
|
|
330
|
+
let line = "";
|
|
331
|
+
for (let col = 0; col < outputWidth; col++) {
|
|
332
|
+
const u = (col + 0.5) / outputWidth;
|
|
333
|
+
const vUpper = (row + 0.5) / pixelRows;
|
|
334
|
+
const vLower = (row + 1.5) / pixelRows;
|
|
335
|
+
|
|
336
|
+
const upper = samplePixel(pixels, srcW, srcH, u, vUpper);
|
|
337
|
+
const lower = samplePixel(pixels, srcW, srcH, u, vLower);
|
|
338
|
+
|
|
339
|
+
const upperBright = brightness(upper) >= threshold;
|
|
340
|
+
const lowerBright = brightness(lower) >= threshold;
|
|
341
|
+
|
|
342
|
+
const [fgR, fgG, fgB] = upperBright
|
|
343
|
+
? [brandR, brandG, brandB]
|
|
344
|
+
: [bgR, bgG, bgB];
|
|
345
|
+
const [bgRc, bgGc, bgBc] = lowerBright
|
|
346
|
+
? [brandR, brandG, brandB]
|
|
347
|
+
: [bgR, bgG, bgB];
|
|
348
|
+
|
|
349
|
+
line += `\x1b[38;2;${fgR};${fgG};${fgB}m\x1b[48;2;${bgRc};${bgGc};${bgBc}m▀\x1b[0m`;
|
|
350
|
+
}
|
|
351
|
+
lines.push(line);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return lines;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Main
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
let pngData;
|
|
362
|
+
try {
|
|
363
|
+
pngData = readFileSync(inputPath);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
process.stderr.write(`Error reading file "${inputPath}": ${err.message}\n`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let png;
|
|
370
|
+
try {
|
|
371
|
+
png = parsePng(pngData);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
process.stderr.write(`PNG parse error: ${err.message}\n`);
|
|
374
|
+
process.exit(1);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
process.stderr.write(
|
|
378
|
+
`Input: ${png.width}×${png.height} px → Output: ${outputWidth} cols × ~${Math.round((outputWidth * png.height) / png.width)} rows\n`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const artLines = pngToHalfBlockLines(png);
|
|
382
|
+
|
|
383
|
+
// Output as a JavaScript string literal fragment ready for LOGO_ART_TRUECOLOR
|
|
384
|
+
process.stdout.write("// paste into LOGO_ART_TRUECOLOR in bin/tui.mjs:\n");
|
|
385
|
+
process.stdout.write("const artLines = [\n");
|
|
386
|
+
for (const line of artLines) {
|
|
387
|
+
// Escape the string for embedding in JS source
|
|
388
|
+
const escaped = JSON.stringify(line);
|
|
389
|
+
process.stdout.write(` ${escaped},\n`);
|
|
390
|
+
}
|
|
391
|
+
process.stdout.write("];\n");
|