mirror of https://github.com/VERT-sh/VERT.git
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:
parent
897ae56702
commit
fa76bf6878
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
];
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in New Issue