mirror of
https://github.com/Dvorinka/ClubLogos.git
synced 2026-06-03 19:42:58 +00:00
371 lines
9.8 KiB
JavaScript
371 lines
9.8 KiB
JavaScript
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { optimize as optimizeSvg } from 'svgo';
|
|
import sharp from 'sharp';
|
|
import chalk from 'chalk';
|
|
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
|
|
const execPromise = promisify(exec);
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
// Configuration
|
|
const CONFIG = {
|
|
// File size reporting thresholds (in KB)
|
|
sizeThresholds: {
|
|
small: 10,
|
|
medium: 100,
|
|
large: 500,
|
|
},
|
|
// Compression settings
|
|
compression: {
|
|
png: {
|
|
quality: 80, // 1-100
|
|
effort: 6, // 1-9 (higher is slower but better compression)
|
|
},
|
|
jpg: {
|
|
quality: 80, // 1-100
|
|
mozjpeg: true,
|
|
},
|
|
pdf: {
|
|
quality: 'printer', // 'screen', 'ebook', 'printer', 'prepress', or 'default'
|
|
},
|
|
},
|
|
// Directories to scan (relative to project root)
|
|
scanDirs: [
|
|
'backend/logos',
|
|
'data/logos',
|
|
'frontend/dist',
|
|
],
|
|
// File extensions to process
|
|
fileExtensions: ['svg', 'png', 'jpg', 'jpeg', 'pdf'],
|
|
};
|
|
|
|
// Statistics
|
|
const stats = {
|
|
totalFiles: 0,
|
|
processedFiles: 0,
|
|
skippedFiles: 0,
|
|
totalSaved: 0,
|
|
byType: {},
|
|
};
|
|
|
|
/**
|
|
* Format file size in human-readable format
|
|
*/
|
|
function formatSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
}
|
|
|
|
/**
|
|
* Get file size in bytes
|
|
*/
|
|
async function getFileSize(filePath) {
|
|
const stats = await fs.stat(filePath);
|
|
return stats.size;
|
|
}
|
|
|
|
/**
|
|
* Optimize SVG file
|
|
*/
|
|
async function optimizeSvgFile(filePath) {
|
|
const svg = await fs.readFile(filePath, 'utf8');
|
|
const result = optimizeSvg(svg, {
|
|
path: filePath,
|
|
multipass: true,
|
|
plugins: [
|
|
'preset-default',
|
|
{
|
|
name: 'removeViewBox',
|
|
active: false,
|
|
},
|
|
{
|
|
name: 'addAttributesToSVGElement',
|
|
params: {
|
|
attributes: [{ 'data-optimized': 'true' }],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
if (result.error) {
|
|
throw new Error(result.error);
|
|
}
|
|
|
|
await fs.writeFile(filePath, result.data);
|
|
return true;
|
|
}
|
|
|
|
async function convertSvgToPng(svgPath) {
|
|
try {
|
|
const normalized = path.normalize(svgPath);
|
|
let pngPath = normalized.replace(/\.svg$/i, '.png');
|
|
|
|
const mappings = [
|
|
{
|
|
from: path.join(path.sep, 'data', 'logos', 'svg') + path.sep,
|
|
to: path.join(path.sep, 'data', 'logos', 'png') + path.sep,
|
|
},
|
|
{
|
|
from: path.join(path.sep, 'backend', 'logos', 'svg') + path.sep,
|
|
to: path.join(path.sep, 'backend', 'logos', 'png') + path.sep,
|
|
},
|
|
];
|
|
|
|
for (const { from, to } of mappings) {
|
|
if (normalized.includes(from)) {
|
|
pngPath = normalized.replace(from, to).replace(/\.svg$/i, '.png');
|
|
break;
|
|
}
|
|
}
|
|
|
|
await fs.mkdir(path.dirname(pngPath), { recursive: true });
|
|
|
|
const image = sharp(svgPath, { density: 300 });
|
|
await image
|
|
.resize({ width: 512, fit: 'inside', withoutEnlargement: true })
|
|
.png({ quality: CONFIG.compression.png.quality, effort: CONFIG.compression.png.effort })
|
|
.toFile(pngPath);
|
|
|
|
console.log(chalk.green(` ✓ Converted to PNG: ${pngPath}`));
|
|
return true;
|
|
} catch (error) {
|
|
console.warn(chalk.yellow(` ⚠️ SVG to PNG conversion skipped: ${error.message}`));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Optimize PNG/JPG file
|
|
*/
|
|
async function optimizeImageFile(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const isJpeg = ['.jpg', '.jpeg'].includes(ext);
|
|
|
|
const pipeline = sharp(filePath);
|
|
|
|
// Get image metadata
|
|
const metadata = await pipeline.metadata();
|
|
|
|
// Skip if already optimized
|
|
if (metadata.optimizationLevel) {
|
|
return false;
|
|
}
|
|
|
|
// Optimize based on file type
|
|
if (isJpeg) {
|
|
await pipeline.jpeg({
|
|
quality: CONFIG.compression.jpg.quality,
|
|
mozjpeg: CONFIG.compression.jpg.mozjpeg,
|
|
});
|
|
} else {
|
|
await pipeline.png({
|
|
quality: CONFIG.compression.png.quality,
|
|
effort: CONFIG.compression.png.effort,
|
|
});
|
|
}
|
|
|
|
// Write optimized file
|
|
await pipeline.toFile(filePath);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Optimize PDF file using Ghostscript
|
|
*/
|
|
async function optimizePdfFile(filePath) {
|
|
try {
|
|
// Create a temporary file
|
|
const tempPath = `${filePath}.optimized`;
|
|
|
|
// Use Ghostscript to optimize the PDF
|
|
const { stderr } = await execPromise(
|
|
`gswin64c -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 ` +
|
|
`-dPDFSETTINGS=/${CONFIG.compression.pdf.quality} ` +
|
|
`-dNOPAUSE -dQUIET -dBATCH ` +
|
|
`-sOutputFile="${tempPath}" "${filePath}"`
|
|
);
|
|
|
|
if (stderr && !stderr.includes('Error')) {
|
|
// Replace original with optimized version
|
|
await fs.rename(tempPath, filePath);
|
|
return true;
|
|
} else {
|
|
// Clean up temp file if there was an error
|
|
await fs.unlink(tempPath).catch(() => {});
|
|
throw new Error(stderr || 'Unknown error optimizing PDF');
|
|
}
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') {
|
|
console.warn(chalk.yellow(' ⚠️ Ghostscript not found. Install it from https://ghostscript.com/ for PDF optimization.'));
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a single file
|
|
*/
|
|
async function processFile(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
const fileName = path.basename(filePath);
|
|
|
|
// Skip unsupported file types
|
|
if (!CONFIG.fileExtensions.includes(ext)) {
|
|
return { status: 'skipped', message: 'Unsupported file type' };
|
|
}
|
|
|
|
// Get original size
|
|
const originalSize = await getFileSize(filePath);
|
|
const originalSizeFormatted = formatSize(originalSize);
|
|
|
|
// Skip empty files
|
|
if (originalSize === 0) {
|
|
return { status: 'skipped', message: 'Empty file' };
|
|
}
|
|
|
|
try {
|
|
let optimized = false;
|
|
|
|
// Optimize based on file type
|
|
switch (ext) {
|
|
case 'svg':
|
|
{
|
|
const didOptimize = await optimizeSvgFile(filePath);
|
|
const didConvert = await convertSvgToPng(filePath);
|
|
optimized = didOptimize || didConvert;
|
|
}
|
|
break;
|
|
case 'png':
|
|
case 'jpg':
|
|
case 'jpeg':
|
|
optimized = await optimizeImageFile(filePath);
|
|
break;
|
|
case 'pdf':
|
|
optimized = await optimizePdfFile(filePath);
|
|
break;
|
|
}
|
|
|
|
// Get new size
|
|
const newSize = await getFileSize(filePath);
|
|
const newSizeFormatted = formatSize(newSize);
|
|
const saved = originalSize - newSize;
|
|
const savedPercent = ((saved / originalSize) * 100).toFixed(2);
|
|
|
|
// Update statistics
|
|
stats.processedFiles++;
|
|
stats.totalSaved += saved;
|
|
|
|
if (!stats.byType[ext]) {
|
|
stats.byType[ext] = { count: 0, saved: 0 };
|
|
}
|
|
stats.byType[ext].count++;
|
|
stats.byType[ext].saved += saved;
|
|
|
|
return {
|
|
status: 'optimized',
|
|
originalSize,
|
|
newSize,
|
|
saved,
|
|
savedPercent,
|
|
message: `${originalSizeFormatted} → ${newSizeFormatted} (saved ${savedPercent}%)`,
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error(chalk.red(` ✗ Error optimizing ${fileName}: ${error.message}`));
|
|
stats.skippedFiles++;
|
|
return { status: 'error', message: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a directory recursively
|
|
*/
|
|
async function processDirectory(directory) {
|
|
try {
|
|
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(directory, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
await processDirectory(fullPath);
|
|
} else if (entry.isFile()) {
|
|
const ext = path.extname(entry.name).toLowerCase().slice(1);
|
|
|
|
if (CONFIG.fileExtensions.includes(ext)) {
|
|
stats.totalFiles++;
|
|
|
|
console.log(chalk.blue(`\n🔍 Processing: ${fullPath}`));
|
|
|
|
const result = await processFile(fullPath);
|
|
|
|
switch (result.status) {
|
|
case 'optimized':
|
|
console.log(chalk.green(` ✓ Optimized: ${result.message}`));
|
|
break;
|
|
case 'skipped':
|
|
console.log(chalk.yellow(` ⏭️ Skipped: ${result.message}`));
|
|
break;
|
|
case 'error':
|
|
console.error(chalk.red(` ✗ Error: ${result.message}`));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(chalk.red(`Error reading directory ${directory}: ${error.message}`));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main function
|
|
*/
|
|
async function main() {
|
|
console.log(chalk.cyan('🚀 Starting asset optimization...\n'));
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Process each directory
|
|
for (const dir of CONFIG.scanDirs) {
|
|
const fullPath = path.resolve(__dirname, '..', dir);
|
|
console.log(chalk.cyan(`📂 Processing directory: ${fullPath}`));
|
|
await processDirectory(fullPath);
|
|
}
|
|
|
|
// Calculate total time
|
|
const totalTime = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
|
|
// Print summary
|
|
console.log('\n' + chalk.cyan('📊 Optimization Summary:'));
|
|
console.log(chalk.cyan('='.repeat(50)));
|
|
console.log(chalk.cyan(`Total files processed: ${stats.totalFiles}`));
|
|
console.log(chalk.green(`✓ Successfully optimized: ${stats.processedFiles}`));
|
|
|
|
if (stats.skippedFiles > 0) {
|
|
console.log(chalk.yellow(`⏭️ Skipped: ${stats.skippedFiles}`));
|
|
}
|
|
|
|
console.log(chalk.cyan(`💾 Total space saved: ${formatSize(stats.totalSaved)}`));
|
|
|
|
// Print stats by file type
|
|
console.log('\n' + chalk.cyan('📂 By file type:'));
|
|
for (const [type, data] of Object.entries(stats.byType)) {
|
|
console.log(` ${type.toUpperCase()}: ${data.count} files, saved ${formatSize(data.saved)}`);
|
|
}
|
|
|
|
console.log('\n' + chalk.green(`✨ Optimization completed in ${totalTime} seconds!`));
|
|
}
|
|
|
|
// Run the script
|
|
main().catch(error => {
|
|
console.error(chalk.red('❌ Fatal error:'), error);
|
|
process.exit(1);
|
|
});
|