#!/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, оба ) + 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 = '';
const 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,
``,
``,
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>\r?\n?/.exec(html);
if (!m) fail('в index.html не найден … — некуда вставить блок');
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(/]*href="data:image\/png;base64,[A-Za-z0-9+/=]+"/g) || []).length;
if (links !== 2) fail(`самопроверка: ожидалось 2 с 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 пройден`);