diff --git a/config/scripts/trim-windows-icon-source.mjs b/config/scripts/trim-windows-icon-source.mjs new file mode 100644 index 00000000000..90a1914e733 --- /dev/null +++ b/config/scripts/trim-windows-icon-source.mjs @@ -0,0 +1,219 @@ +#!/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 { fileURLToPath } from 'node:url' +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 = dirname(fileURLToPath(import.meta.url)) + 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() +} diff --git a/config/scripts/trim-windows-icon-source.test.mjs b/config/scripts/trim-windows-icon-source.test.mjs new file mode 100644 index 00000000000..a52f0c49f2c --- /dev/null +++ b/config/scripts/trim-windows-icon-source.test.mjs @@ -0,0 +1,114 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { PNG } from 'pngjs' +import { + buildWindowsIcoFromPng, + cropImage, + findOpaqueBounds, + resizeImage, + squareWithMargin +} from './trim-windows-icon-source.mjs' + +const scriptDir = dirname(fileURLToPath(import.meta.url)) +const projectDir = dirname(dirname(scriptDir)) +const buildDir = join(projectDir, 'resources', 'build') + +// Why: Windows scales the largest ICO frame down for the taskbar/"Open with" +// list, so the glyph must fill most of that frame to match native apps. The +// macOS safe-area inset left it at ~83%, which is the visible defect (#5357). +const MIN_GLYPH_FILL_FRACTION = 0.92 + +function largestIcoFrame(icoBuffer) { + const count = icoBuffer.readUInt16LE(4) + let best = null + for (let i = 0; i < count; i++) { + const entry = 6 + i * 16 + let width = icoBuffer.readUInt8(entry) + if (width === 0) { + width = 256 + } + const byteLength = icoBuffer.readUInt32LE(entry + 8) + const imageOffset = icoBuffer.readUInt32LE(entry + 12) + if (!best || width > best.width) { + best = { width, byteLength, imageOffset } + } + } + return best +} + +function decodeIcoFrame(icoBuffer, frame) { + const payload = icoBuffer.subarray(frame.imageOffset, frame.imageOffset + frame.byteLength) + const isPng = + payload[0] === 0x89 && payload[1] === 0x50 && payload[2] === 0x4e && payload[3] === 0x47 + if (!isPng) { + throw new Error('Expected PNG-compressed ICO frame') + } + const png = PNG.sync.read(Buffer.from(payload)) + return { width: png.width, height: png.height, data: png.data } +} + +function glyphFillFraction(image) { + const bounds = findOpaqueBounds(image) + if (!bounds) { + return 0 + } + return Math.max(bounds.width, bounds.height) / Math.max(image.width, image.height) +} + +describe('Windows ICO glyph fill', () => { + it('committed resources/build/icon.ico fills the canvas', () => { + const ico = readFileSync(join(buildDir, 'icon.ico')) + const frame = decodeIcoFrame(ico, largestIcoFrame(ico)) + expect(glyphFillFraction(frame)).toBeGreaterThanOrEqual(MIN_GLYPH_FILL_FRACTION) + }) + + it('regenerating from the macOS render fills the canvas', () => { + const sourcePng = readFileSync(join(buildDir, 'icon.png')) + const ico = buildWindowsIcoFromPng(sourcePng) + const frame = decodeIcoFrame(ico, largestIcoFrame(ico)) + expect(glyphFillFraction(frame)).toBeGreaterThanOrEqual(MIN_GLYPH_FILL_FRACTION) + }) +}) + +describe('trim pipeline', () => { + function solidSquareWithInset(canvas, glyph) { + const data = Buffer.alloc(canvas * canvas * 4) + const offset = Math.floor((canvas - glyph) / 2) + for (let y = 0; y < glyph; y++) { + for (let x = 0; x < glyph; x++) { + const idx = ((offset + y) * canvas + (offset + x)) * 4 + data[idx] = 200 + data[idx + 1] = 100 + data[idx + 2] = 50 + data[idx + 3] = 255 + } + } + return { width: canvas, height: canvas, data } + } + + it('finds the opaque glyph bounds and ignores transparent inset', () => { + const image = solidSquareWithInset(100, 60) + const bounds = findOpaqueBounds(image) + expect(bounds).toEqual({ minX: 20, minY: 20, maxX: 79, maxY: 79, width: 60, height: 60 }) + }) + + it('crops to the glyph then re-squares with a small margin', () => { + const image = solidSquareWithInset(100, 60) + const cropped = cropImage(image, findOpaqueBounds(image)) + expect(cropped.width).toBe(60) + const squared = squareWithMargin(cropped, 0.04) + // Glyph (60) plus ~4% margin per side keeps it near-full-bleed. + expect(glyphFillFraction(squared)).toBeGreaterThanOrEqual(0.9) + expect(squared.width).toBe(squared.height) + }) + + it('downscales without leaving the glyph undersized', () => { + const image = solidSquareWithInset(256, 200) + const filled = squareWithMargin(cropImage(image, findOpaqueBounds(image))) + const small = resizeImage(filled, 32, 32) + expect(small.width).toBe(32) + expect(glyphFillFraction(small)).toBeGreaterThanOrEqual(0.9) + }) +}) diff --git a/resources/build/icon.ico b/resources/build/icon.ico index 88093983a0d..d73c40f4af3 100644 Binary files a/resources/build/icon.ico and b/resources/build/icon.ico differ diff --git a/resources/icon-source/generate.sh b/resources/icon-source/generate.sh index d8377345a51..3ea4d72c78e 100755 --- a/resources/icon-source/generate.sh +++ b/resources/icon-source/generate.sh @@ -70,8 +70,14 @@ echo " -> resources/build/icon.png (1024x1024)" sips -s format png --resampleWidth 256 "$TMP_DIR/icon.icns" --out "$RESOURCES_DIR/icon.png" >/dev/null 2>&1 echo " -> resources/icon.png (256x256)" -# Generate .ico for Windows (proper ICO format with multiple sizes) -"$MAGICK_BIN" "$BUILD_DIR/icon.png" -define icon:auto-resize=256,128,64,48,32,16 "$BUILD_DIR/icon.ico" -echo " -> resources/build/icon.ico (multi-size ICO via ImageMagick)" +# Generate .ico for Windows. Icon Composer keeps the macOS safe-area inset in +# the 1024px render, but Windows scales the largest ICO frame down for the +# taskbar/"Open with" list without compensating, so the glyph looks small next +# to native apps (issue #5357). Delegate to the pngjs trim script so the +# committed ICO always matches it: it trims the transparent inset, re-squares +# with a small 2% margin, and emits the filled multi-size ICO. Node + pngjs are +# already repo dependencies, so this also works where ImageMagick is unavailable. +node "$PROJECT_DIR/config/scripts/trim-windows-icon-source.mjs" +echo " -> resources/build/icon.ico (trimmed, filled multi-size ICO)" echo "Done! Icons generated in resources/build/ and resources/"