281 lines
13 KiB
JavaScript
281 lines
13 KiB
JavaScript
#!/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 пройден`);
|