#!/usr/bin/env node // tools/favicon.mjs — встраивает favicon в index.html как data:-URI PNG. // // Зачем data:-URI, а не файл favicon.ico: в Яндекс Игры уходит ТОЛЬКО index.html // (tools/pack.mjs, треб. 1.22) — внешняя иконка в архив не попала бы. data:-URI // работает и с file://, и с dev-сервером, и внутри iframe платформы. // // Источник — logo.png (512×512 RGB). Скрипт сам: // 1) декодирует PNG (zlib + снятие фильтров, 8-бит, цветовые типы 0/2/4/6); // 2) area-average ресемплит в 64×64 (иконка вкладки) и 180×180 (apple-touch-icon); // 3) кодирует обратно в PNG и кладёт base64 в /rel="apple-touch-icon". // // Запуск: node tools/favicon.mjs (из каталога репо или откуда угодно — пути от репо) // - идемпотентен: если маркеры FAVICON:BEGIN/END есть — заменяет всё между ними; // если нет — вставляет блок сразу ПОСЛЕ строки . // - после записи — самопроверка (маркеры, data:image/png, оба <link>) + node tools/check.mjs. // Чистый Node, без npm-зависимостей. Детерминирован: одинаковый logo.png → одинаковый index.html. // // Обновление иконки: заменить logo.png → `node tools/favicon.mjs`. import { readFileSync, writeFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { inflateSync, deflateSync } from 'node:zlib'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const INDEX = join(ROOT, 'index.html'); const SRC = join(ROOT, 'logo.png'); const ICON_SIZE = 64; // вкладка браузера (Chrome/Firefox масштабируют под 16/32, 64 покрывает HiDPI) const APPLE_SIZE = 180; // apple-touch-icon (домашний экран iOS) const BEGIN = '<!-- FAVICON:BEGIN (генерируется tools/favicon.mjs — не править руками) -->'; const END = '<!-- FAVICON:END -->'; function fail(msg) { console.error(`favicon: ОШИБКА: ${msg}`); process.exit(1); } // ---------- CRC32 (для чанков PNG) ---------- 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; } // ---------- Декодер PNG → {w,h,rgba} ---------- function decodePng(buf) { const SIG = [137, 80, 78, 71, 13, 10, 26, 10]; for (let i = 0; i < 8; i++) if (buf[i] !== SIG[i]) fail('logo.png: не PNG (нет сигнатуры)'); let off = 8, w = 0, h = 0, bitDepth = 0, colorType = 0, interlace = 0; const idat = []; for (;;) { if (off + 8 > buf.length) fail('logo.png: обрыв чанка'); const len = buf.readUInt32BE(off); const type = buf.toString('latin1', off + 4, off + 8); const data = buf.subarray(off + 8, off + 8 + len); if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); bitDepth = data[8]; colorType = data[9]; interlace = data[12]; } else if (type === 'IDAT') { idat.push(data); } else if (type === 'IEND') { break; } off += 12 + len; } if (interlace) fail('logo.png: interlaced PNG не поддерживается'); if (bitDepth !== 8) fail(`logo.png: bitDepth=${bitDepth}, поддерживается только 8`); if (![0, 2, 4, 6].includes(colorType)) fail(`logo.png: colorType=${colorType}, поддерживаются 0/2/4/6`); const channels = colorType === 0 ? 1 : colorType === 2 ? 3 : colorType === 4 ? 2 : 4; const bpp = channels; // bitDepth 8 → байт на пиксель const stride = w * bpp; const raw = inflateSync(Buffer.concat(idat)); if (raw.length < (stride + 1) * h) fail('logo.png: распакованных данных меньше, чем ожидается'); const rgba = new Uint8Array(w * h * 4); const line = Buffer.alloc(stride); const prev = Buffer.alloc(stride); let p = 0; for (let y = 0; y < h; y++) { const ft = raw[p++]; for (let x = 0; x < stride; x++) { const cur = raw[p + x]; const a = x >= bpp ? line[x - bpp] : 0; const b = prev[x]; const c = x >= bpp ? prev[x - bpp] : 0; let v; switch (ft) { case 0: v = cur; break; case 1: v = cur + a; break; case 2: v = cur + b; break; case 3: v = cur + ((a + b) >> 1); break; case 4: { const pp = a + b - c; const pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); v = cur + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); break; } default: fail(`logo.png: неизвестный фильтр строки ${ft}`); } line[x] = v & 0xFF; } p += stride; for (let x = 0; x < w; x++) { const s = x * bpp, d = (y * w + x) * 4; if (colorType === 0) { rgba[d] = rgba[d + 1] = rgba[d + 2] = line[s]; rgba[d + 3] = 255; } else if (colorType === 2) { rgba[d] = line[s]; rgba[d + 1] = line[s + 1]; rgba[d + 2] = line[s + 2]; rgba[d + 3] = 255; } else if (colorType === 4) { rgba[d] = rgba[d + 1] = rgba[d + 2] = line[s]; rgba[d + 3] = line[s + 1]; } else { rgba[d] = line[s]; rgba[d + 1] = line[s + 1]; rgba[d + 2] = line[s + 2]; rgba[d + 3] = line[s + 3]; } } line.copy(prev); } return { w, h, rgba }; } // ---------- Area-average ресемпл (RGB; альфу источника считаем непрозрачной — логотип RGB) ---------- function resizeRgb(src, sw, sh, dw, dh) { const out = new Uint8Array(dw * dh * 3); const xr = sw / dw, yr = sh / dh; for (let dy = 0; dy < dh; dy++) { const sy0 = dy * yr, sy1 = (dy + 1) * yr; const iy0 = Math.floor(sy0), iy1 = Math.ceil(sy1); for (let dx = 0; dx < dw; dx++) { const sx0 = dx * xr, sx1 = (dx + 1) * xr; const ix0 = Math.floor(sx0), ix1 = Math.ceil(sx1); let r = 0, g = 0, b = 0, sum = 0; for (let sy = iy0; sy < iy1; sy++) { const wy = Math.min(sy + 1, sy1) - Math.max(sy, sy0); if (wy <= 0) continue; for (let sx = ix0; sx < ix1; sx++) { const wx = Math.min(sx + 1, sx1) - Math.max(sx, sx0); if (wx <= 0) continue; const wt = wx * wy, o = (sy * sw + sx) * 4; r += src[o] * wt; g += src[o + 1] * wt; b += src[o + 2] * wt; sum += wt; } } const q = (dy * dw + dx) * 3; out[q] = Math.round(r / sum); out[q + 1] = Math.round(g / sum); out[q + 2] = Math.round(b / sum); } } return out; } // ---------- Энкодер PNG (colorType 2, RGB, 8 бит, адаптивный фильтр по строкам) ---------- // Фильтр строки выбирается эвристикой libpng (минимум суммы |signed| предиктора) — на // градиентах это заметно меньше, чем всегда filter 0. function encodePng(rgb, w, h) { const rowLen = w * 3; const bpp = 3; const raw = Buffer.alloc((rowLen + 1) * h); const cand = [Buffer.alloc(rowLen), Buffer.alloc(rowLen), Buffer.alloc(rowLen), Buffer.alloc(rowLen), Buffer.alloc(rowLen)]; const paeth = (a, b, c) => { const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; }; for (let y = 0; y < h; y++) { const row = rgb.subarray(y * rowLen, (y + 1) * rowLen); const up = y > 0 ? rgb.subarray((y - 1) * rowLen, y * rowLen) : null; for (let x = 0; x < rowLen; x++) { const a = x >= bpp ? row[x - bpp] : 0; const b = up ? up[x] : 0; const c = (up && x >= bpp) ? up[x - bpp] : 0; cand[0][x] = row[x]; cand[1][x] = (row[x] - a) & 0xFF; cand[2][x] = (row[x] - b) & 0xFF; cand[3][x] = (row[x] - ((a + b) >> 1)) & 0xFF; cand[4][x] = (row[x] - paeth(a, b, c)) & 0xFF; } let best = 0, bestScore = Infinity; for (let f = 0; f < 5; f++) { let score = 0; for (let x = 0; x < rowLen; x++) { const v = cand[f][x]; score += v < 128 ? v : 256 - v; } if (score < bestScore) { bestScore = score; best = f; } } raw[y * (rowLen + 1)] = best; cand[best].copy(raw, y * (rowLen + 1) + 1); } const chunk = (type, data) => { const out = Buffer.alloc(12 + data.length); out.writeUInt32BE(data.length, 0); out.write(type, 4, 'latin1'); data.copy(out, 8); out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length); return out; }; const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; return Buffer.concat([ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0)), ]); } // ---------- Сборка блока ---------- function dataUri(size) { const rgb = resizeRgb(src.rgba, src.w, src.h, size, size); const png = encodePng(rgb, size, size); return { uri: 'data:image/png;base64,' + png.toString('base64'), bytes: png.length }; } function buildBlock() { const icon = dataUri(ICON_SIZE); const apple = dataUri(APPLE_SIZE); const lines = [ BEGIN, `<link rel="icon" type="image/png" sizes="${ICON_SIZE}x${ICON_SIZE}" href="${icon.uri}">`, `<link rel="apple-touch-icon" sizes="${APPLE_SIZE}x${APPLE_SIZE}" href="${apple.uri}">`, END, ]; return { block: lines.join('\n'), iconBytes: icon.bytes, appleBytes: apple.bytes }; } // --- основная логика --- let src; try { src = decodePng(readFileSync(SRC)); } catch (e) { fail(`не удалось прочитать/разобрать ${SRC}: ${e.message}`); } if (src.w !== src.h) console.warn(`favicon: предупреждение — logo.png не квадратный (${src.w}×${src.h}), иконка будет обрезана краями`); let html; try { html = readFileSync(INDEX, 'utf8'); } catch (e) { fail(`не удалось прочитать ${INDEX}: ${e.message}`); } const { block, iconBytes, appleBytes } = buildBlock(); const b = html.indexOf(BEGIN); const e = html.indexOf(END); if (b !== -1 || e !== -1) { if (b === -1 || e === -1 || e < b) fail('маркеры FAVICON:BEGIN/END найдены непарно/не по порядку — index.html повреждён'); html = html.slice(0, b) + block + html.slice(e + END.length); console.log('favicon: маркеры уже есть — блок заменён (повторный запуск)'); } else { const m = /<title>[^<]*<\/title>\r?\n?/.exec(html); if (!m) fail('в index.html не найден <title>… — некуда вставить блок'); const pos = m.index + m[0].length; html = html.slice(0, pos) + block + '\n' + html.slice(pos); console.log(`favicon: блок вставлен после (строка ${html.slice(0, pos).split('\n').length})`); } writeFileSync(INDEX, html); // --- самопроверка: перечитываем файл --- const check = readFileSync(INDEX, 'utf8'); if (check.split(BEGIN).length - 1 !== 1) fail('самопроверка: маркер FAVICON:BEGIN не найден или дублируется'); if (check.split(END).length - 1 !== 1) fail('самопроверка: маркер FAVICON:END не найден или дублируется'); const links = (check.match(/<link rel="(?:icon|apple-touch-icon)"[^>]*href="data:image\/png;base64,[A-Za-z0-9+/=]+"/g) || []).length; if (links !== 2) fail(`самопроверка: ожидалось 2 <link> с data:image/png, найдено ${links}`); // Синтаксис JS в index.html — п.1 «Проверки» из AGENTS.md. let out; try { out = execFileSync(process.execPath, [join(ROOT, 'tools', 'check.mjs')], { encoding: 'utf8', cwd: ROOT }); } catch (err) { process.stdout.write(err.stdout || ''); process.stderr.write(err.stderr || String(err)); fail('node tools/check.mjs завершился с ошибкой'); } process.stdout.write(out); console.log(`favicon: OK — icon ${ICON_SIZE}×${ICON_SIZE} (${iconBytes} Б) + apple-touch-icon ${APPLE_SIZE}×${APPLE_SIZE} (${appleBytes} Б) встроены, check.mjs пройден`);