UNPKG

folderkit

Version:

Pixel-perfect macOS folder icons generator for developers.

361 lines (360 loc) 13.3 kB
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 };