342 lines
12 KiB
JavaScript
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);
|
|
});
|