diff --git a/BUILD.md b/BUILD.md index b8a4e60..043808f 100644 --- a/BUILD.md +++ b/BUILD.md @@ -66,6 +66,19 @@ npm run build 中间构建输出位于 `out`。该目录为生成内容,应修改源文件后重新构建,不要直接编辑。 +## 图标生成 + +应用图标源文件位于 `icons`。修改源图或图标处理逻辑后运行: + +```bash +npm run icons +``` + +脚本会精确裁出亮色和深色圆角卡片,清理圆角外侧背景并使用适合主题的 +边缘颜色生成透明抗锯齿,避免缩放后出现白边。它会统一更新 `build` 中的 +PNG / ICO、Renderer 图标,以及官网使用的亮色和深色品牌图标。任务栏和 +托盘图标保持透明背景。 + ## 平台打包 ### 当前平台默认包 diff --git a/build/generate-icons.mjs b/build/generate-icons.mjs index 4673200..75e2f47 100644 --- a/build/generate-icons.mjs +++ b/build/generate-icons.mjs @@ -17,6 +17,25 @@ const darkSourcePath = join( 'ChatGPT_qPkaIrLGsm.png' ) const rendererAssetRoot = join(root, 'src', 'renderer', 'src', 'assets') +const websiteAssetRoot = join(root, 'sites', 'assets') + +const lightTile = { + left: 40, + top: 32, + right: 704, + bottom: 696, + radius: 126, + edgeColor: [255, 255, 255] +} + +const darkTile = { + left: 20, + top: 16, + right: 684, + bottom: 680, + radius: 126, + edgeColor: [15, 21, 31] +} function cropSquare(source, size) { const output = new PNG({ width: size, height: size }) @@ -24,6 +43,102 @@ function cropSquare(source, size) { return output } +function scaleTile(tile, sourceSize, targetSize) { + const scale = targetSize / sourceSize + return { + left: tile.left * scale, + top: tile.top * scale, + right: tile.right * scale, + bottom: tile.bottom * scale, + radius: tile.radius * scale, + edgeColor: tile.edgeColor + } +} + +function cropTile(source, tile) { + const width = Math.round(tile.right - tile.left) + const height = Math.round(tile.bottom - tile.top) + if (width !== height || width <= 0) { + throw new Error('图标卡片裁剪区域必须是有效正方形') + } + const output = new PNG({ width, height }) + PNG.bitblt( + source, + output, + Math.round(tile.left), + Math.round(tile.top), + width, + height, + 0, + 0 + ) + return { + image: output, + tile: { + left: 0, + top: 0, + right: width, + bottom: height, + radius: tile.radius, + edgeColor: tile.edgeColor + } + } +} + +function roundedRectangleDistance(x, y, tile) { + const centerX = (tile.left + tile.right) / 2 + const centerY = (tile.top + tile.bottom) / 2 + const halfWidth = (tile.right - tile.left) / 2 + const halfHeight = (tile.bottom - tile.top) / 2 + const offsetX = Math.abs(x - centerX) - (halfWidth - tile.radius) + const offsetY = Math.abs(y - centerY) - (halfHeight - tile.radius) + return ( + Math.hypot(Math.max(offsetX, 0), Math.max(offsetY, 0)) + + Math.min(Math.max(offsetX, offsetY), 0) - + tile.radius + ) +} + +function applyRoundedTransparency(image, tile) { + for (let y = 0; y < image.height; y += 1) { + for (let x = 0; x < image.width; x += 1) { + const offset = pixelOffset(image, x, y) + const distance = roundedRectangleDistance(x + 0.5, y + 0.5, tile) + const coverage = Math.min(Math.max(0.5 - distance, 0), 1) + if (coverage >= 1) { + if (distance > -2 && image.data[offset + 3] < 255) { + image.data[offset] = tile.edgeColor[0] + image.data[offset + 1] = tile.edgeColor[1] + image.data[offset + 2] = tile.edgeColor[2] + } + continue + } + image.data[offset] = tile.edgeColor[0] + image.data[offset + 1] = tile.edgeColor[1] + image.data[offset + 2] = tile.edgeColor[2] + image.data[offset + 3] = Math.round( + image.data[offset + 3] * coverage + ) + } + } +} + +function createRoundedIcon(source, tile, size) { + const cropped = cropTile(source, tile) + applyRoundedTransparency(cropped.image, cropped.tile) + const output = resize( + cropped.image, + size, + size, + 'bicubicInterpolation' + ) + applyRoundedTransparency( + output, + scaleTile(cropped.tile, cropped.image.width, size) + ) + return output +} + function pixelOffset(image, x, y) { return (y * image.width + x) * 4 } @@ -119,6 +234,51 @@ function assertTaskbarIcon(image) { } } +function assertTransparentCorners(image, label) { + const corners = [ + pixelOffset(image, 0, 0), + pixelOffset(image, image.width - 1, 0), + pixelOffset(image, 0, image.height - 1), + pixelOffset(image, image.width - 1, image.height - 1) + ] + if (corners.some((offset) => image.data[offset + 3] !== 0)) { + throw new Error(`${label} 的圆角外侧必须透明`) + } +} + +function assertDarkEdgeHasNoWhiteFringe(image) { + const edgeWidth = Math.max(4, Math.round(image.width * 0.08)) + for (let y = 0; y < image.height; y += 1) { + for (let x = 0; x < image.width; x += 1) { + if ( + x >= edgeWidth && + x < image.width - edgeWidth && + y >= edgeWidth && + y < image.height - edgeWidth + ) { + continue + } + const offset = pixelOffset(image, x, y) + const alpha = image.data[offset + 3] + if ( + alpha > 0 && + alpha < 255 && + Math.max( + image.data[offset], + image.data[offset + 1], + image.data[offset + 2] + ) > 96 + ) { + throw new Error( + `暗色图标的透明边缘仍包含白色像素:${x},${y} ` + + `rgba(${image.data[offset]},${image.data[offset + 1]},` + + `${image.data[offset + 2]},${alpha})` + ) + } + } + } +} + function repairDarkCursor(dark, light) { for (let y = 570; y <= 606; y += 1) { const backgroundOffset = pixelOffset(dark, 640, y) @@ -197,22 +357,31 @@ async function main() { repairDarkCursor(darkSquare, lightSquare) assertCursorRemoved(lightSquare, darkSquare) - const light = resize(lightSquare, 512, 512, 'bicubicInterpolation') - const dark = resize(darkSquare, 512, 512, 'bicubicInterpolation') const taskbar = createTaskbarIcon(lightSquare) + const light = createRoundedIcon(lightSquare, lightTile, 512) + const dark = createRoundedIcon(darkSquare, darkTile, 512) + const rendererLight = createRoundedIcon(lightSquare, lightTile, 128) + const rendererDark = createRoundedIcon(darkSquare, darkTile, 128) assertTaskbarIcon(taskbar) + assertTransparentCorners(light, '亮色图标') + assertTransparentCorners(dark, '暗色图标') + assertTransparentCorners(rendererLight, '亮色界面图标') + assertTransparentCorners(rendererDark, '暗色界面图标') + assertTransparentCorners(taskbar, '任务栏图标') + assertDarkEdgeHasNoWhiteFringe(dark) + assertDarkEdgeHasNoWhiteFringe(rendererDark) const tray = resize(taskbar, 32, 32, 'bicubicInterpolation') + assertTransparentCorners(tray, '托盘图标') const lightPng = PNG.sync.write(light) const darkPng = PNG.sync.write(dark) const taskbarPng = PNG.sync.write(taskbar) const trayPng = PNG.sync.write(tray) - const rendererLightPng = PNG.sync.write( - resize(light, 128, 128, 'bicubicInterpolation') - ) - const rendererDarkPng = PNG.sync.write( - resize(dark, 128, 128, 'bicubicInterpolation') - ) - await mkdir(rendererAssetRoot, { recursive: true }) + const rendererLightPng = PNG.sync.write(rendererLight) + const rendererDarkPng = PNG.sync.write(rendererDark) + await Promise.all([ + mkdir(rendererAssetRoot, { recursive: true }), + mkdir(websiteAssetRoot, { recursive: true }) + ]) const outputs = [ [join(root, 'build', 'icon-light.png'), lightPng], @@ -221,7 +390,9 @@ async function main() { [join(root, 'build', 'icon-taskbar.png'), taskbarPng], [join(root, 'build', 'icon-tray.png'), trayPng], [join(rendererAssetRoot, 'goodbuddy-light.png'), rendererLightPng], - [join(rendererAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng] + [join(rendererAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng], + [join(websiteAssetRoot, 'goodbuddy-light.png'), rendererLightPng], + [join(websiteAssetRoot, 'goodbuddy-dark.png'), rendererDarkPng] ] await Promise.all(outputs.map(([path, contents]) => writeFile(path, contents))) diff --git a/build/icon-dark.ico b/build/icon-dark.ico index 2c4958f..2156750 100644 Binary files a/build/icon-dark.ico and b/build/icon-dark.ico differ diff --git a/build/icon-dark.png b/build/icon-dark.png index 2940a50..3cc86f6 100644 Binary files a/build/icon-dark.png and b/build/icon-dark.png differ diff --git a/build/icon-light.ico b/build/icon-light.ico index b92eb96..d6a3632 100644 Binary files a/build/icon-light.ico and b/build/icon-light.ico differ diff --git a/build/icon-light.png b/build/icon-light.png index ea4f3d5..218221a 100644 Binary files a/build/icon-light.png and b/build/icon-light.png differ diff --git a/build/icon.ico b/build/icon.ico index b92eb96..d6a3632 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png index ea4f3d5..218221a 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/sites/README.md b/sites/README.md index 252a325..62029ab 100644 --- a/sites/README.md +++ b/sites/README.md @@ -59,5 +59,5 @@ SHA-256 清单。 - `index.html`:页面结构与简体中文内容 - `styles.css`:语义令牌、浅深主题、焦点与响应式布局 - `app.js`:主题、移动导航和当前章节 -- `assets/favicon.svg`:站点图标 +- `assets/goodbuddy-light.png`、`assets/goodbuddy-dark.png`:由 `npm run icons` 与桌面应用同步生成的官方品牌图标 - `scripts/validate.mjs`:无依赖静态检查 diff --git a/sites/assets/favicon.svg b/sites/assets/favicon.svg deleted file mode 100644 index 4c1c657..0000000 --- a/sites/assets/favicon.svg +++ /dev/null @@ -1,12 +0,0 @@ - diff --git a/sites/assets/goodbuddy-dark.png b/sites/assets/goodbuddy-dark.png new file mode 100644 index 0000000..8fcd697 Binary files /dev/null and b/sites/assets/goodbuddy-dark.png differ diff --git a/sites/assets/goodbuddy-light.png b/sites/assets/goodbuddy-light.png new file mode 100644 index 0000000..ec5840f Binary files /dev/null and b/sites/assets/goodbuddy-light.png differ diff --git a/sites/index.html b/sites/index.html index a742c41..88876fb 100644 --- a/sites/index.html +++ b/sites/index.html @@ -10,7 +10,18 @@