#!/usr/bin/env node // Build the Windows multi-size ICO from the macOS 1024px render, trimming the // transparent Icon Composer "safe-area" inset so the glyph fills the canvas the // way other Windows taskbar/"Open with" icons do. Without this the glyph is only // ~83% of the frame and looks visibly small next to native apps (issue #5357). import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { PNG } from 'pngjs' // Why: Windows renders the largest ICO frame at small sizes too, so the glyph // must fill nearly the whole canvas (~96%) to match native apps. A small uniform // margin keeps anti-aliased edges and the soft shadow from clipping at the border. export const ICON_CANVAS_MARGIN_RATIO = 0.02 // Why: alpha below this is treated as background; the macOS render fades the // safe-area shadow to a few percent opacity, which must not count as glyph. const ALPHA_BACKGROUND_THRESHOLD = 16 // Standard Windows ICO sizes, largest first (matches the prior ImageMagick set). export const ICO_FRAME_SIZES = [256, 128, 64, 48, 32, 16] export function decodePng(buffer) { const png = PNG.sync.read(buffer) return { width: png.width, height: png.height, data: png.data } } export function encodePng({ width, height, data }) { const png = new PNG({ width, height }) data.copy(png.data) return PNG.sync.write(png) } // Tightest box containing every pixel whose alpha exceeds the background // threshold. Returns null when the image is fully transparent. export function findOpaqueBounds({ width, height, data }, threshold = ALPHA_BACKGROUND_THRESHOLD) { let minX = width let minY = height let maxX = -1 let maxY = -1 for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const alpha = data[(y * width + x) * 4 + 3] if (alpha > threshold) { if (x < minX) { minX = x } if (x > maxX) { maxX = x } if (y < minY) { minY = y } if (y > maxY) { maxY = y } } } } if (maxX < 0) { return null } return { minX, minY, maxX, maxY, width: maxX - minX + 1, height: maxY - minY + 1 } } // Crop an RGBA image to the given inclusive bounds. export function cropImage({ width, data }, bounds) { const cropWidth = bounds.width const cropHeight = bounds.height const out = Buffer.alloc(cropWidth * cropHeight * 4) for (let y = 0; y < cropHeight; y++) { const srcRow = (bounds.minY + y) * width + bounds.minX const srcStart = srcRow * 4 out.set(data.subarray(srcStart, srcStart + cropWidth * 4), y * cropWidth * 4) } return { width: cropWidth, height: cropHeight, data: out } } // Center the (already-trimmed) glyph on a transparent square canvas, leaving a // small uniform margin so edges/shadow do not clip. The square side is the // glyph's longer dimension grown by the margin so the glyph keeps its shape. export function squareWithMargin(image, marginRatio = ICON_CANVAS_MARGIN_RATIO) { const longSide = Math.max(image.width, image.height) const side = Math.round(longSide / (1 - 2 * marginRatio)) const data = Buffer.alloc(side * side * 4) const offsetX = Math.floor((side - image.width) / 2) const offsetY = Math.floor((side - image.height) / 2) for (let y = 0; y < image.height; y++) { const srcStart = y * image.width * 4 const dstStart = ((offsetY + y) * side + offsetX) * 4 data.set(image.data.subarray(srcStart, srcStart + image.width * 4), dstStart) } return { width: side, height: side, data } } // Area-averaging (box filter) resize with premultiplied alpha so transparent // edges do not bleed dark fringes. Used to downscale to each ICO frame size. export function resizeImage(image, targetWidth, targetHeight) { const { width: srcW, height: srcH, data: src } = image const out = Buffer.alloc(targetWidth * targetHeight * 4) const scaleX = srcW / targetWidth const scaleY = srcH / targetHeight for (let ty = 0; ty < targetHeight; ty++) { const sy0 = ty * scaleY const sy1 = (ty + 1) * scaleY const y0 = Math.floor(sy0) const y1 = Math.min(srcH, Math.ceil(sy1)) for (let tx = 0; tx < targetWidth; tx++) { const sx0 = tx * scaleX const sx1 = (tx + 1) * scaleX const x0 = Math.floor(sx0) const x1 = Math.min(srcW, Math.ceil(sx1)) let rSum = 0 let gSum = 0 let bSum = 0 let aSum = 0 let weightSum = 0 for (let sy = y0; sy < y1; sy++) { const wy = Math.min(sy1, sy + 1) - Math.max(sy0, sy) for (let sx = x0; sx < x1; sx++) { const wx = Math.min(sx1, sx + 1) - Math.max(sx0, sx) const weight = wx * wy if (weight <= 0) { continue } const idx = (sy * srcW + sx) * 4 const alpha = src[idx + 3] const premul = (alpha / 255) * weight rSum += src[idx] * premul gSum += src[idx + 1] * premul bSum += src[idx + 2] * premul aSum += alpha * weight weightSum += weight } } const dst = (ty * targetWidth + tx) * 4 if (weightSum === 0 || aSum === 0) { out[dst] = 0 out[dst + 1] = 0 out[dst + 2] = 0 out[dst + 3] = 0 continue } const alphaAvg = aSum / weightSum const colorDivisor = alphaAvg / 255 out[dst] = Math.round(rSum / weightSum / colorDivisor) out[dst + 1] = Math.round(gSum / weightSum / colorDivisor) out[dst + 2] = Math.round(bSum / weightSum / colorDivisor) out[dst + 3] = Math.round(alphaAvg) } } return { width: targetWidth, height: targetHeight, data: out } } // Encode a Windows .ico holding PNG-compressed frames (supported since Vista, // the format ImageMagick's auto-resize also emits for the larger sizes). export function encodeIco(frames) { const header = Buffer.alloc(6) header.writeUInt16LE(0, 0) // reserved header.writeUInt16LE(1, 2) // type 1 = icon header.writeUInt16LE(frames.length, 4) const directory = Buffer.alloc(frames.length * 16) let dataOffset = 6 + frames.length * 16 const payloads = [] frames.forEach((frame, index) => { const png = encodePng(frame) payloads.push(png) const entry = index * 16 // Width/height of 256 are stored as 0 per the ICO spec. directory.writeUInt8(frame.width >= 256 ? 0 : frame.width, entry) directory.writeUInt8(frame.height >= 256 ? 0 : frame.height, entry + 1) directory.writeUInt8(0, entry + 2) // color count (0 = truecolor) directory.writeUInt8(0, entry + 3) // reserved directory.writeUInt16LE(1, entry + 4) // color planes directory.writeUInt16LE(32, entry + 6) // bits per pixel directory.writeUInt32LE(png.length, entry + 8) directory.writeUInt32LE(dataOffset, entry + 12) dataOffset += png.length }) return Buffer.concat([header, directory, ...payloads]) } // Trim the safe-area inset from a 1024px source render and produce a filled // multi-size ICO buffer. Exported so the regression test can drive it directly. export function buildWindowsIcoFromPng(sourcePngBuffer, sizes = ICO_FRAME_SIZES) { const source = decodePng(sourcePngBuffer) const bounds = findOpaqueBounds(source) if (!bounds) { throw new Error('Source icon is fully transparent; cannot trim safe-area inset.') } const trimmed = cropImage(source, bounds) const filled = squareWithMargin(trimmed) const frames = sizes.map((size) => resizeImage(filled, size, size)) return encodeIco(frames) } function resolveDefaultPaths() { const scriptDir = import.meta.dirname const projectDir = dirname(dirname(scriptDir)) return { sourcePng: join(projectDir, 'resources', 'build', 'icon.png'), outputIco: join(projectDir, 'resources', 'build', 'icon.ico') } } function main() { const { sourcePng, outputIco } = resolveDefaultPaths() if (!existsSync(sourcePng)) { console.error(`Error: source PNG not found at ${sourcePng}`) process.exit(1) } const ico = buildWindowsIcoFromPng(readFileSync(sourcePng)) writeFileSync(outputIco, ico) console.log(` -> ${outputIco} (filled multi-size ICO, safe-area inset trimmed)`) } if ( import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('trim-windows-icon-source.mjs') ) { main() }