mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(windows): trim macOS safe-area inset so app icon fills the canvas (#6548)
The Windows ICO was rasterized straight from the untrimmed 1024px macOS render, inheriting Icon Composer's transparent safe-area inset (glyph only ~83% of the canvas). Windows scales the largest ICO frame down for the taskbar/"Open with" list without compensating, so the icon looked visibly small next to native apps. Add config/scripts/trim-windows-icon-source.mjs (pngjs-based): trims the transparent inset, re-squares with a small 2% margin, and emits a filled multi-size ICO (~96% fill). Regenerate resources/build/icon.ico from it and update generate.sh to delegate to the script so the committed binary stays in sync regardless of whether ImageMagick is installed. Fixes #5357 Co-authored-by: brennanb2025 <brennanb2025@users.noreply.github.com>
This commit is contained in:
co-authored by
brennanb2025
parent
3d22d69d03
commit
74d564a67e
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 57 KiB |
@@ -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/"
|
||||
|
||||
Reference in New Issue
Block a user