/* Предрейсовые осмотры - мобильное приложение Генератор иконок приложения из одного PNG */ 'use strict'; //--------------------- //Подключение библиотек //--------------------- const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); //--------- //Константы //--------- const APP_ROOT = path.join(__dirname, '..'); const LOGO_PATH = path.join(APP_ROOT, 'assets', 'icons', 'logo.png'); const ANDROID_SIZES = Object.freeze([ Object.freeze({ dir: 'mipmap-mdpi', size: 48 }), Object.freeze({ dir: 'mipmap-hdpi', size: 72 }), Object.freeze({ dir: 'mipmap-xhdpi', size: 96 }), Object.freeze({ dir: 'mipmap-xxhdpi', size: 144 }), Object.freeze({ dir: 'mipmap-xxxhdpi', size: 192 }) ]); const IOS_ICONS = Object.freeze([ Object.freeze({ filename: 'icon-20@2x.png', size: 40 }), Object.freeze({ filename: 'icon-20@3x.png', size: 60 }), Object.freeze({ filename: 'icon-29@2x.png', size: 58 }), Object.freeze({ filename: 'icon-29@3x.png', size: 87 }), Object.freeze({ filename: 'icon-40@2x.png', size: 80 }), Object.freeze({ filename: 'icon-40@3x.png', size: 120 }), Object.freeze({ filename: 'icon-60@2x.png', size: 120 }), Object.freeze({ filename: 'icon-60@3x.png', size: 180 }), Object.freeze({ filename: 'icon-1024.png', size: 1024 }) ]); const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const ANDROID_RES_DIR = path.join(APP_ROOT, 'android', 'app', 'src', 'main', 'res'); const IOS_APP_ICON_DIR = path.join(APP_ROOT, 'ios', 'app', 'Images.xcassets', 'AppIcon.appiconset'); const U32_MAX = 4294967296; //----------- //Тело модуля //----------- //Приведение числа к беззнаковому 32-битному (без побитовых операторов) function u32(x) { return ((x % U32_MAX) + U32_MAX) % U32_MAX; } //Побитовое XOR для двух 32-битных беззнаковых (через арифметику, для соответствия ESLint) function xor32(a, b) { const au = u32(a); const bu = u32(b); let r = 0; let p = 1; for (let i = 0; i < 32; i++) { if (Math.floor(au / p) % 2 !== Math.floor(bu / p) % 2) r += p; p *= 2; } return u32(r); } //Байт в диапазоне 0..255 (эквивалент & 0xff для суммы/разности байтов) function byteClamp(v) { return ((v % 256) + 256) % 256; } //Таблица CRC32 для PNG-чанков (полином IEEE, без побитовых операторов) const CRC_TABLE = (() => { const table = new Uint32Array(256); const poly = 0xedb88320; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { const low = c % 2; c = Math.floor(u32(c) / 2); if (low) c = xor32(poly, c); } table[n] = u32(c); } return table; })(); //CRC32 от буфера (тип чанка + данные), для проверки и записи PNG. Индекс таблицы — младший байт (crc XOR byte). function crc32(buffer, start = 0, length = buffer.length - start) { let crc = 0xffffffff; const end = start + length; for (let i = start; i < end; i++) { const lo = xor32(crc, buffer[i]); const indexByte = u32(lo) % 256; crc = xor32(CRC_TABLE[indexByte], Math.floor(u32(crc) / 256)); } return u32(xor32(crc, 0xffffffff)); } //Чтение PNG: файл → RGBA (Uint8Array, по строкам, 4 байта на пиксель). Поддержка RGB/RGBA 8-bit. function readPng(filePath) { const data = fs.readFileSync(filePath); let offset = 0; if (data.length < 8 || !PNG_SIGNATURE.equals(data.subarray(0, 8))) { throw new Error(`Invalid PNG signature: ${filePath}`); } offset = 8; let width = 0; let height = 0; let bitDepth = 0; let colorType = 0; let interlace = 0; const idatChunks = []; while (offset < data.length) { const length = data.readUInt32BE(offset); const type = data.toString('ascii', offset + 4, offset + 8); const chunkStart = offset + 8; const chunkEnd = chunkStart + length; if (chunkEnd + 4 > data.length) throw new Error('PNG truncated'); const crcExpected = data.readUInt32BE(chunkEnd); const typeAndDataStart = offset + 4; const crcInput = data.subarray(typeAndDataStart, typeAndDataStart + 4 + length); const crcGot = crc32(crcInput); if (crcGot !== crcExpected) throw new Error(`PNG chunk CRC mismatch: ${type}`); offset = chunkEnd + 4; if (type === 'IHDR') { if (length < 13) throw new Error('IHDR too short'); width = data.readUInt32BE(chunkStart); height = data.readUInt32BE(chunkStart + 4); bitDepth = data[chunkStart + 8]; colorType = data[chunkStart + 9]; interlace = data[chunkStart + 12]; } else if (type === 'IDAT') { idatChunks.push(data.subarray(chunkStart, chunkEnd)); } else if (type === 'IEND') { break; } } if (interlace !== 0) throw new Error('Interlaced PNG is not supported'); if (bitDepth !== 8) throw new Error('Only 8-bit PNG is supported'); if (colorType !== 2 && colorType !== 6) { throw new Error('Only RGB or RGBA PNG is supported (color type 2 or 6)'); } const bytesPerPixel = colorType === 6 ? 4 : 3; const rawSize = (1 + width * bytesPerPixel) * height; const concatenated = Buffer.concat(idatChunks); const inflated = zlib.inflateSync(concatenated, { chunkSize: rawSize + 1024 }); if (inflated.length < rawSize) throw new Error('PNG decompressed size too small'); //Снятие фильтров строк и приведение к RGBA const rgba = new Uint8Array(width * height * 4); const stride = width * bytesPerPixel; let prev = null; for (let y = 0; y < height; y++) { const rowStart = y * (1 + stride); const filter = inflated[rowStart]; const raw = inflated.subarray(rowStart + 1, rowStart + 1 + stride); const out = new Uint8Array(stride); for (let x = 0; x < stride; x++) { const left = x >= bytesPerPixel ? out[x - bytesPerPixel] : 0; const up = prev ? prev[x] : 0; const upLeft = prev && x >= bytesPerPixel ? prev[x - bytesPerPixel] : 0; let v = raw[x]; if (filter === 1) v = byteClamp(v + left); else if (filter === 2) v = byteClamp(v + up); else if (filter === 3) v = byteClamp(v + Math.floor((left + up) / 2)); else if (filter === 4) v = byteClamp(v + paeth(left, up, upLeft)); out[x] = v; } prev = out; for (let x = 0; x < width; x++) { const i = x * bytesPerPixel; const o = (y * width + x) * 4; rgba[o] = out[i]; rgba[o + 1] = out[i + 1]; rgba[o + 2] = out[i + 2]; rgba[o + 3] = bytesPerPixel === 4 ? out[i + 3] : 255; } } return { width, height, rgba }; } //Предиктор Paeth для фильтра PNG (без побитовых операций) function paeth(a, b, c) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); if (pa <= pb && pa <= pc) return a; if (pb <= pc) return b; return c; } //Масштабирование RGBA билинейной интерполяцией function resizeBilinear(srcWidth, srcHeight, srcRgba, dstWidth, dstHeight) { const dst = new Uint8Array(dstWidth * dstHeight * 4); const src = srcRgba; for (let dy = 0; dy < dstHeight; dy++) { const sy = (dy + 0.5) * (srcHeight / dstHeight) - 0.5; const sy0 = Math.max(0, Math.floor(sy)); const sy1 = Math.min(srcHeight - 1, sy0 + 1); const fy = sy - sy0; for (let dx = 0; dx < dstWidth; dx++) { const sx = (dx + 0.5) * (srcWidth / dstWidth) - 0.5; const sx0 = Math.max(0, Math.floor(sx)); const sx1 = Math.min(srcWidth - 1, sx0 + 1); const fx = sx - sx0; const i00 = (sy0 * srcWidth + sx0) * 4; const i10 = (sy0 * srcWidth + sx1) * 4; const i01 = (sy1 * srcWidth + sx0) * 4; const i11 = (sy1 * srcWidth + sx1) * 4; const o = (dy * dstWidth + dx) * 4; for (let ch = 0; ch < 4; ch++) { const v00 = src[i00 + ch]; const v10 = src[i10 + ch]; const v01 = src[i01 + ch]; const v11 = src[i11 + ch]; const top = v00 * (1 - fx) + v10 * fx; const bot = v01 * (1 - fx) + v11 * fx; dst[o + ch] = Math.round(top * (1 - fy) + bot * fy); } } } return dst; } //Кодирование RGBA в буфер PNG (фильтр None, deflate) function writePngBuffer(width, height, rgba) { const stride = width * 4; const rawRows = []; for (let y = 0; y < height; y++) { const row = Buffer.alloc(1 + stride); row[0] = 0; //фильтр None for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; row[1 + x * 4] = rgba[i]; row[2 + x * 4] = rgba[i + 1]; row[3 + x * 4] = rgba[i + 2]; row[4 + x * 4] = rgba[i + 3]; } rawRows.push(row); } const rawData = Buffer.concat(rawRows); const compressed = zlib.deflateSync(rawData, { level: 9 }); function writeChunk(out, type, data) { const len = data ? data.length : 0; const buf = Buffer.alloc(12 + len); buf.writeUInt32BE(len, 0); buf.write(type, 4, 4, 'ascii'); if (data) data.copy(buf, 8); const crc = crc32(Buffer.concat([Buffer.from(type, 'ascii'), data || Buffer.alloc(0)])); buf.writeUInt32BE(crc, 8 + len); out.push(buf); } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; //глубина цвета ihdr[9] = 6; //тип цвета RGBA ihdr[10] = 0; //сжатие deflate ihdr[11] = 0; //фильтр адаптивный ihdr[12] = 0; //без чересстрочности const out = [PNG_SIGNATURE]; writeChunk(out, 'IHDR', ihdr); writeChunk(out, 'IDAT', compressed); writeChunk(out, 'IEND', null); return Buffer.concat(out); } //Создание каталога при необходимости function ensureDir(dirPath) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); } } //Генерация всех иконок: один раз читаем логотип, ресайзим и пишем в Android/iOS async function generate() { if (!fs.existsSync(LOGO_PATH)) { const err = new Error(`Logo not found: ${LOGO_PATH}`); err.code = 'ENOENT'; throw err; } const logo = readPng(LOGO_PATH); const { width: w, height: h, rgba } = logo; for (const { dir, size } of ANDROID_SIZES) { const dirPath = path.join(ANDROID_RES_DIR, dir); ensureDir(dirPath); const resized = resizeBilinear(w, h, rgba, size, size); const png = writePngBuffer(size, size, resized); const base = path.join(dirPath, 'ic_launcher.png'); const round = path.join(dirPath, 'ic_launcher_round.png'); await Promise.all([fs.promises.writeFile(base, png), fs.promises.writeFile(round, png)]); console.log('Android', dir, `${size}x${size}`); } for (const { filename, size } of IOS_ICONS) { const outPath = path.join(IOS_APP_ICON_DIR, filename); const resized = resizeBilinear(w, h, rgba, size, size); const png = writePngBuffer(size, size, resized); await fs.promises.writeFile(outPath, png); console.log('iOS', filename, `${size}x${size}`); } console.log('Done. App icon set from', LOGO_PATH); } //---------------- //Точка входа //---------------- generate().catch(err => { console.error(err.message || err); process.exit(1); });