pixeli
Version:
A lightweight command-line tool for merging multiple images into customizable grid layouts.
209 lines (171 loc) • 5.72 kB
JavaScript
import readline from 'node:readline';
import path from 'node:path';
import chalk from 'chalk';
import { validateSharedOptions } from './validations.js';
import { progressBar } from './progressBar.js';
export const SUPPORTED_INPUT_FORMATS = ['.webp', '.gif', '.jpeg', '.jpg', '.png', '.tiff', '.avif', '.svg'];
export const SUPPORTED_OUTPUT_FORMATS = ['.webp', '.gif', '.jpeg', '.jpg', '.png', '.tiff', '.avif'];
// Message class used for logging
export class Message {
constructor(text, type) {
this.text = text;
this.type = type;
switch (this.type) {
case 'error':
this.message = chalk.bold.red('Error: ') + chalk.red(this.text);
break;
case 'warning':
this.message = chalk.yellow(this.text);
break;
case 'success':
this.message = chalk.blue(this.text);
break;
case 'neutral':
this.message = chalk.gray(this.text);
break;
default:
this.message = chalk.gray(this.text);
break;
}
}
}
export const addSharedOptions = (cmd) => {
return cmd
.argument('[files...]', 'Image filepaths to merge (use --dir for directories)')
.option('-d, --dir <path>', 'Directory of images to merge')
.option('-r, --recursive', 'Recursively include subdirectories', false)
.option('--sh, --shuffle', 'Shuffle up images to randomize order in the grid', false)
.option('-g, --gap <px>', 'Gap between images', 50)
.option('--bg, --canvas-color <hex|transparent>', 'Background color for canvas', '#ffffff')
.option('-o, --output <file>', 'Output file path', './pixeli.png');
};
export const isValidHexadecimal = (str) => {
const hexRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
return hexRegex.test(str);
};
export const parseAspectRatio = (input) => {
// return ratio straight away if its just a number
const ratio = Number(input);
if (ratio) {
return ratio;
}
const ratioRegex = /^\s*(\d+)\s*(\/|:|x)\s*(\d+)\s*$/i;
const match = input.match(ratioRegex);
// not parsable
if (!match) {
return null;
}
const width = parseInt(match[1], 10);
const height = parseInt(match[3], 10);
return width / height;
};
export const handleError = (error) => {
const m = new Message(error.message, 'error');
console.log(m.message);
};
export const displayInfoMessage = (message) => {
const m = new Message(message, 'neutral');
console.log(m.message);
};
export const displayWarningMessage = (message) => {
const m = new Message(message, 'warning');
console.log(m.message);
};
export const displaySuccessMessage = (message) => {
const m = new Message(message, 'success');
console.log(m.message);
};
export const cliConfirm = (message) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(chalk.yellow(`${message} (Y/n) `), (value) => {
const cleanedValue = value.toLowerCase().trim();
if (cleanedValue === 'y' || !cleanedValue.length) resolve(true);
else resolve(false);
console.log();
rl.close();
});
});
};
export const isSupportedInputImage = (filename) => {
for (const supportedFormat of SUPPORTED_INPUT_FORMATS) {
if (filename.endsWith(supportedFormat)) {
return true;
}
}
return false;
};
export const isSupportedOutputImage = (filename) => {
for (const supportedFormat of SUPPORTED_OUTPUT_FORMATS) {
if (filename.endsWith(supportedFormat)) {
return true;
}
}
return false;
};
export const writeImage = async (image, output) => {
// Define file size limits
const LIMITS = {
png: 2_147_483_647,
jpg: 65_535,
jpeg: 65_535,
avif: 65_535,
webp: 16_383,
};
// Update progress bar stage
progressBar.update({ stage: 'Writing to file' });
try {
// Get image width and height
const { width, height } = await image.metadata();
const format = path.extname(output).replaceAll('.', '');
// Ensure image can be encoded in the respective format
const formatLimit = LIMITS[format];
if (width > formatLimit || height > formatLimit) {
throw new Error(`image is too large for ${format} format.`);
}
// Write to file
await image.toFile(output);
} catch (e) {
// Complete the progress bar
progressBar.update(progressBar.getTotal());
// Handle any errors
const m = new Message('Failed to write image on disk: ' + e.message, 'error');
console.log('\n' + m.message);
return false;
}
// Complete the progress bar
progressBar.update(progressBar.getTotal());
return true;
};
export const getValidatedParams = (files, opts, validationFunc) => {
const params = { files, ...opts };
const sharedOptions = validateSharedOptions(params);
const commandOptions = validationFunc(sharedOptions, params);
return { ...sharedOptions, ...commandOptions };
};
export const shuffleArray = (array) => {
let currentIndex = array.length;
let randomIndex;
// While there remain elements to shuffle.
while (currentIndex !== 0) {
// Pick a remaining element.
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
}
return array;
};
export const shuffleTogether = (a, b) => {
// 1. Build array of indices
const indices = [...a.keys()];
// 2. Shuffle the indices using your Fisher-Yates
shuffleArray(indices);
// 3. Apply same permutation to both arrays
const aShuffled = indices.map((i) => a[i]);
const bShuffled = indices.map((i) => b[i]);
return [aShuffled, bShuffled];
};