Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/converters/magick.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
111 changes: 111 additions & 0 deletions src/lib/util/icns-encoder.ts
Original file line number Diff line number Diff line change
@@ -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)
];
}
89 changes: 89 additions & 0 deletions src/lib/workers/magick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 (
Expand Down