Files
DevEnv nis2-agileandClaude Opus 4.8 36513fa722 [FEAT] PWA installabile Android/iOS conforme AGID (v1.15.0)
- manifest.webmanifest + service worker (offline shell, /api network-only, asset stale-while-revalidate)
- set icone (192/512 + maskable + apple-touch + favicon), generate zero-deps (pure Node+zlib)
- tag PWA + viewport viewport-fit=cover (zoom mantenuto, WCAG 1.4.4) in 77 pagine HTML
- AGID/WCAG 2.1 AA: touch target >=44px, orientamento/zoom liberi, safe-area standalone
- generatori riproducibili: scripts/gen-pwa-icons.mjs + scripts/inject-pwa-head.mjs
- bump version 1.14.1 -> 1.15.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:09:54 +02:00

488 lines
18 KiB
JavaScript

#!/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 `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="NIS2 Agile">
<title>NIS2 Agile</title>
<!-- Italy-blue rounded tile -->
<rect x="${margin}" y="${margin}" width="${512 - 2 * margin}" height="${512 - 2 * margin}" rx="${r}" ry="${r}" fill="#0066CC"/>
<!-- White shield -->
<polygon points="${shield}" fill="#FFFFFF"/>
<!-- Negative blue check -->
<polygon points="${check}" fill="#0066CC"/>
</svg>
`;
}
// ---------------------------------------------------------------------------
// 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();