folderkit
Version:
Pixel-perfect macOS folder icons generator for developers.
361 lines (360 loc) • 13.3 kB
JavaScript
import node_fs from "node:fs";
import node_path from "node:path";
import sharp from "sharp";
var enums_Resolution = /*#__PURE__*/ function(Resolution) {
Resolution["NonRetina16"] = "16x16";
Resolution["Retina16"] = "16x16@2x";
Resolution["NonRetina32"] = "32x32";
Resolution["Retina32"] = "32x32@2x";
Resolution["NonRetina128"] = "128x128";
Resolution["Retina128"] = "128x128@2x";
Resolution["NonRetina256"] = "256x256";
Resolution["Retina256"] = "256x256@2x";
Resolution["NonRetina512"] = "512x512";
Resolution["Retina512"] = "512x512@2x";
return Resolution;
}({});
var enums_FolderTheme = /*#__PURE__*/ function(FolderTheme) {
FolderTheme["Tahoe"] = "Tahoe";
FolderTheme["BigSurLight"] = "BigSur";
FolderTheme["BigSurDark"] = "BigSur.dark";
return FolderTheme;
}({});
const RETINA_SCALE = 2;
const DEFAULT_THEME = enums_FolderTheme.BigSurLight;
const DEFAULT_RESOLUTION = enums_Resolution.NonRetina256;
const DEFAULT_OPTIONS = Object.freeze({
theme: DEFAULT_THEME,
filter: {},
resolution: DEFAULT_RESOLUTION
});
const RESOLUTION_SIZE = {
[enums_Resolution.NonRetina16]: 16,
[enums_Resolution.Retina16]: 16 * RETINA_SCALE,
[enums_Resolution.NonRetina32]: 32,
[enums_Resolution.Retina32]: 32 * RETINA_SCALE,
[enums_Resolution.NonRetina128]: 128,
[enums_Resolution.Retina128]: 128 * RETINA_SCALE,
[enums_Resolution.NonRetina256]: 256,
[enums_Resolution.Retina256]: 256 * RETINA_SCALE,
[enums_Resolution.NonRetina512]: 512,
[enums_Resolution.Retina512]: 512 * RETINA_SCALE
};
const RESOLUTION_OFFSET_Y = {
[enums_Resolution.NonRetina16]: 2,
[enums_Resolution.Retina16]: 2,
[enums_Resolution.NonRetina32]: 2,
[enums_Resolution.Retina32]: 3,
[enums_Resolution.NonRetina128]: 6,
[enums_Resolution.Retina128]: 12,
[enums_Resolution.NonRetina256]: 12,
[enums_Resolution.Retina256]: 24,
[enums_Resolution.NonRetina512]: 24,
[enums_Resolution.Retina512]: 48
};
const TOP_BEZEL_COLOR = {
r: 58,
g: 152,
b: 208,
alpha: 1
};
const BOTTOM_BEZEL_COLOR = {
r: 174,
g: 225,
b: 253,
alpha: 1
};
const THEME_FILL_COLOR = {
[enums_FolderTheme.Tahoe]: {
r: 74,
g: 141,
b: 172
},
[enums_FolderTheme.BigSurLight]: {
r: 8,
g: 134,
b: 206
},
[enums_FolderTheme.BigSurDark]: {
r: 6,
g: 111,
b: 194
}
};
const DEFAULT_ICNS_FILENAME = 'GenericFolderIcon.icns';
const blur_blur = ({ step, distance, angle })=>async (input)=>{
const angleRad = angle * Math.PI / 180;
const xOffset = Math.round(Math.cos(angleRad) * distance);
const yOffset = -Math.round(Math.sin(angleRad) * distance);
const { width, height } = await sharp(input).metadata();
const baseImage = sharp({
create: {
width: width + 2 * Math.abs(xOffset),
height: height + 2 * Math.abs(yOffset),
channels: 4,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0
}
}
});
const sourceBuffer = await sharp(input).ensureAlpha().toBuffer();
const composites = [];
for(let i = 0; i < step; i++){
const offsetX = Math.round(xOffset * i / step);
const offsetY = Math.round(yOffset * i / step);
const opacity = 1 / (i + 1);
const offsetImage = await sharp(sourceBuffer).composite([
{
input: Buffer.from([
0,
0,
0,
255 * opacity
]),
raw: {
width: 1,
height: 1,
channels: 4
},
blend: 'dest-in',
tile: true
}
]).toBuffer();
composites.push({
input: offsetImage,
top: offsetY + Math.abs(yOffset),
left: offsetX + Math.abs(xOffset),
blend: 'over'
});
}
return baseImage.composite(composites).extract({
left: Math.abs(xOffset),
top: Math.abs(yOffset),
width,
height
});
};
const composite = (...options)=>(input)=>sharp(input).composite(options);
const extend = (options)=>(input)=>sharp(input).extend({
...options,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0
}
});
const modulate = (options)=>(input)=>sharp(input).modulate(options);
const negate = (options)=>(input)=>sharp(input).negate(options);
const resize = (options)=>(input)=>sharp(input).resize({
...options,
background: {
r: 0,
g: 0,
b: 0,
alpha: 0
}
});
const tint = (color, strategy = 'fill')=>async (input)=>{
if ('overlay' === strategy) return sharp(input).tint(color);
const { width, height } = await sharp(input).metadata();
return sharp({
create: {
width,
height,
channels: 4,
background: color
}
}).composite([
{
input,
blend: 'dest-in'
}
]);
};
const translucent = (alpha)=>(input)=>sharp(input).ensureAlpha().composite([
{
input: {
create: {
width: 1,
height: 1,
channels: 4,
background: {
r: 0,
g: 0,
b: 0,
alpha
}
}
},
blend: 'dest-in',
tile: true
}
]);
const trim = (options)=>(input)=>sharp(input).trim(options);
const bold = (s)=>`\x1b[1m${s}\x1b[0m`;
const getFolderResource = async ({ theme, resolution })=>{
const { default: resource } = await import(`@folderkit/resources/folders/${theme}.iconset/icon_${resolution}.ts`);
return Buffer.from(resource, 'base64');
};
const pipeProcessors = async (input, ...processors)=>{
const validProcessors = processors.filter((p)=>void 0 !== p);
return validProcessors.reduce(async (previousPromise, processor)=>{
const processedImage = await processor(await previousPromise);
return processedImage.toFormat('png').toBuffer();
}, sharp(input).toBuffer());
};
const withErrorBoundary = (fn)=>{
try {
return fn();
} catch (error) {
console.error(error);
process.exit(1);
}
};
const FULL_MASK_WIDTH = 768;
const FULL_MASK_HEIGHT = 384;
const pipeResizedMask = async (input, { trim: shouldTrim = true, resolution = DEFAULT_RESOLUTION })=>withErrorBoundary(async ()=>{
const resolutionSize = RESOLUTION_SIZE[resolution];
if (!resolutionSize) throw Error(`Unsupported resolution: ${resolution}`);
const resizedWidth = Math.floor(3 * resolutionSize / 4);
const resizedHeight = Math.floor(resolutionSize / 2);
const offsetY = RESOLUTION_OFFSET_Y[resolution] || 0;
const centerOffset = (size, resized)=>(size - resized) / 2;
try {
const result = await pipeProcessors(input, shouldTrim ? trim() : void 0, resize({
width: FULL_MASK_WIDTH,
height: FULL_MASK_HEIGHT,
fit: 'contain'
}), resize({
width: resizedWidth,
height: resizedHeight,
fit: 'contain'
}), extend({
top: centerOffset(resolutionSize, resizedHeight) + offsetY,
bottom: centerOffset(resolutionSize, resizedHeight) - offsetY,
left: centerOffset(resolutionSize, resizedWidth),
right: centerOffset(resolutionSize, resizedWidth)
}));
return result;
} catch (error) {
throw Error(`Failed to resize mask: ${error instanceof Error ? error.message : String(error)}`);
}
});
const pipeFilledMask = (input, { theme = DEFAULT_THEME })=>withErrorBoundary(async ()=>{
const fillColor = THEME_FILL_COLOR[theme];
if (!fillColor) throw Error(`Unsupported theme: ${theme}`);
try {
const result = await pipeProcessors(input, tint(fillColor), translucent(0.5));
return result;
} catch (error) {
throw Error(`Failed to create filled mask: ${error instanceof Error ? error.message : String(error)}`);
}
});
const pipeTopBezelMask = (input)=>withErrorBoundary(async ()=>{
try {
const result = await pipeProcessors(input, negate(), tint(TOP_BEZEL_COLOR), blur_blur({
step: 5,
distance: 2,
angle: 180
}), composite({
input: input,
blend: 'dest-in'
}), translucent(0.5));
return result;
} catch (error) {
throw Error(`Failed to create top bezel mask: ${error instanceof Error ? error.message : String(error)}`);
}
});
const pipeBottomBezelMask = (input)=>withErrorBoundary(async ()=>{
try {
const result = await pipeProcessors(input, tint(BOTTOM_BEZEL_COLOR), blur_blur({
step: 5,
distance: 1,
angle: 180
}), composite({
input: input,
blend: 'dest-out'
}), translucent(0.6));
return result;
} catch (error) {
throw Error(`Failed to create bottom bezel mask: ${error instanceof Error ? error.message : String(error)}`);
}
});
const pipeFilter = (input, filterOptions = {})=>withErrorBoundary(async ()=>{
const tintFilterProcessors = filterOptions.tintColor ? [
modulate({
saturation: 0.1
}),
tint(filterOptions.tintColor, 'overlay')
] : [];
return await pipeProcessors(input, ...tintFilterProcessors);
});
const validateOptions = ({ resolution, theme })=>{
if (!resolution || !Object.values(enums_Resolution).includes(resolution)) throw Error(`Unsupported resolution: ${resolution}`);
if (!theme || !Object.values(enums_FolderTheme).includes(theme)) throw Error(`Unsupported theme: ${theme}`);
};
const validateIconSetOptions = ({ theme, output })=>{
if (node_fs.existsSync(output)) throw Error(`Path already exists: ${output}`);
if (!theme || !Object.values(enums_FolderTheme).includes(theme)) throw Error(`Unsupported theme: ${theme}`);
};
const processImage = async (input, options)=>{
const resizedMask = await pipeResizedMask(input, options);
const [filledMask, topBezelMask, bottomBezelMask] = await Promise.all([
pipeFilledMask(resizedMask, options),
pipeTopBezelMask(resizedMask),
pipeBottomBezelMask(resizedMask)
]);
const resource = await getFolderResource(options);
try {
const result = await pipeProcessors(resource, composite({
input: filledMask
}, {
input: topBezelMask
}, {
input: bottomBezelMask
}));
return pipeFilter(result, options.filter);
} catch (error) {
throw Error(`Failed to composite final image: ${error instanceof Error ? error.message : String(error)}`);
}
};
const generate = (input, options = {})=>{
const mergedOptions = {
...DEFAULT_OPTIONS,
...options
};
return withErrorBoundary(async ()=>{
validateOptions(mergedOptions);
return await processImage(input, mergedOptions);
});
};
const generateIconSet = (input, output, options = {})=>{
if ('.iconset' !== node_path.extname(output)) output += '.iconset';
const { resolution: _resolution, ...defaultOptions } = DEFAULT_OPTIONS;
const mergedOptions = {
...defaultOptions,
...options
};
return withErrorBoundary(async ()=>{
validateIconSetOptions({
...mergedOptions,
output
});
node_fs.mkdirSync(output, {
recursive: true
});
await Promise.all(Object.values(enums_Resolution).map(async (resolution)=>node_fs.writeFileSync(node_path.join(output, `icon_${resolution}.png`), await processImage(input, {
...mergedOptions,
resolution
}))));
const icnsPath = node_path.join(output, '..', DEFAULT_ICNS_FILENAME);
console.log(`Iconset generated at: ${output}`);
console.log('To convert the iconset to an .icns file, run the following command:');
console.log(bold(`\n iconutil --convert icns ${output} --output ${icnsPath}\n`));
});
};
export { enums_FolderTheme as FolderTheme, enums_Resolution as Resolution, generate, generateIconSet };