227 lines
6.8 KiB
JavaScript
Executable File
227 lines
6.8 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* PWA Icon Generator for Workout App
|
|
* Generates PNG icons without external dependencies
|
|
* Uses only Node.js built-in modules (fs, zlib, path)
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const zlib = require('zlib');
|
|
|
|
// Configuration
|
|
const BACKGROUND_COLOR = { r: 10, g: 10, b: 10 }; // #0A0A0A
|
|
const TEXT_COLOR = { r: 255, g: 255, b: 255 }; // White
|
|
const ICON_SIZES = [72, 96, 128, 144, 152, 192, 384, 512];
|
|
const MASKABLE_SIZES = [192, 512];
|
|
|
|
const OUTPUT_DIR = path.join(__dirname, '..', 'public', 'icons');
|
|
|
|
/**
|
|
* Create output directory if it doesn't exist
|
|
*/
|
|
function ensureOutputDir() {
|
|
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw a stylized "W" character on a pixel buffer
|
|
* Uses simple geometric shapes to create the letter
|
|
*/
|
|
function drawW(buffer, width, height) {
|
|
const canvasWidth = width;
|
|
const canvasHeight = height;
|
|
|
|
// Helper to set a pixel
|
|
const setPixel = (x, y, r, g, b, a = 255) => {
|
|
if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight) return;
|
|
const idx = (y * canvasWidth + x) * 4;
|
|
buffer[idx] = r;
|
|
buffer[idx + 1] = g;
|
|
buffer[idx + 2] = b;
|
|
buffer[idx + 3] = a;
|
|
};
|
|
|
|
// Helper to draw a filled rectangle (fast)
|
|
const fillRect = (x, y, w, h, r, g, b) => {
|
|
const x0 = Math.max(0, Math.floor(x));
|
|
const y0 = Math.max(0, Math.floor(y));
|
|
const x1 = Math.min(canvasWidth, Math.ceil(x + w));
|
|
const y1 = Math.min(canvasHeight, Math.ceil(y + h));
|
|
|
|
for (let yi = y0; yi < y1; yi++) {
|
|
for (let xi = x0; xi < x1; xi++) {
|
|
setPixel(xi, yi, r, g, b);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Fill background
|
|
fillRect(0, 0, canvasWidth, canvasHeight, BACKGROUND_COLOR.r, BACKGROUND_COLOR.g, BACKGROUND_COLOR.b);
|
|
|
|
// Calculate dimensions for the "W"
|
|
const padding = Math.floor(canvasHeight * 0.1);
|
|
const letterWidth = canvasWidth - padding * 2;
|
|
const letterHeight = canvasHeight - padding * 2;
|
|
|
|
const startX = padding;
|
|
const startY = padding;
|
|
|
|
// Draw a stylized "W" using rectangles (five vertical bars forming W shape)
|
|
const barWidth = letterWidth * 0.12;
|
|
const spacing = letterWidth / 4;
|
|
|
|
// Create a V-V-V pattern (W shape)
|
|
// First V: two diagonal bars meeting at a point
|
|
const v1LeftX = startX + spacing * 0.2;
|
|
const v1RightX = startX + spacing * 0.8;
|
|
const v1MidX = (v1LeftX + v1RightX) / 2;
|
|
const v1BottomY = startY + letterHeight;
|
|
const v1TopY = startY;
|
|
|
|
// Left bar of first V
|
|
fillRect(v1LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
|
|
|
// Right bar of first V
|
|
fillRect(v1RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
|
|
|
// Second V
|
|
const v2LeftX = startX + spacing * 0.9;
|
|
const v2RightX = startX + spacing * 1.5;
|
|
|
|
fillRect(v2LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
|
fillRect(v2RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
|
|
|
// Third V (right side)
|
|
const v3LeftX = startX + spacing * 1.6;
|
|
const v3RightX = startX + spacing * 2.2;
|
|
|
|
fillRect(v3LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
|
fillRect(v3RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
|
}
|
|
|
|
/**
|
|
* Create a PNG file from raw pixel data
|
|
* Uses PNG file format with zlib compression
|
|
*/
|
|
function createPNG(width, height, pixelBuffer, outputPath) {
|
|
// PNG signature
|
|
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
|
|
// IHDR chunk (image header)
|
|
const ihdrData = Buffer.alloc(13);
|
|
ihdrData.writeUInt32BE(width, 0);
|
|
ihdrData.writeUInt32BE(height, 4);
|
|
ihdrData[8] = 8; // bit depth
|
|
ihdrData[9] = 6; // color type (RGBA)
|
|
ihdrData[10] = 0; // compression method
|
|
ihdrData[11] = 0; // filter method
|
|
ihdrData[12] = 0; // interlace method
|
|
|
|
const ihdr = createChunk('IHDR', ihdrData);
|
|
|
|
// IDAT chunk (image data)
|
|
// Each row needs a filter byte (0 = None)
|
|
const rawData = Buffer.alloc(height * (1 + width * 4));
|
|
for (let y = 0; y < height; y++) {
|
|
rawData[y * (1 + width * 4)] = 0; // filter type
|
|
const rowStart = y * (1 + width * 4) + 1;
|
|
pixelBuffer.copy(rawData, rowStart, y * width * 4, (y + 1) * width * 4);
|
|
}
|
|
|
|
const compressed = zlib.deflateSync(rawData);
|
|
const idat = createChunk('IDAT', compressed);
|
|
|
|
// IEND chunk (image end)
|
|
const iend = createChunk('IEND', Buffer.alloc(0));
|
|
|
|
// Combine all chunks
|
|
const png = Buffer.concat([pngSignature, ihdr, idat, iend]);
|
|
|
|
fs.writeFileSync(outputPath, png);
|
|
}
|
|
|
|
/**
|
|
* Create a PNG chunk with proper CRC
|
|
*/
|
|
function createChunk(type, data) {
|
|
const length = Buffer.alloc(4);
|
|
length.writeUInt32BE(data.length, 0);
|
|
|
|
const typeBuffer = Buffer.from(type, 'ascii');
|
|
const chunkData = Buffer.concat([typeBuffer, data]);
|
|
|
|
// Calculate CRC32
|
|
const crc = calculateCRC32(chunkData);
|
|
const crcBuffer = Buffer.alloc(4);
|
|
crcBuffer.writeUInt32BE(crc, 0);
|
|
|
|
return Buffer.concat([length, chunkData, crcBuffer]);
|
|
}
|
|
|
|
/**
|
|
* Calculate CRC32 checksum for PNG chunks
|
|
*/
|
|
function calculateCRC32(data) {
|
|
const table = [];
|
|
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;
|
|
}
|
|
table[n] = c >>> 0;
|
|
}
|
|
|
|
let crc = 0xffffffff;
|
|
for (let i = 0; i < data.length; i++) {
|
|
crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
/**
|
|
* Generate all icon sizes
|
|
*/
|
|
function generateIcons() {
|
|
ensureOutputDir();
|
|
|
|
console.log('Generating PWA icons for Workout App...\n');
|
|
|
|
ICON_SIZES.forEach((size) => {
|
|
// Regular icon
|
|
const buffer = Buffer.alloc(size * size * 4);
|
|
drawW(buffer, size, size);
|
|
|
|
const outputPath = path.join(OUTPUT_DIR, `icon-${size}x${size}.png`);
|
|
createPNG(size, size, buffer, outputPath);
|
|
console.log(`✓ Created ${path.basename(outputPath)}`);
|
|
|
|
// Maskable variant (for adaptive icons on Android)
|
|
if (MASKABLE_SIZES.includes(size)) {
|
|
const maskableBuffer = Buffer.alloc(size * size * 4);
|
|
drawW(maskableBuffer, size, size);
|
|
|
|
const maskablePath = path.join(OUTPUT_DIR, `icon-${size}x${size}-maskable.png`);
|
|
createPNG(size, size, maskableBuffer, maskablePath);
|
|
console.log(`✓ Created ${path.basename(maskablePath)}`);
|
|
}
|
|
});
|
|
|
|
// Create a favicon SVG as well
|
|
const svgPath = path.join(OUTPUT_DIR, 'favicon.svg');
|
|
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
|
<rect width="192" height="192" fill="#0A0A0A"/>
|
|
<text x="96" y="132" font-size="120" font-weight="bold" text-anchor="middle" fill="white" font-family="Arial, sans-serif">W</text>
|
|
</svg>`;
|
|
fs.writeFileSync(svgPath, svgContent);
|
|
console.log(`✓ Created ${path.basename(svgPath)}`);
|
|
|
|
console.log(`\nAll icons generated successfully in ${OUTPUT_DIR}`);
|
|
}
|
|
|
|
// Run the generator
|
|
generateIcons();
|