#!/usr/bin/env node /** * gen-pwa-icons.mjs — NIS2 Agile PWA icon generator (dependency-free) * * Generates the full PWA icon set using ONLY Node v20 built-ins: * - node:zlib (deflate for PNG IDAT) * - node:fs (write files) * - node:path (resolve paths) * * Design: Italy-blue (#0066CC) square tile, centered white shield * (compliance/security silhouette) with a bold "negative" check mark * cut out of the shield (rendered in #0066CC) so it stays readable * down to 16px. * * Rendering: each icon is drawn at 4x (supersampling) into an RGBA * buffer using hand-written primitives (rounded rect, scanline polygon * fill), then box-downsampled to final size for smooth anti-aliased * edges. PNG is encoded manually (8-bit, color type 6 RGBA). * * Run: cd /projects/nis2-agile && node scripts/gen-pwa-icons.mjs */ import zlib from 'node:zlib'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PROJECT_ROOT = path.resolve(__dirname, '..'); const ICONS_DIR = path.join(PROJECT_ROOT, 'public', 'assets', 'icons'); // --------------------------------------------------------------------------- // Palette // --------------------------------------------------------------------------- const BLUE = [0, 102, 204]; // #0066CC — Italy blue const WHITE = [255, 255, 255]; // --------------------------------------------------------------------------- // RGBA canvas helpers // --------------------------------------------------------------------------- function makeCanvas(w, h, fill) { // fill = [r,g,b,a] or null => transparent (0,0,0,0) const buf = new Uint8Array(w * h * 4); if (fill) { for (let i = 0; i < w * h; i++) { const o = i * 4; buf[o] = fill[0]; buf[o + 1] = fill[1]; buf[o + 2] = fill[2]; buf[o + 3] = fill[3]; } } return { w, h, buf }; } function setPx(cv, x, y, rgba) { if (x < 0 || y < 0 || x >= cv.w || y >= cv.h) return; const o = (y * cv.w + x) * 4; cv.buf[o] = rgba[0]; cv.buf[o + 1] = rgba[1]; cv.buf[o + 2] = rgba[2]; cv.buf[o + 3] = rgba[3]; } // Fill the whole canvas with an OPAQUE color. function fillBackground(cv, rgb) { for (let i = 0; i < cv.w * cv.h; i++) { const o = i * 4; cv.buf[o] = rgb[0]; cv.buf[o + 1] = rgb[1]; cv.buf[o + 2] = rgb[2]; cv.buf[o + 3] = 255; } } // Filled rounded rectangle (opaque). r = corner radius in px (canvas space). function fillRoundedRect(cv, x0, y0, x1, y1, r, rgb, alpha = 255) { const minX = Math.max(0, Math.floor(x0)); const minY = Math.max(0, Math.floor(y0)); const maxX = Math.min(cv.w - 1, Math.ceil(x1)); const maxY = Math.min(cv.h - 1, Math.ceil(y1)); const rr = r * r; for (let y = minY; y <= maxY; y++) { for (let x = minX; x <= maxX; x++) { const px = x + 0.5; const py = y + 0.5; let inside = true; // corner clipping if (px < x0 + r && py < y0 + r) { const dx = px - (x0 + r), dy = py - (y0 + r); inside = dx * dx + dy * dy <= rr; } else if (px > x1 - r && py < y0 + r) { const dx = px - (x1 - r), dy = py - (y0 + r); inside = dx * dx + dy * dy <= rr; } else if (px < x0 + r && py > y1 - r) { const dx = px - (x0 + r), dy = py - (y1 - r); inside = dx * dx + dy * dy <= rr; } else if (px > x1 - r && py > y1 - r) { const dx = px - (x1 - r), dy = py - (y1 - r); inside = dx * dx + dy * dy <= rr; } if (inside) setPx(cv, x, y, [rgb[0], rgb[1], rgb[2], alpha]); } } } // Scanline polygon fill. pts = [[x,y],...] in canvas space. Even-odd rule. function fillPolygon(cv, pts, rgb, alpha = 255) { let minY = Infinity, maxY = -Infinity; for (const p of pts) { if (p[1] < minY) minY = p[1]; if (p[1] > maxY) maxY = p[1]; } minY = Math.max(0, Math.floor(minY)); maxY = Math.min(cv.h - 1, Math.ceil(maxY)); const n = pts.length; for (let y = minY; y <= maxY; y++) { const yc = y + 0.5; const xs = []; for (let i = 0; i < n; i++) { const a = pts[i]; const b = pts[(i + 1) % n]; const ay = a[1], by = b[1]; if ((ay <= yc && by > yc) || (by <= yc && ay > yc)) { const t = (yc - ay) / (by - ay); xs.push(a[0] + t * (b[0] - a[0])); } } xs.sort((p, q) => p - q); for (let i = 0; i + 1 < xs.length; i += 2) { const xStart = Math.max(0, Math.ceil(xs[i] - 0.5)); const xEnd = Math.min(cv.w - 1, Math.floor(xs[i + 1] - 0.5)); for (let x = xStart; x <= xEnd; x++) setPx(cv, x, y, [rgb[0], rgb[1], rgb[2], alpha]); } } } // --------------------------------------------------------------------------- // Shield + check geometry // --------------------------------------------------------------------------- // All geometry is expressed in a normalized 0..1 box, then scaled into the // target region (cx,cy = center, half = half-size of the content square). // Build shield outline polygon (heraldic shield: flat top, rounded shoulders // approximated by points, tapering to a point at the bottom). function shieldPolygon(cx, cy, half) { // Normalized shield within a square: x in [-0.5,0.5], y in [-0.6,0.6] // top edge slightly inset, sides curve down, bottom comes to a point. const W = half * 0.92; // half width const Htop = -half * 1.02; const pts = []; // Top edge pts.push([cx - W, cy + Htop]); pts.push([cx + W, cy + Htop]); // Right side going down with a gentle inward curve, then to the point. // Approximate the right & bottom with sampled points along a parametric path. const right = [ [cx + W, cy + Htop], [cx + W * 0.99, cy - half * 0.35], [cx + W * 0.92, cy + half * 0.05], [cx + W * 0.74, cy + half * 0.45], [cx + W * 0.42, cy + half * 0.85], [cx + 0, cy + half * 1.12], // bottom point ]; const left = [ [cx - W * 0.42, cy + half * 0.85], [cx - W * 0.74, cy + half * 0.45], [cx - W * 0.92, cy + half * 0.05], [cx - W * 0.99, cy - half * 0.35], [cx - W, cy + Htop], ]; // Compose: top-left -> top-right -> right path -> bottom -> left path const poly = []; poly.push([cx - W, cy + Htop]); poly.push([cx + W, cy + Htop]); for (let i = 1; i < right.length; i++) poly.push(right[i]); for (let i = 0; i < left.length; i++) poly.push(left[i]); return poly; } // Build a bold check-mark polygon (thick stroke as a filled hexagon-ish shape). // Centered roughly in the shield. `s` scales overall size, `t` is stroke thickness. function checkPolygon(cx, cy, s, t) { // Check has a short arm (down-left) and a long arm (up-right). // Define center vertex (bottom of the V), short tip, long tip. const vx = cx - s * 0.10, vy = cy + s * 0.34; // inner corner (bottom) const s1x = cx - s * 0.46, s1y = cy + s * 0.02; // short arm tip const l1x = cx + s * 0.50, l1y = cy - s * 0.42; // long arm tip // Build a thick stroke by offsetting perpendicular to each segment. // Segment A: short tip -> inner corner // Segment B: inner corner -> long tip const half = t / 2; function unit(ax, ay, bx, by) { const dx = bx - ax, dy = by - ay; const L = Math.hypot(dx, dy) || 1; return [dx / L, dy / L]; } function perp(u) { return [-u[1], u[0]]; } const uA = unit(s1x, s1y, vx, vy); const uB = unit(vx, vy, l1x, l1y); const pA = perp(uA); const pB = perp(uB); // Outer & inner offsets for each arm; assemble as a single polygon outline. // Order: short tip outer -> corner outer -> long tip outer -> long tip inner // -> corner inner -> short tip inner const poly = [ [s1x + pA[0] * half, s1y + pA[1] * half], // short tip outer [vx + pA[0] * half, vy + pA[1] * half], // corner outer (A side) [vx + pB[0] * half, vy + pB[1] * half], // corner outer (B side) [l1x + pB[0] * half, l1y + pB[1] * half], // long tip outer [l1x - pB[0] * half, l1y - pB[1] * half], // long tip inner [vx - pB[0] * half, vy - pB[1] * half], // corner inner (B side) [vx - pA[0] * half, vy - pA[1] * half], // corner inner (A side) [s1x - pA[0] * half, s1y - pA[1] * half], // short tip inner ]; return poly; } // --------------------------------------------------------------------------- // Draw the NIS2 emblem (shield + check) into a canvas region. // cx,cy: center; half: half content size; simplified: bolder geometry for tiny sizes // --------------------------------------------------------------------------- function drawEmblem(cv, cx, cy, half, simplified = false) { // White shield const shield = shieldPolygon(cx, cy, half); fillPolygon(cv, shield, WHITE, 255); // Negative blue check inside the shield. const checkScale = half * (simplified ? 1.18 : 1.05); const stroke = half * (simplified ? 0.32 : 0.24); const check = checkPolygon(cx, cy - half * 0.02, checkScale, stroke); fillPolygon(cv, check, BLUE, 255); } // --------------------------------------------------------------------------- // Box downsample (supersampled -> final). factor must divide dimensions. // --------------------------------------------------------------------------- function downsample(cv, factor) { const W = cv.w / factor; const H = cv.h / factor; const out = makeCanvas(W, H, null); const f2 = factor * factor; for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) { let r = 0, g = 0, b = 0, a = 0; for (let sy = 0; sy < factor; sy++) { for (let sx = 0; sx < factor; sx++) { const o = ((y * factor + sy) * cv.w + (x * factor + sx)) * 4; const sa = cv.buf[o + 3]; // premultiply for correct edge blending r += cv.buf[o] * sa; g += cv.buf[o + 1] * sa; b += cv.buf[o + 2] * sa; a += sa; } } const oo = (y * W + x) * 4; if (a === 0) { out.buf[oo] = 0; out.buf[oo + 1] = 0; out.buf[oo + 2] = 0; out.buf[oo + 3] = 0; } else { out.buf[oo] = Math.round(r / a); out.buf[oo + 1] = Math.round(g / a); out.buf[oo + 2] = Math.round(b / a); out.buf[oo + 3] = Math.round(a / f2); } } } return out; } // --------------------------------------------------------------------------- // PNG encoding (manual, 8-bit RGBA, color type 6) // --------------------------------------------------------------------------- const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // CRC32 (standard polynomial 0xEDB88320) const CRC_TABLE = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); } t[n] = c >>> 0; } return t; })(); function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) { c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); } return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); const typeBuf = Buffer.from(type, 'ascii'); const body = Buffer.concat([typeBuf, data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0); return Buffer.concat([len, body, crc]); } function encodePNG(cv) { const { w, h, buf } = cv; // IHDR const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // color type RGBA ihdr[10] = 0; // compression ihdr[11] = 0; // filter ihdr[12] = 0; // interlace // Filtered raw data: each scanline prefixed with filter byte 0 const stride = w * 4; const raw = Buffer.alloc((stride + 1) * h); for (let y = 0; y < h; y++) { raw[y * (stride + 1)] = 0; // filter: none buf.subarray(y * stride, y * stride + stride) .forEach ? null : null; // (Uint8Array subarray copy below) const srcStart = y * stride; raw.set(buf.subarray(srcStart, srcStart + stride), y * (stride + 1) + 1); } const idat = zlib.deflateSync(raw, { level: 9 }); return Buffer.concat([ PNG_SIG, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0)), ]); } // --------------------------------------------------------------------------- // Icon builders // --------------------------------------------------------------------------- const SS = 4; // supersampling factor // Standard (rounded tile) icon: transparent corners, rounded blue tile, // margin ~8%, centered emblem. function buildStandard(size, simplified = false) { const W = size * SS; const cv = makeCanvas(W, W, null); // transparent background const margin = W * 0.08; const radius = W * 0.18; fillRoundedRect(cv, margin, margin, W - margin, W - margin, radius, BLUE, 255); const cx = W / 2, cy = W / 2; // emblem content sized to ~ inner tile const half = (W - 2 * margin) * 0.30; drawEmblem(cv, cx, cy, half, simplified); return downsample(cv, SS); } // Maskable icon: FULL-BLEED blue, square corners, emblem inside the central // 80% safe-zone. function buildMaskable(size) { const W = size * SS; const cv = makeCanvas(W, W, null); fillBackground(cv, BLUE); // full bleed, opaque const cx = W / 2, cy = W / 2; // safe zone is central 80% => content sized so it stays inside 0.8*W box const half = W * 0.5 * 0.80 * 0.62; // ~31% of half-canvas drawEmblem(cv, cx, cy, half, false); return downsample(cv, SS); } // Apple touch icon: fully OPAQUE blue background, square corners, emblem. function buildAppleTouch(size) { const W = size * SS; const cv = makeCanvas(W, W, null); fillBackground(cv, BLUE); // opaque, no transparency const cx = W / 2, cy = W / 2; const half = W * 0.30; drawEmblem(cv, cx, cy, half, false); return downsample(cv, SS); } // Favicon: rounded blue tile + bolder simplified emblem for legibility. function buildFavicon(size) { const W = size * SS; const cv = makeCanvas(W, W, null); const margin = W * 0.04; const radius = W * 0.18; fillRoundedRect(cv, margin, margin, W - margin, W - margin, radius, BLUE, 255); const cx = W / 2, cy = W / 2; const half = W * 0.34; drawEmblem(cv, cx, cy, half, true); // simplified/bolder return downsample(cv, SS); } // --------------------------------------------------------------------------- // SVG master (same design, vector) // --------------------------------------------------------------------------- function buildSVG() { // Express the same geometry in a 512 viewBox. const cx = 256, cy = 256, half = 512 * 0.30; // 153.6 const shield = shieldPolygon(cx, cy, half) .map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); const check = checkPolygon(cx, cy - half * 0.02, half * 1.05, half * 0.24) .map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); const margin = 512 * 0.08; const r = 512 * 0.18; return ` NIS2 Agile `; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { fs.mkdirSync(ICONS_DIR, { recursive: true }); const outputs = []; function write(name, cv) { const png = encodePNG(cv); const fp = path.join(ICONS_DIR, name); fs.writeFileSync(fp, png); outputs.push({ name, bytes: png.length, w: cv.w, h: cv.h }); } write('icon-192.png', buildStandard(192)); write('icon-512.png', buildStandard(512)); write('icon-192-maskable.png', buildMaskable(192)); write('icon-512-maskable.png', buildMaskable(512)); write('apple-touch-icon.png', buildAppleTouch(180)); write('favicon-32.png', buildFavicon(32)); write('favicon-16.png', buildFavicon(16)); // SVG master const svg = buildSVG(); fs.writeFileSync(path.join(ICONS_DIR, 'icon.svg'), svg); // ---- Validation: re-read each PNG, verify signature + IHDR dims ---- console.log('\n=== NIS2 PWA Icon Generation ==='); console.log('Output dir:', ICONS_DIR); console.log('\nFile | Bytes | Declared | Read(IHDR) | SIG'); console.log('---------------------------|--------|----------|------------|----'); let allOk = true; for (const o of outputs) { const data = fs.readFileSync(path.join(ICONS_DIR, o.name)); const sigOk = data.length >= 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a; // IHDR starts at byte 16 (8 sig + 4 len + 4 'IHDR'); width@16, height@20 const rw = data.readUInt32BE(16); const rh = data.readUInt32BE(20); const nonEmpty = data.length > 0; const ok = sigOk && nonEmpty && rw === o.w && rh === o.h; if (!ok) allOk = false; console.log( `${o.name.padEnd(26)} | ${String(o.bytes).padStart(6)} | ${(o.w + 'x' + o.h).padStart(8)} | ${(rw + 'x' + rh).padStart(10)} | ${ok ? 'OK' : 'KO'}` ); } // SVG report const svgStat = fs.statSync(path.join(ICONS_DIR, 'icon.svg')); console.log(`${'icon.svg'.padEnd(26)} | ${String(svgStat.size).padStart(6)} | ${'vector'.padStart(8)} | ${'-'.padStart(10)} | ${svgStat.size > 0 ? 'OK' : 'KO'}`); console.log('\nValidation:', allOk ? 'ALL PNG OK' : 'FAILURES PRESENT'); if (!allOk) process.exitCode = 1; } main();