You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lossless-cut/script/generateIcon.ts

98 lines
3.7 KiB
TypeScript

import sharp from 'sharp';
import { writeFile } from 'node:fs/promises';
const renderPng = (from: string, width: number, height: number) => sharp(from)
.png()
.resize(width, height, {
fit: sharp.fit.contain,
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
const svg2png = async (from: string, to: string, width: number, height: number) => (
writeFile(to, await renderPng(from, width, height))
);
const renderSquarePngs = (from: string, sizes: number[]) => Promise.all(sizes.map(async (size) => ({ size, data: await renderPng(from, size, size) })));
// https://en.wikipedia.org/wiki/ICO_(file_format)
// PNG-compressed entries require Windows Vista or newer
function makeIco(pngs: { size: number, data: Buffer }[]) {
const fileHeaderSize = 6;
const dirEntrySize = 16;
const fileHeader = Buffer.alloc(fileHeaderSize);
fileHeader.writeUInt16LE(0, 0); // reserved
fileHeader.writeUInt16LE(1, 2); // image type (1 = icon)
fileHeader.writeUInt16LE(pngs.length, 4);
let imageOffset = fileHeaderSize + dirEntrySize * pngs.length;
const dirEntries = pngs.map(({ size, data }) => {
const entry = Buffer.alloc(dirEntrySize);
entry.writeUInt8(size >= 256 ? 0 : size, 0); // width (0 means 256 or more)
entry.writeUInt8(size >= 256 ? 0 : size, 1); // height
entry.writeUInt8(0, 2); // number of palette colors
entry.writeUInt8(0, 3); // reserved
entry.writeUInt16LE(1, 4); // color planes
entry.writeUInt16LE(32, 6); // bits per pixel
entry.writeUInt32LE(data.length, 8);
entry.writeUInt32LE(imageOffset, 12);
imageOffset += data.length;
return entry;
});
return Buffer.concat([fileHeader, ...dirEntries, ...pngs.map(({ data }) => data)]);
}
// https://en.wikipedia.org/wiki/Apple_Icon_Image_format
// chunk types whose payload is a plain PNG, and the pixel size each represents
const icnsChunkTypes = [
{ type: 'ic07', size: 128 }, // 128x128
{ type: 'ic08', size: 256 }, // 256x256
{ type: 'ic09', size: 512 }, // 512x512
{ type: 'ic10', size: 1024 }, // 1024x1024 (512x512@2x)
{ type: 'ic11', size: 32 }, // 16x16@2x
{ type: 'ic12', size: 64 }, // 32x32@2x
{ type: 'ic13', size: 256 }, // 128x128@2x
{ type: 'ic14', size: 512 }, // 256x256@2x
];
function makeIcns(pngs: { size: number, data: Buffer }[]) {
const chunks = icnsChunkTypes.flatMap(({ type, size }) => {
const png = pngs.find((p) => p.size === size);
if (png == null) return [];
const chunkHeader = Buffer.alloc(8);
chunkHeader.write(type, 0, 'ascii');
chunkHeader.writeUInt32BE(8 + png.data.length, 4);
return [Buffer.concat([chunkHeader, png.data])];
});
const fileHeader = Buffer.alloc(8);
fileHeader.write('icns', 0, 'ascii');
fileHeader.writeUInt32BE(8 + chunks.reduce((total, chunk) => total + chunk.length, 0), 4);
return Buffer.concat([fileHeader, ...chunks]);
}
const srcIcon = 'src/renderer/src/icon.svg';
const srcMacIcon = 'src/renderer/src/icon-mac.svg';
// Linux:
await svg2png(srcIcon, 'icon-build/app-512.png', 512, 512);
// Windows Store
await svg2png(srcIcon, 'build-resources/appx/StoreLogo.png', 50, 50);
await svg2png(srcIcon, 'build-resources/appx/Square150x150Logo.png', 300, 300);
await svg2png(srcIcon, 'build-resources/appx/Square44x44Logo.png', 44, 44);
await svg2png(srcIcon, 'build-resources/appx/Wide310x150Logo.png', 620, 300);
// MacOS:
// https://github.com/mifi/lossless-cut/issues/1820
await writeFile('icon-build/app.icns', makeIcns(await renderSquarePngs(srcMacIcon, [512, 1024])));
// Windows ICO:
// https://github.com/mifi/lossless-cut/issues/778
// https://stackoverflow.com/questions/3236115/which-icon-sizes-should-my-windows-applications-icon-include
await writeFile('icon-build/app.ico', makeIco(await renderSquarePngs(srcIcon, [16, 24, 32, 40, 48, 64, 96, 128, 256, 512])));