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.
This commit is contained in:
Deniz ŞAHİN 2026-02-10 10:34:05 +03:00
parent 897ae56702
commit fa76bf6878
3 changed files with 201 additions and 1 deletions

View File

@ -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),

View File

@ -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)
];
}

View File

@ -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<Uint8Array>(
(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<number, Uint8Array>();
// 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 (