CEMROS_hauler_pti_app/rn/app/scripts/generate-app-icons.js

342 lines
12 KiB
JavaScript

/*
Предрейсовые осмотры - мобильное приложение
Генератор иконок приложения из одного 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);
});