import { Buffer } from "node:buffer"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { deflateSync, inflateSync } from "node:zlib"; const root = dirname(dirname(fileURLToPath(import.meta.url))); const iconsDir = join(root, "src-tauri", "icons"); const sourcePath = join(iconsDir, "GitCat_org.png"); const pngSizes = [32, 64, 128, 512]; const icoSizes = [16, 24, 32, 48, 64, 128, 256]; function decodePng(buffer) { if (buffer.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") { throw new Error("Source icon must be a PNG file."); } let width = 0; let height = 0; const idatChunks = []; for (let offset = 8; offset < buffer.length; ) { const length = buffer.readUInt32BE(offset); const type = buffer.subarray(offset + 4, offset + 8).toString("ascii"); const dataStart = offset + 8; const dataEnd = dataStart + length; const data = buffer.subarray(dataStart, dataEnd); if (type === "IHDR") { width = data.readUInt32BE(0); height = data.readUInt32BE(4); if (data[8] !== 8 || data[9] !== 6) { throw new Error("Source icon must be an 8-bit RGBA PNG."); } } else if (type === "IDAT") { idatChunks.push(data); } else if (type === "IEND") { break; } offset = dataEnd + 4; } const stride = width * 4; const raw = inflateSync(Buffer.concat(idatChunks)); const rgba = Buffer.alloc(width * height * 4); for (let y = 0; y < height; y += 1) { const rowStart = y * (stride + 1); const filter = raw[rowStart]; const input = raw.subarray(rowStart + 1, rowStart + 1 + stride); const priorOffset = (y - 1) * stride; const outputOffset = y * stride; for (let x = 0; x < stride; x += 1) { const left = x >= 4 ? rgba[outputOffset + x - 4] : 0; const up = y > 0 ? rgba[priorOffset + x] : 0; const upLeft = y > 0 && x >= 4 ? rgba[priorOffset + x - 4] : 0; let value = input[x]; if (filter === 1) value += left; else if (filter === 2) value += up; else if (filter === 3) value += Math.floor((left + up) / 2); else if (filter === 4) value += paeth(left, up, upLeft); else if (filter !== 0) throw new Error(`Unsupported PNG filter: ${filter}`); rgba[outputOffset + x] = value & 0xff; } } return { width, height, rgba }; } function paeth(left, up, upLeft) { const estimate = left + up - upLeft; const distanceLeft = Math.abs(estimate - left); const distanceUp = Math.abs(estimate - up); const distanceUpLeft = Math.abs(estimate - upLeft); if (distanceLeft <= distanceUp && distanceLeft <= distanceUpLeft) return left; if (distanceUp <= distanceUpLeft) return up; return upLeft; } function visibleSquare(image, alphaThreshold = 8) { let minX = image.width; let minY = image.height; let maxX = -1; let maxY = -1; for (let y = 0; y < image.height; y += 1) { for (let x = 0; x < image.width; x += 1) { const alpha = image.rgba[(y * image.width + x) * 4 + 3]; if (alpha > alphaThreshold) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } } } if (maxX < 0 || maxY < 0) { throw new Error("Source icon has no visible pixels."); } const visibleWidth = maxX - minX + 1; const visibleHeight = maxY - minY + 1; const side = Math.max(visibleWidth, visibleHeight); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const left = Math.max(0, Math.min(image.width - side, Math.round(centerX - side / 2))); const top = Math.max(0, Math.min(image.height - side, Math.round(centerY - side / 2))); return { left, top, side }; } function resizePng(image, crop, size) { const rgba = Buffer.alloc(size * size * 4); const scale = crop.side / size; for (let y = 0; y < size; y += 1) { for (let x = 0; x < size; x += 1) { const sourceX = crop.left + (x + 0.5) * scale - 0.5; const sourceY = crop.top + (y + 0.5) * scale - 0.5; const pixel = sampleBilinear(image, sourceX, sourceY); const offset = (y * size + x) * 4; rgba[offset] = pixel.r; rgba[offset + 1] = pixel.g; rgba[offset + 2] = pixel.b; rgba[offset + 3] = pixel.a; } } return encodePng(size, size, rgba); } function sampleBilinear(image, x, y) { const x0 = Math.max(0, Math.min(image.width - 1, Math.floor(x))); const y0 = Math.max(0, Math.min(image.height - 1, Math.floor(y))); const x1 = Math.max(0, Math.min(image.width - 1, x0 + 1)); const y1 = Math.max(0, Math.min(image.height - 1, y0 + 1)); const tx = x - x0; const ty = y - y0; const top = mixPixels(getPixel(image, x0, y0), getPixel(image, x1, y0), tx); const bottom = mixPixels(getPixel(image, x0, y1), getPixel(image, x1, y1), tx); return mixPixels(top, bottom, ty); } function getPixel(image, x, y) { const offset = (y * image.width + x) * 4; return { r: image.rgba[offset], g: image.rgba[offset + 1], b: image.rgba[offset + 2], a: image.rgba[offset + 3], }; } function mixPixels(a, b, t) { return { r: Math.round(a.r + (b.r - a.r) * t), g: Math.round(a.g + (b.g - a.g) * t), b: Math.round(a.b + (b.b - a.b) * t), a: Math.round(a.a + (b.a - a.a) * t), }; } const crcTable = new Uint32Array(256); for (let n = 0; n < 256; n += 1) { let c = n; for (let k = 0; k < 8; k += 1) { c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } crcTable[n] = c >>> 0; } function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) { crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } function chunk(type, payload) { const typeBytes = Buffer.from(type); const out = Buffer.alloc(12 + payload.length); out.writeUInt32BE(payload.length, 0); typeBytes.copy(out, 4); payload.copy(out, 8); out.writeUInt32BE(crc32(Buffer.concat([typeBytes, payload])), out.length - 4); return out; } function encodePng(width, height, rgba) { const header = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6; const raw = Buffer.alloc((width * 4 + 1) * height); for (let y = 0; y < height; y += 1) { const rowStart = y * (width * 4 + 1); raw[rowStart] = 0; rgba.copy(raw, rowStart + 1, y * width * 4, (y + 1) * width * 4); } return Buffer.concat([ header, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0)), ]); } function makeIco(images) { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(images.length, 4); const entries = Buffer.alloc(images.length * 16); let offset = 6 + images.length * 16; images.forEach((image, index) => { const entry = index * 16; entries[entry] = image.size >= 256 ? 0 : image.size; entries[entry + 1] = image.size >= 256 ? 0 : image.size; entries[entry + 2] = 0; entries[entry + 3] = 0; entries.writeUInt16LE(1, entry + 4); entries.writeUInt16LE(32, entry + 6); entries.writeUInt32LE(image.png.length, entry + 8); entries.writeUInt32LE(offset, entry + 12); offset += image.png.length; }); return Buffer.concat([header, entries, ...images.map((image) => image.png)]); } mkdirSync(iconsDir, { recursive: true }); const source = decodePng(readFileSync(sourcePath)); const crop = visibleSquare(source); const rendered = new Map( [...new Set([...pngSizes, ...icoSizes])].map((size) => [size, resizePng(source, crop, size)]), ); writeFileSync(join(iconsDir, "32x32.png"), rendered.get(32)); writeFileSync(join(iconsDir, "64x64.png"), rendered.get(64)); writeFileSync(join(iconsDir, "128x128.png"), rendered.get(128)); writeFileSync(join(iconsDir, "icon.png"), rendered.get(512)); writeFileSync(join(iconsDir, "icon.ico"), makeIco(icoSizes.map((size) => ({ size, png: rendered.get(size) })))); console.log(`Read ${join("src-tauri", "icons", "GitCat_org.png")}`); console.log(`Cropped visible icon area to ${crop.side}x${crop.side} at ${crop.left},${crop.top}`); console.log(`Wrote ${join("src-tauri", "icons", "32x32.png")}`); console.log(`Wrote ${join("src-tauri", "icons", "64x64.png")}`); console.log(`Wrote ${join("src-tauri", "icons", "128x128.png")}`); console.log(`Wrote ${join("src-tauri", "icons", "icon.png")}`); console.log(`Wrote ${join("src-tauri", "icons", "icon.ico")} (${icoSizes.length} sizes)`);