forgedock 1.0.13 → 1.0.15

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,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");