From fa76bf687821d4e33bd65ff23261d2c5994cbc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9EAH=C4=B0N?= Date: Tue, 10 Feb 2026 10:34:05 +0300 Subject: [PATCH] feat: add ICNS write support Enable encoding images to Apple ICNS format using a custom PNG-based encoder targeting modern OS X 10.5+ icon types (ic07-ic14). Resized PNG variants are cached by pixel size to avoid duplicate work. --- src/lib/converters/magick.svelte.ts | 2 +- src/lib/util/icns-encoder.ts | 111 ++++++++++++++++++++++++++++ src/lib/workers/magick.ts | 89 ++++++++++++++++++++++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/lib/util/icns-encoder.ts diff --git a/src/lib/converters/magick.svelte.ts b/src/lib/converters/magick.svelte.ts index 1190d49..8d04bc2 100644 --- a/src/lib/converters/magick.svelte.ts +++ b/src/lib/converters/magick.svelte.ts @@ -34,7 +34,7 @@ export class MagickConverter extends Converter { new FormatInfo("bmp", true, true), new FormatInfo("cur", true, true), new FormatInfo("ani", true, false), - new FormatInfo("icns", true, false), + new FormatInfo("icns", true, true), new FormatInfo("nef", true, false), new FormatInfo("cr2", true, false), new FormatInfo("hdr", true, true), diff --git a/src/lib/util/icns-encoder.ts b/src/lib/util/icns-encoder.ts new file mode 100644 index 0000000..c9dd6f7 --- /dev/null +++ b/src/lib/util/icns-encoder.ts @@ -0,0 +1,111 @@ +/** + * ICNS (Apple Icon Image) file format encoder + * + * Creates .icns files from PNG images at various sizes. + * + * File structure: + * - 8 byte header: 'icns' magic + 4 byte file length (big-endian) + * - Icon elements: OSType (4 bytes) + length (4 bytes) + data + * + * References: + * - https://en.wikipedia.org/wiki/Apple_Icon_Image_format + * - https://github.com/fiahfy/icns + */ + +/** + * Icon type definitions for ICNS format + * Maps pixel dimensions to OSType codes + * + * Only modern PNG-based types (OS X 10.5+) are included. + * Legacy types (is32, il32, ih32, etc.) require raw RGB + mask encoding + * and are not supported by this encoder. + */ +export const ICNS_TYPES = { + // Modern PNG-based formats (OS X 10.5+) + 'ic07': { size: 128, scale: 1, format: 'png' }, // 128x128 + 'ic08': { size: 256, scale: 1, format: 'png' }, // 256x256 + 'ic09': { size: 512, scale: 1, format: 'png' }, // 512x512 + 'ic10': { size: 1024, scale: 2, format: 'png' }, // 1024x1024 (512x512@2x retina) + 'ic11': { size: 32, scale: 2, format: 'png' }, // 32x32 (16x16@2x retina) + 'ic12': { size: 64, scale: 2, format: 'png' }, // 64x64 (32x32@2x retina) + 'ic13': { size: 256, scale: 2, format: 'png' }, // 256x256 (128x128@2x retina) + 'ic14': { size: 512, scale: 2, format: 'png' }, // 512x512 (256x256@2x retina) +} as const; + +export type IconType = keyof typeof ICNS_TYPES; + +export interface IconEntry { + type: IconType; + data: Uint8Array; +} + +/** + * Encodes icon entries into a complete ICNS file + */ +export function encodeIcns(entries: IconEntry[]): Uint8Array { + if (entries.length === 0) { + throw new Error('At least one icon entry is required'); + } + + // Calculate total file size + let totalSize = 8; // Header size + for (const entry of entries) { + totalSize += 8 + entry.data.length; // type (4) + length (4) + data + } + + // Create output buffer + const buffer = new ArrayBuffer(totalSize); + const view = new DataView(buffer); + const uint8 = new Uint8Array(buffer); + + let offset = 0; + + // Write file header + // Magic number: 'icns' + uint8[offset++] = 0x69; // 'i' + uint8[offset++] = 0x63; // 'c' + uint8[offset++] = 0x6E; // 'n' + uint8[offset++] = 0x73; // 's' + + // File length (big-endian) + view.setUint32(offset, totalSize, false); + offset += 4; + + const textEncoder = new TextEncoder(); + + // Write icon elements + for (const entry of entries) { + // Write OSType (4 bytes) + const typeBytes = textEncoder.encode(entry.type); + uint8.set(typeBytes, offset); + offset += 4; + + // Write data length (big-endian): 8 (header) + data length + const elementSize = 8 + entry.data.length; + view.setUint32(offset, elementSize, false); + offset += 4; + + // Write image data + uint8.set(entry.data, offset); + offset += entry.data.length; + } + + return uint8; +} + +/** + * Get the recommended icon sizes for a complete ICNS file + * Returns sizes in descending order (largest first) + */ +export function getRecommendedSizes(): IconType[] { + return [ + 'ic10', // 1024x1024 (512@2x) + 'ic14', // 512x512 (256@2x) + 'ic09', // 512x512 + 'ic13', // 256x256 (128@2x) + 'ic08', // 256x256 + 'ic12', // 64x64 (32@2x) + 'ic07', // 128x128 + 'ic11', // 32x32 (16@2x) + ]; +} diff --git a/src/lib/workers/magick.ts b/src/lib/workers/magick.ts index aa3ab5d..e11ceb8 100644 --- a/src/lib/workers/magick.ts +++ b/src/lib/workers/magick.ts @@ -10,6 +10,12 @@ import { makeZip } from "client-zip"; import { parseAni } from "$lib/util/parse/ani"; import { parseIcns } from "vert-wasm"; import type { WorkerMessage } from "$lib/types"; +import { + encodeIcns, + getRecommendedSizes, + ICNS_TYPES, + type IconEntry, +} from "$lib/util/icns-encoder"; let magickInitialized = false; @@ -215,6 +221,89 @@ const handleMessage = async ( }; } + // handle converting TO .icns + if (message.to === ".icns") { + const sourceImg = MagickImage.create( + new Uint8Array(buffer), + new MagickReadSettings({ + format: from.slice(1).toUpperCase() as MagickFormat, + }), + ); + + try { + const iconEntries: IconEntry[] = []; + const sizes = getRecommendedSizes(); + + // Export source to PNG once and reuse for all sizes + const sourcePng = await new Promise( + (resolve, reject) => { + try { + sourceImg.write( + MagickFormat.Png, + (output: Uint8Array) => { + resolve(structuredClone(output)); + }, + ); + } catch (error) { + reject(error); + } + }, + ); + + // Cache resized PNGs by pixel size to avoid duplicate work + const pngBySize = new Map(); + + // Generate all recommended icon sizes + for (const iconType of sizes) { + const size = ICNS_TYPES[iconType].size; + + let pngData = pngBySize.get(size); + if (!pngData) { + const resizedImg = MagickImage.create( + sourcePng, + new MagickReadSettings({ + format: MagickFormat.Png, + }), + ); + + try { + resizedImg.resize(size, size); + pngData = await magickConvert( + resizedImg, + ".png", + keepMetadata, + compression, + ); + } finally { + resizedImg.dispose(); + } + + pngBySize.set(size, pngData); + } + + iconEntries.push({ + type: iconType, + data: pngData, + }); + } + + // Encode all icons into ICNS format + const icnsData = encodeIcns(iconEntries); + + return { + type: "finished", + output: icnsData, + }; + } catch (error) { + return { + type: "error", + error: `Failed to convert to ICNS -- ${error instanceof Error ? error.message : String(error)}`, + }; + } finally { + sourceImg.dispose(); + } + } + // build frames of animated formats (webp/gif) // APNG does not work on magick-wasm since it needs ffmpeg built-in (not in magick-wasm) - handle in ffmpeg if (