Files
andrey a80d8fb464 Этап 54: favicon вшит в index.html (data:-URI PNG). (1) Внешняя иконка в архив Яндекс Игр не доехала бы (публикуется только index.html), поэтому логотип встроен в <head> как data:image/png;base64 — работает и под file://, и в iframe площадки. Генератор tools/favicon.mjs (новый, по образцу embed-fish.mjs): чистый Node без зависимостей, идемпотентный блок между маркерами <!-- FAVICON:BEGIN/END --> сразу после <title>, самопроверка + check.mjs внутри. Собственный PNG-декодер на node:zlib (8 бит, colorType 0/2/4/6, снятие фильтров строк 0–4, не-interlaced) читает logo.png (512×512 RGB); area-average ресемпл в 64×64 (rel="icon") и 180×180 (rel="apple-touch-icon"); PNG-энкодер (colorType 2, level 9) выбирает фильтр строки эвристикой libpng (минимум суммы |signed| по 5 предикторам; 180×180 → 21 КБ против 36 КБ при всегда filter 0). Обновить иконку = заменить logo.png + node tools/favicon.mjs. (2) В <head> index.html добавлены два <link> с base64 (блок ~34 КБ). Маркеры не в исключениях минификатора — minify.mjs их снимает, в релизе остаются только <link> (data:-URI побайтово совпадают с оригиналом). (3) Проверки: check.mjs OK; tests/test.js 234/234 PASS (тесты не менялись, геймплей не задет); minify.mjs 6/6 (−34%: 577→382 КБ, gzip 208→152 КБ); pack.mjs 5/5; pack-smoke.js 6/6; обе иконки декодируются в Chromium как 64×64/180×180. (4) Доки: AGENTS.md (пункт «Favicon (Этап 54)»), README (размеры/состав файла ~577 КБ, 234 проверки), docs/DEVLOG.md (Этап 54).
2026-10-12 07:58:18 +03:00

281 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 в <link rel="icon">/rel="apple-touch-icon".
//
// Запуск: node tools/favicon.mjs (из каталога репо или откуда угодно — пути от репо)
// - идемпотентен: если маркеры FAVICON:BEGIN/END есть — заменяет всё между ними;
// если нет — вставляет блок сразу ПОСЛЕ строки <title>.
// - после записи — самопроверка (маркеры, 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>…</title> — некуда вставить блок');
const pos = m.index + m[0].length;
html = html.slice(0, pos) + block + '\n' + html.slice(pos);
console.log(`favicon: блок вставлен после <title> (строка ${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 пройден`);