Flat
Join

Code

GPL v3 or later · 54 files · 1.2 MB

Read me What changed Versions and downloads

top / scripts

scripts/make-icons.js · 6.7 KB · raw

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 'use strict'; // Draws the Flat icon at every size the desktop file, .DirIcon and // the AppStream metadata need, with no image library on the machine. // // The artwork is square, and everything that carries meaning sits inside a // circle of radius 0.40 around the centre — a launcher that crops icons to a // circle clips only background. // // node scripts/make-icons.js const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const SIZES = [16, 22, 24, 32, 48, 64, 128, 256, 512]; const OUT_DIR = path.join(__dirname, '..', 'build', 'icons'); // Brand yellow ground, brand navy artwork. This way round because it is the // pair that survives a tray: a dark mark on a bright badge stays readable at // 22px against any panel colour, where a dark badge disappears into a dark // panel. const GROUND = [0xfb, 0xc7, 0x11]; const MARK = [0x11, 0x18, 0x27]; // -------------------------------------------------------------------------- // Geometry, all in a 0..1 unit square // -------------------------------------------------------------------------- function insideRoundedRect(px, py, x, y, w, h, r) { const cx = Math.max(x + r, Math.min(px, x + w - r)); const cy = Math.max(y + r, Math.min(py, y + h - r)); const dx = px - cx; const dy = py - cy; return dx * dx + dy * dy <= r * r; } // Distance from a point to a line segment: strokes are drawn as capsules. function distanceToSegment(px, py, ax, ay, bx, by) { const vx = bx - ax; const vy = by - ay; const wx = px - ax; const wy = py - ay; const len2 = vx * vx + vy * vy; const t = len2 ? Math.max(0, Math.min(1, (wx * vx + wy * vy) / len2)) : 0; const dx = px - (ax + t * vx); const dy = py - (ay + t * vy); return Math.sqrt(dx * dx + dy * dy); } const BOX = { x: 0.255, y: 0.545, w: 0.49, h: 0.245, r: 0.055 }; const STROKE = 0.052; const ARROW = { x: 0.5, top: 0.205, tip: 0.515, wing: 0.108 }; // Returns the colour at a point, or null for the background. function artworkAt(px, py) { // The tray, drawn as an outline. Two colours only: the inside of it is the // yellow ground, not a wash of the mark colour over it. A tinted fill was a // third tone in an icon that is meant to be brand yellow and brand navy. const outer = insideRoundedRect(px, py, BOX.x, BOX.y, BOX.w, BOX.h, BOX.r); if (outer) { const inner = insideRoundedRect( px, py, BOX.x + STROKE, BOX.y + STROKE, BOX.w - STROKE * 2, BOX.h - STROKE * 2, Math.max(0.001, BOX.r - STROKE), ); if (!inner) return { colour: MARK, alpha: 1 }; return null; } // The arrow going into it. const half = STROKE / 2; const shaft = distanceToSegment(px, py, ARROW.x, ARROW.top, ARROW.x, ARROW.tip); const left = distanceToSegment(px, py, ARROW.x, ARROW.tip, ARROW.x - ARROW.wing, ARROW.tip - ARROW.wing); const right = distanceToSegment(px, py, ARROW.x, ARROW.tip, ARROW.x + ARROW.wing, ARROW.tip - ARROW.wing); if (Math.min(shaft, left, right) <= half) return { colour: MARK, alpha: 1 }; return null; } // -------------------------------------------------------------------------- // Rasteriser // -------------------------------------------------------------------------- // 4x4 samples per pixel. Nothing here is animated or resized at runtime, so // the cost is paid once at build time and the edges come out clean. const SAMPLES = 4; function render(size) { const rgba = Buffer.alloc(size * size * 4); const step = 1 / (size * SAMPLES); const bgRadius = 0.235; // rounded-square corner, as a fraction of the side for (let y = 0; y < size; y += 1) { for (let x = 0; x < size; x += 1) { let r = 0; let g = 0; let b = 0; let a = 0; for (let sy = 0; sy < SAMPLES; sy += 1) { for (let sx = 0; sx < SAMPLES; sx += 1) { const px = (x * SAMPLES + sx + 0.5) * step; const py = (y * SAMPLES + sy + 0.5) * step; if (!insideRoundedRect(px, py, 0.02, 0.02, 0.96, 0.96, bgRadius)) continue; const art = artworkAt(px, py); let cr = GROUND[0]; let cg = GROUND[1]; let cb = GROUND[2]; if (art) { cr = GROUND[0] + (art.colour[0] - GROUND[0]) * art.alpha; cg = GROUND[1] + (art.colour[1] - GROUND[1]) * art.alpha; cb = GROUND[2] + (art.colour[2] - GROUND[2]) * art.alpha; } r += cr; g += cg; b += cb; a += 255; } } const total = SAMPLES * SAMPLES; const coverage = a / total; const i = (y * size + x) * 4; if (coverage > 0) { // Straight (non-premultiplied) alpha: divide the colour by the // number of samples that actually landed on the shape, not by all // of them, or every edge pixel comes out dark. const hits = a / 255; rgba[i] = Math.round(r / hits); rgba[i + 1] = Math.round(g / hits); rgba[i + 2] = Math.round(b / hits); rgba[i + 3] = Math.round(coverage); } } } return rgba; } // -------------------------------------------------------------------------- // PNG // -------------------------------------------------------------------------- function crc32(buf) { let crc = ~0; for (let i = 0; i < buf.length; i += 1) { crc ^= buf[i]; for (let bit = 0; bit < 8; bit += 1) { crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } } return ~crc >>> 0; } function chunk(type, data) { const length = Buffer.alloc(4); length.writeUInt32BE(data.length, 0); const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0); return Buffer.concat([length, body, crc]); } function writePng(file, size, rgba) { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // truecolour with alpha ihdr[10] = 0; // deflate ihdr[11] = 0; // adaptive filtering ihdr[12] = 0; // no interlace // One filter byte per scanline, filter type 0. The images are small and // flat, so a cleverer filter would buy nothing worth the code. const raw = Buffer.alloc((size * 4 + 1) * size); for (let y = 0; y < size; y += 1) { raw[y * (size * 4 + 1)] = 0; rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); } fs.writeFileSync(file, Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0)), ])); } // -------------------------------------------------------------------------- fs.mkdirSync(OUT_DIR, { recursive: true }); for (const size of SIZES) { const file = path.join(OUT_DIR, `${size}x${size}.png`); writePng(file, size, render(size)); process.stdout.write(`${path.relative(process.cwd(), file)}\n`); }