// One-off dev-time script — generates the app's PWA icon PNGs with zero // dependencies (no ImageMagick/PIL/canvas available in this environment). // Run once (`node scripts/generate-icons.js`), commit the output under // public/icons/ like any other static asset. Not required at runtime. const fs = require('node:fs'); const path = require('node:path'); const zlib = require('node:zlib'); const OUT_DIR = path.join(__dirname, '..', 'public', 'icons'); // App's own --school blue (public/css/shared.css) const BG = [74, 144, 217]; const WHITE = [255, 255, 255]; function distToSegment(px, py, x1, y1, x2, y2) { const dx = x2 - x1; const dy = y2 - y1; const lengthSq = dx * dx + dy * dy; let t = lengthSq === 0 ? 0 : ((px - x1) * dx + (py - y1) * dy) / lengthSq; t = Math.max(0, Math.min(1, t)); const cx = x1 + t * dx; const cy = y1 + t * dy; return Math.hypot(px - cx, py - cy); } // Rounded-rect coverage (0..1) for anti-aliased corners; `radius` in px. function roundedRectCoverage(x, y, w, h, radius) { const inCoreX = x >= radius && x <= w - radius; const inCoreY = y >= radius && y <= h - radius; if (inCoreX || inCoreY) return 1; const cx = x < radius ? radius : w - radius; const cy = y < radius ? radius : h - radius; const dist = Math.hypot(x - cx, y - cy); if (dist <= radius - 0.5) return 1; if (dist >= radius + 0.5) return 0; return radius + 0.5 - dist; // 1px anti-aliased band } function mix(a, b, t) { return a + (b - a) * t; } // Checkmark path, proportional to icon size. function checkmarkCoverage(x, y, size, strokeHalfWidth) { const p1 = [size * 0.27, size * 0.53]; const p2 = [size * 0.43, size * 0.68]; const p3 = [size * 0.74, size * 0.32]; const d = Math.min( distToSegment(x, y, p1[0], p1[1], p2[0], p2[1]), distToSegment(x, y, p2[0], p2[1], p3[0], p3[1]) ); if (d <= strokeHalfWidth - 0.5) return 1; if (d >= strokeHalfWidth + 0.5) return 0; return strokeHalfWidth + 0.5 - d; } function renderIcon({ size, cornerRadiusRatio, glyphStrokeRatio }) { const buf = Buffer.alloc(size * size * 4); const radius = size * cornerRadiusRatio; const strokeHalfWidth = size * glyphStrokeRatio; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const bgCoverage = cornerRadiusRatio > 0 ? roundedRectCoverage(x + 0.5, y + 0.5, size, size, radius) : 1; const glyphCoverage = checkmarkCoverage(x + 0.5, y + 0.5, size, strokeHalfWidth); // Composite: background (with its own edge coverage against // transparent) under the white glyph stroke. let r = mix(0, BG[0], bgCoverage); let g = mix(0, BG[1], bgCoverage); let b = mix(0, BG[2], bgCoverage); let a = mix(0, 255, bgCoverage); r = mix(r, WHITE[0], glyphCoverage); g = mix(g, WHITE[1], glyphCoverage); b = mix(b, WHITE[2], glyphCoverage); a = mix(a, 255, glyphCoverage); const idx = (y * size + x) * 4; buf[idx] = Math.round(r); buf[idx + 1] = Math.round(g); buf[idx + 2] = Math.round(b); buf[idx + 3] = Math.round(a); } } return buf; } function crc32(buf) { return zlib.crc32(buf); } function chunk(type, data) { const typeBuf = Buffer.from(type, 'ascii'); const lenBuf = Buffer.alloc(4); lenBuf.writeUInt32BE(data.length, 0); const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); return Buffer.concat([lenBuf, typeBuf, data, crcBuf]); } function encodePng(rgbaBuf, width, height) { const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const ihdrData = Buffer.alloc(13); ihdrData.writeUInt32BE(width, 0); ihdrData.writeUInt32BE(height, 4); ihdrData[8] = 8; // bit depth ihdrData[9] = 6; // color type: truecolor + alpha ihdrData[10] = 0; // compression ihdrData[11] = 0; // filter ihdrData[12] = 0; // interlace const ihdr = chunk('IHDR', ihdrData); const stride = width * 4; const raw = Buffer.alloc((stride + 1) * height); for (let y = 0; y < height; y++) { raw[y * (stride + 1)] = 0; // filter type: None rgbaBuf.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride); } const idat = chunk('IDAT', zlib.deflateSync(raw)); const iend = chunk('IEND', Buffer.alloc(0)); return Buffer.concat([signature, ihdr, idat, iend]); } function writeIcon(filename, { size, cornerRadiusRatio, glyphStrokeRatio }) { const rgba = renderIcon({ size, cornerRadiusRatio, glyphStrokeRatio }); const png = encodePng(rgba, size, size); fs.mkdirSync(OUT_DIR, { recursive: true }); fs.writeFileSync(path.join(OUT_DIR, filename), png); console.log(`wrote ${filename} (${png.length} bytes)`); } writeIcon('icon-192.png', { size: 192, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 }); writeIcon('icon-512.png', { size: 512, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 }); writeIcon('apple-touch-icon-180.png', { size: 180, cornerRadiusRatio: 0.18, glyphStrokeRatio: 0.045 }); // Maskable: background fills edge-to-edge (no rounding — the OS applies its // own mask), glyph confined within the inner ~80% "safe zone" is handled // implicitly here since the checkmark path itself is well within that area. writeIcon('icon-maskable-512.png', { size: 512, cornerRadiusRatio: 0, glyphStrokeRatio: 0.038 });