@istic-co/annealer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/action.yml +51 -0
- package/bin/cli.js +47 -0
- package/bin/cli.test.js +55 -0
- package/package.json +33 -0
- package/src/colors.js +71 -0
- package/src/colors.test.js +40 -0
- package/src/generate-apple-touch-icon.js +194 -0
- package/src/generate-apple-touch-icon.test.js +57 -0
- package/src/generate-web-icons.js +61 -0
- package/src/generate-web-icons.test.js +94 -0
- package/src/index.js +5 -0
- package/src/pack-ico.js +48 -0
- package/src/pack-ico.test.js +58 -0
- package/src/squircle.js +19 -0
- package/src/squircle.test.js +17 -0
- package/src/test-fixtures/glyph.svg +4 -0
- package/src/test-fixtures/sample.icon/Assets/glyph.svg +4 -0
- package/src/test-fixtures/sample.icon/icon.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aquarion
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Annealer
|
|
2
|
+
|
|
3
|
+
Render an Apple Icon Composer bundle (`.icon`) and its companion web
|
|
4
|
+
favicons in plain Node.js — no Xcode, no `actool`, no macOS runner.
|
|
5
|
+
|
|
6
|
+
Annealer is a from-scratch reimplementation of Icon Composer's rendering
|
|
7
|
+
(squircle masking, gradient compensation, Display P3 color handling, and
|
|
8
|
+
iOS 26 "liquid glass" specular effects), reverse-engineered empirically
|
|
9
|
+
against Icon Composer's actual output. It ships as an npm package, a CLI,
|
|
10
|
+
and a GitHub Action.
|
|
11
|
+
|
|
12
|
+
## Supported icon shape
|
|
13
|
+
|
|
14
|
+
Annealer currently supports exactly one Icon Composer shape:
|
|
15
|
+
|
|
16
|
+
- A single `fill['automatic-gradient']` value (no `flat-color` fill, no
|
|
17
|
+
multi-stop fills).
|
|
18
|
+
- Exactly one layer group, with `glass: true` on its layer (no
|
|
19
|
+
`glass: false` rendering path).
|
|
20
|
+
- A glyph SVG with exactly one `<path d="...">` element (no multi-path or
|
|
21
|
+
multi-group glyphs).
|
|
22
|
+
|
|
23
|
+
Icons outside this shape will render incorrectly rather than fail loudly.
|
|
24
|
+
See the [issue tracker](https://github.com/istic/annealer/issues) for
|
|
25
|
+
tracked gaps, and feel free to open a PR to extend support.
|
|
26
|
+
|
|
27
|
+
The web-icon filenames (`bloom-standard.svg`, `bloom-standard.png`,
|
|
28
|
+
`bloom-on-white.png`) are currently hardcoded from Annealer's origin
|
|
29
|
+
project and not yet configurable — also tracked in the issue tracker.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### As a GitHub Action
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
- uses: istic/annealer@v1
|
|
37
|
+
with:
|
|
38
|
+
icon-path: resources/branding/my-app.icon
|
|
39
|
+
glyph: resources/branding/glyph.svg
|
|
40
|
+
background-color: '#6A2AAC'
|
|
41
|
+
output-dir: resources/icons
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### As a CLI
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npx @istic-co/annealer \
|
|
48
|
+
--icon-path resources/branding/my-app.icon \
|
|
49
|
+
--glyph resources/branding/glyph.svg \
|
|
50
|
+
--background-color '#6A2AAC' \
|
|
51
|
+
--output-dir resources/icons
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### As an npm package
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { generateAppleTouchIcon, generateWebIcons } from '@istic-co/annealer';
|
|
58
|
+
|
|
59
|
+
const config = {
|
|
60
|
+
iconPath: 'resources/branding/my-app.icon',
|
|
61
|
+
glyph: 'resources/branding/glyph.svg',
|
|
62
|
+
backgroundColor: '#6A2AAC',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await generateAppleTouchIcon(config, 'resources/icons');
|
|
66
|
+
await generateWebIcons(config, 'resources/icons');
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npm install
|
|
73
|
+
npm test
|
|
74
|
+
```
|
package/action.yml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
name: 'Annealer'
|
|
2
|
+
description: 'Render Apple Icon Composer bundles and web favicons in plain Node.js — no Xcode required.'
|
|
3
|
+
branding:
|
|
4
|
+
icon: 'image'
|
|
5
|
+
color: 'purple'
|
|
6
|
+
inputs:
|
|
7
|
+
icon-path:
|
|
8
|
+
description: 'Path to the Apple Icon Composer (.icon) bundle. Required for the apple/all targets.'
|
|
9
|
+
required: false
|
|
10
|
+
default: ''
|
|
11
|
+
glyph:
|
|
12
|
+
description: 'Path to the glyph SVG used for web icon rendering. Required for the web/all targets.'
|
|
13
|
+
required: false
|
|
14
|
+
default: ''
|
|
15
|
+
background-color:
|
|
16
|
+
description: 'Background color as a hex string, e.g. #6A2AAC.'
|
|
17
|
+
required: true
|
|
18
|
+
output-dir:
|
|
19
|
+
description: 'Directory to write generated icons into.'
|
|
20
|
+
required: false
|
|
21
|
+
default: 'resources/icons'
|
|
22
|
+
target:
|
|
23
|
+
description: 'Which icons to generate: apple, web, or all.'
|
|
24
|
+
required: false
|
|
25
|
+
default: 'all'
|
|
26
|
+
runs:
|
|
27
|
+
using: 'composite'
|
|
28
|
+
steps:
|
|
29
|
+
- name: Install Node.js
|
|
30
|
+
uses: actions/setup-node@v4
|
|
31
|
+
with:
|
|
32
|
+
node-version: '22'
|
|
33
|
+
- name: Install Annealer's dependencies
|
|
34
|
+
shell: bash
|
|
35
|
+
run: npm ci --prefix "${{ github.action_path }}"
|
|
36
|
+
- name: Generate icons
|
|
37
|
+
shell: bash
|
|
38
|
+
env:
|
|
39
|
+
ANNEALER_ICON_PATH: ${{ inputs.icon-path }}
|
|
40
|
+
ANNEALER_GLYPH: ${{ inputs.glyph }}
|
|
41
|
+
ANNEALER_BACKGROUND_COLOR: ${{ inputs.background-color }}
|
|
42
|
+
ANNEALER_OUTPUT_DIR: ${{ inputs.output-dir }}
|
|
43
|
+
ANNEALER_TARGET: ${{ inputs.target }}
|
|
44
|
+
ANNEALER_ACTION_PATH: ${{ github.action_path }}
|
|
45
|
+
run: |
|
|
46
|
+
node "$ANNEALER_ACTION_PATH/bin/cli.js" \
|
|
47
|
+
--icon-path="$ANNEALER_ICON_PATH" \
|
|
48
|
+
--glyph="$ANNEALER_GLYPH" \
|
|
49
|
+
--background-color="$ANNEALER_BACKGROUND_COLOR" \
|
|
50
|
+
--output-dir="$ANNEALER_OUTPUT_DIR" \
|
|
51
|
+
--target="$ANNEALER_TARGET"
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* global process */
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { generateAppleTouchIcon, generateWebIcons } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
const { values } = parseArgs({
|
|
7
|
+
options: {
|
|
8
|
+
'icon-path': { type: 'string', default: '' },
|
|
9
|
+
glyph: { type: 'string', default: '' },
|
|
10
|
+
'background-color': { type: 'string', default: '' },
|
|
11
|
+
'output-dir': { type: 'string', default: 'resources/icons' },
|
|
12
|
+
target: { type: 'string', default: 'all' },
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export async function main() {
|
|
17
|
+
if (!values['background-color']) {
|
|
18
|
+
throw new Error('--background-color is required');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const config = {
|
|
22
|
+
backgroundColor: values['background-color'],
|
|
23
|
+
iconPath: values['icon-path'],
|
|
24
|
+
glyph: values.glyph,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
if (values.target === 'apple' || values.target === 'all') {
|
|
28
|
+
if (!config.iconPath) {
|
|
29
|
+
throw new Error('--icon-path is required for the apple target');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await generateAppleTouchIcon(config, values['output-dir']);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (values.target === 'web' || values.target === 'all') {
|
|
36
|
+
if (!config.glyph) {
|
|
37
|
+
throw new Error('--glyph is required for the web target');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await generateWebIcons(config, values['output-dir']);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
main().catch((error) => {
|
|
45
|
+
console.error(error.message);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
});
|
package/bin/cli.test.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const CLI_PATH = path.join(import.meta.dirname, 'cli.js');
|
|
10
|
+
const FIXTURE_ICON_DIR = path.join(import.meta.dirname, '..', 'src', 'test-fixtures', 'sample.icon');
|
|
11
|
+
const FIXTURE_GLYPH = path.join(import.meta.dirname, '..', 'src', 'test-fixtures', 'glyph.svg');
|
|
12
|
+
|
|
13
|
+
let outputDir;
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
if (outputDir) {
|
|
17
|
+
await fs.rm(outputDir, { recursive: true, force: true });
|
|
18
|
+
outputDir = undefined;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('cli', () => {
|
|
23
|
+
it(
|
|
24
|
+
'generates both apple and web icons for target=all',
|
|
25
|
+
async () => {
|
|
26
|
+
outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-cli-'));
|
|
27
|
+
|
|
28
|
+
await execFileAsync('node', [
|
|
29
|
+
CLI_PATH,
|
|
30
|
+
'--icon-path', FIXTURE_ICON_DIR,
|
|
31
|
+
'--glyph', FIXTURE_GLYPH,
|
|
32
|
+
'--background-color', '#6A2AAC',
|
|
33
|
+
'--output-dir', outputDir,
|
|
34
|
+
'--target', 'all',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const files = await fs.readdir(outputDir);
|
|
38
|
+
|
|
39
|
+
expect(files).toContain('apple-touch-icon.png');
|
|
40
|
+
expect(files).toContain('favicon.ico');
|
|
41
|
+
},
|
|
42
|
+
20000,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
it('exits non-zero with a clear message when required flags are missing', async () => {
|
|
46
|
+
outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-cli-'));
|
|
47
|
+
|
|
48
|
+
await expect(
|
|
49
|
+
execFileAsync('node', [CLI_PATH, '--output-dir', outputDir, '--target', 'apple']),
|
|
50
|
+
).rejects.toMatchObject({
|
|
51
|
+
code: 1,
|
|
52
|
+
stderr: expect.stringContaining('background-color is required'),
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@istic-co/annealer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Render Apple Icon Composer bundles and web favicons in plain Node.js — no Xcode required.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Aquarion",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/istic/annealer.git"
|
|
11
|
+
},
|
|
12
|
+
"main": "src/index.js",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.js"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"annealer": "bin/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"bin",
|
|
22
|
+
"action.yml"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"sharp": "^0.35.3"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"vitest": "^4.1.10"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/colors.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// D65 matrices, ported from regen-icons.sh's hex_to_display_p3 Python heredoc.
|
|
2
|
+
const SRGB_TO_XYZ = [
|
|
3
|
+
[0.4124564, 0.3575761, 0.1804375],
|
|
4
|
+
[0.2126729, 0.7151522, 0.072175],
|
|
5
|
+
[0.0193339, 0.119192, 0.9503041],
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
const XYZ_TO_P3 = [
|
|
9
|
+
[2.4934969, -0.9313836, -0.4027108],
|
|
10
|
+
[-0.829489, 1.762664, 0.0236247],
|
|
11
|
+
[0.0358458, -0.0761724, 0.9568845],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
// Empirical per-channel darkening observed in apple-touch-icon output vs the
|
|
15
|
+
// requested background color, ported from regen-icons.sh's DARKEN constant.
|
|
16
|
+
const APPLE_RENDER_DARKEN = [
|
|
17
|
+
0.6022727272727273, 0.4375, 0.7962962962962963,
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function multiply(matrix, vector) {
|
|
21
|
+
return matrix.map((row) =>
|
|
22
|
+
row.reduce((sum, value, index) => sum + value * vector[index], 0),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function srgbToLinear(channel) {
|
|
27
|
+
return channel <= 0.04045
|
|
28
|
+
? channel / 12.92
|
|
29
|
+
: ((channel + 0.055) / 1.055) ** 2.4;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hexToRgb(hex) {
|
|
33
|
+
const raw = hex.replace('#', '');
|
|
34
|
+
|
|
35
|
+
return [0, 2, 4].map((offset) => Number.parseInt(raw.slice(offset, offset + 2), 16));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function rgbToHex([r, g, b]) {
|
|
39
|
+
return `#${[r, g, b]
|
|
40
|
+
.map((channel) => channel.toString(16).padStart(2, '0').toUpperCase())
|
|
41
|
+
.join('')}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function hexToDisplayP3(hex) {
|
|
45
|
+
const linear = hexToRgb(hex).map((channel) => srgbToLinear(channel / 255));
|
|
46
|
+
const xyz = multiply(SRGB_TO_XYZ, linear);
|
|
47
|
+
const p3 = multiply(XYZ_TO_P3, xyz).map((channel) => Math.min(1, Math.max(0, channel)));
|
|
48
|
+
|
|
49
|
+
return `display-p3:${p3.map((channel) => channel.toFixed(5)).join(',')},1.00000`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function compensateForAppleRender(hex) {
|
|
53
|
+
const compensated = hexToRgb(hex).map((channel, index) =>
|
|
54
|
+
Math.min(255, Math.max(0, Math.round(channel / APPLE_RENDER_DARKEN[index]))),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return rgbToHex(compensated);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Apple's icon tool stores gamma-encoded P3 components directly in the sRGB
|
|
61
|
+
// container without gamut conversion. This replicates that quirk so the
|
|
62
|
+
// background color used for icon rendering matches the old pipeline exactly.
|
|
63
|
+
export function p3StringToAppleRgb(p3Str) {
|
|
64
|
+
const match = p3Str.match(/display-p3:([\d.]+),([\d.]+),([\d.]+)/);
|
|
65
|
+
|
|
66
|
+
if (!match) {
|
|
67
|
+
throw new Error(`p3StringToAppleRgb: cannot parse "${p3Str}"`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [match[1], match[2], match[3]].map((v) => Math.round(Number(v) * 255));
|
|
71
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compensateForAppleRender, hexToDisplayP3, hexToRgb, p3StringToAppleRgb } from './colors.js';
|
|
3
|
+
|
|
4
|
+
describe('hexToRgb', () => {
|
|
5
|
+
it('parses a hex string into RGB channel values', () => {
|
|
6
|
+
expect(hexToRgb('#6A2AAC')).toEqual([106, 42, 172]);
|
|
7
|
+
});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('hexToDisplayP3', () => {
|
|
11
|
+
it('converts the brand purple to a Display P3 string', () => {
|
|
12
|
+
expect(hexToDisplayP3('#6A2AAC')).toBe(
|
|
13
|
+
'display-p3:0.12267,0.02717,0.37968,1.00000',
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('converts black to zeroed P3 components', () => {
|
|
18
|
+
expect(hexToDisplayP3('#000000')).toBe(
|
|
19
|
+
'display-p3:0.00000,0.00000,0.00000,1.00000',
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('compensateForAppleRender', () => {
|
|
25
|
+
it('lightens the brand purple to counter Apple darkening', () => {
|
|
26
|
+
expect(compensateForAppleRender('#6A2AAC')).toBe('#B060D8');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('leaves black and white unaffected', () => {
|
|
30
|
+
expect(compensateForAppleRender('#000000')).toBe('#000000');
|
|
31
|
+
expect(compensateForAppleRender('#FFFFFF')).toBe('#FFFFFF');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('p3StringToAppleRgb', () => {
|
|
36
|
+
it('treats P3 components as sRGB matching Apple icon tool quirk', () => {
|
|
37
|
+
// display-p3:0.37790,0.12750,0.64098 -> rgb(96, 33, 163) not rgb(176, 96, 216)
|
|
38
|
+
expect(p3StringToAppleRgb('display-p3:0.37790,0.12750,0.64098,1.00000')).toEqual([96, 33, 163]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/* global Buffer */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { compensateForAppleRender, hexToDisplayP3, p3StringToAppleRgb } from './colors.js';
|
|
6
|
+
import { generateSquirclePath } from './squircle.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_OUTPUT_DIR = 'resources/icons';
|
|
9
|
+
const SIZE = 1024;
|
|
10
|
+
|
|
11
|
+
// Apple's "automatic-gradient" lightens the top of the icon by ~40 RGB units,
|
|
12
|
+
// reaching the base color at ~70% of the height and staying flat below that.
|
|
13
|
+
const GRADIENT_LIFT = 40;
|
|
14
|
+
|
|
15
|
+
async function fileExists(filePath) {
|
|
16
|
+
try {
|
|
17
|
+
await fs.access(filePath);
|
|
18
|
+
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function syncIconJsonGradient(jsonPath, compensatedHex, write = true) {
|
|
26
|
+
const iconData = JSON.parse(await fs.readFile(jsonPath, 'utf-8'));
|
|
27
|
+
|
|
28
|
+
iconData.fill = { ...iconData.fill, 'automatic-gradient': hexToDisplayP3(compensatedHex) };
|
|
29
|
+
|
|
30
|
+
if (write) {
|
|
31
|
+
await fs.writeFile(jsonPath, `${JSON.stringify(iconData, null, 2)}\n`, 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return iconData;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function backgroundLayer(rgb) {
|
|
38
|
+
const [r, g, b] = rgb;
|
|
39
|
+
const baseColor = `rgb(${r}, ${g}, ${b})`;
|
|
40
|
+
const topColor = `rgb(${Math.min(255, r + GRADIENT_LIFT)}, ${Math.min(255, g + GRADIENT_LIFT)}, ${Math.min(255, b + GRADIENT_LIFT)})`;
|
|
41
|
+
|
|
42
|
+
const squircleMask = Buffer.from(`
|
|
43
|
+
<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
|
|
44
|
+
<path d="${generateSquirclePath(SIZE, 5)}" fill="white" />
|
|
45
|
+
</svg>
|
|
46
|
+
`);
|
|
47
|
+
|
|
48
|
+
const gradient = Buffer.from(`
|
|
49
|
+
<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
|
|
50
|
+
<linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
51
|
+
<stop offset="0%" style="stop-color:${topColor}" />
|
|
52
|
+
<stop offset="70%" style="stop-color:${baseColor}" />
|
|
53
|
+
<stop offset="100%" style="stop-color:${baseColor}" />
|
|
54
|
+
</linearGradient>
|
|
55
|
+
<rect width="${SIZE}" height="${SIZE}" fill="url(#grad)" />
|
|
56
|
+
</svg>
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
return sharp(gradient).composite([{ input: squircleMask, blend: 'dest-in' }]).png().toBuffer();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function glyphLayer(iconDir, group, layer) {
|
|
63
|
+
const imagePath = path.join(iconDir, 'Assets', layer['image-name']);
|
|
64
|
+
|
|
65
|
+
if (!(await fileExists(imagePath))) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const originalSvg = await fs.readFile(imagePath, 'utf-8');
|
|
70
|
+
const pathMatch = originalSvg.match(/<path d="([^"]+)"/);
|
|
71
|
+
|
|
72
|
+
if (!pathMatch) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const scale = layer.position?.scale || 1.0;
|
|
77
|
+
// Apple's icon JSON expresses scale as a "coverage" fraction; its renderer
|
|
78
|
+
// maps that to an effective layer size via a power curve — exponent ~0.35
|
|
79
|
+
// empirically matches Xcode's output across the observable scale range.
|
|
80
|
+
const renderedScale = scale ** 0.35;
|
|
81
|
+
const layerSize = Math.round(SIZE * renderedScale);
|
|
82
|
+
const layerOffset = Math.round((SIZE - layerSize) / 2);
|
|
83
|
+
|
|
84
|
+
// Apple's translucency is a frosted-glass blend, not simple fill-opacity.
|
|
85
|
+
// The interior petal pixels in the reference output match ~0.70 opacity for
|
|
86
|
+
// translucency=0.5; specular highlights then push bright edges toward white.
|
|
87
|
+
const translucency = group.translucency?.enabled ? (group.translucency.value ?? 0.5) : 1.0;
|
|
88
|
+
const layerOpacity = Math.min(1.0, 0.4 + translucency * 0.55);
|
|
89
|
+
|
|
90
|
+
const glassGlyphSvg = `
|
|
91
|
+
<svg width="${layerSize}" height="${layerSize}" viewBox="0 0 1200 1200">
|
|
92
|
+
<defs>
|
|
93
|
+
<filter id="liquidGlass" x="-15%" y="-15%" width="130%" height="130%">
|
|
94
|
+
<feGaussianBlur in="SourceAlpha" stdDeviation="14" result="glowBlur" />
|
|
95
|
+
<feFlood flood-color="white" flood-opacity="0.3" result="glowFill" />
|
|
96
|
+
<feComposite in="glowFill" in2="glowBlur" operator="in" result="outerGlow" />
|
|
97
|
+
<feGaussianBlur in="SourceAlpha" stdDeviation="16" result="bump" />
|
|
98
|
+
<feSpecularLighting in="bump" surfaceScale="6" specularConstant="3" specularExponent="25" lighting-color="white" result="spec">
|
|
99
|
+
<fePointLight x="-300" y="-500" z="900" />
|
|
100
|
+
</feSpecularLighting>
|
|
101
|
+
<feComposite in="spec" in2="SourceAlpha" operator="in" result="specLight" />
|
|
102
|
+
<feMerge>
|
|
103
|
+
<feMergeNode in="outerGlow" />
|
|
104
|
+
<feMergeNode in="SourceGraphic" />
|
|
105
|
+
<feMergeNode in="specLight" />
|
|
106
|
+
</feMerge>
|
|
107
|
+
</filter>
|
|
108
|
+
</defs>
|
|
109
|
+
<path d="${pathMatch[1]}" fill="white" fill-opacity="${layerOpacity}" filter="url(#liquidGlass)" />
|
|
110
|
+
</svg>
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
const input = await sharp(Buffer.from(glassGlyphSvg)).png().toBuffer();
|
|
114
|
+
|
|
115
|
+
return { input, top: layerOffset, left: layerOffset };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Apple's squircle has a ~20px bright specular highlight along all edges,
|
|
119
|
+
// clipped to the squircle boundary: feMorphology erode carves a border ring,
|
|
120
|
+
// then a Gaussian blur softens it inward.
|
|
121
|
+
async function edgeGlowLayer() {
|
|
122
|
+
const svg = `
|
|
123
|
+
<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
|
|
124
|
+
<defs>
|
|
125
|
+
<filter id="edgeGlow" x="0%" y="0%" width="100%" height="100%">
|
|
126
|
+
<feMorphology in="SourceAlpha" operator="erode" radius="16" result="eroded" />
|
|
127
|
+
<feComposite in="SourceAlpha" in2="eroded" operator="arithmetic" k2="1" k3="-1" result="ring" />
|
|
128
|
+
<feGaussianBlur in="ring" stdDeviation="7" result="soft" />
|
|
129
|
+
<feFlood flood-color="white" flood-opacity="0.6" result="white" />
|
|
130
|
+
<feComposite in="white" in2="soft" operator="in" result="glow" />
|
|
131
|
+
<feComposite in="glow" in2="SourceAlpha" operator="in" />
|
|
132
|
+
</filter>
|
|
133
|
+
</defs>
|
|
134
|
+
<path d="${generateSquirclePath(SIZE, 5)}" fill="white" filter="url(#edgeGlow)" />
|
|
135
|
+
</svg>
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
return { input: await sharp(Buffer.from(svg)).png().toBuffer(), top: 0, left: 0 };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Apple's top-left corner has a stronger, crisper highlight. surfaceScale=51
|
|
142
|
+
// compensates for librsvg normalising bump gradients by 255; the low z=80
|
|
143
|
+
// point light makes interior normals near-zero while the TL corner's outward
|
|
144
|
+
// normal aligns with the light, creating a highlight that fades at TR/BR.
|
|
145
|
+
async function cornerSpecularLayer() {
|
|
146
|
+
const svg = `
|
|
147
|
+
<svg width="${SIZE}" height="${SIZE}" viewBox="0 0 ${SIZE} ${SIZE}">
|
|
148
|
+
<defs>
|
|
149
|
+
<filter id="cornerSpec" x="0%" y="0%" width="100%" height="100%">
|
|
150
|
+
<feGaussianBlur in="SourceAlpha" stdDeviation="12" result="bump" />
|
|
151
|
+
<feSpecularLighting in="bump" surfaceScale="51" specularConstant="0.65" specularExponent="8" lighting-color="white" result="spec">
|
|
152
|
+
<fePointLight x="-100" y="-100" z="80" />
|
|
153
|
+
</feSpecularLighting>
|
|
154
|
+
<feComposite in="spec" in2="SourceAlpha" operator="in" />
|
|
155
|
+
</filter>
|
|
156
|
+
</defs>
|
|
157
|
+
<path d="${generateSquirclePath(SIZE, 5)}" fill="white" filter="url(#cornerSpec)" />
|
|
158
|
+
</svg>
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
return { input: await sharp(Buffer.from(svg)).png().toBuffer(), top: 0, left: 0 };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function generateAppleTouchIcon(config, outputDir = DEFAULT_OUTPUT_DIR, { syncJson = true } = {}) {
|
|
165
|
+
const iconDir = config.iconPath;
|
|
166
|
+
const jsonPath = path.join(iconDir, 'icon.json');
|
|
167
|
+
const compensatedHex = compensateForAppleRender(config.backgroundColor);
|
|
168
|
+
const iconData = await syncIconJsonGradient(jsonPath, compensatedHex, syncJson);
|
|
169
|
+
// Replicate Apple's icon tool quirk: P3 components are stored in the sRGB
|
|
170
|
+
// container without gamut conversion, so we read them back the same way.
|
|
171
|
+
const rgb = p3StringToAppleRgb(iconData.fill['automatic-gradient']);
|
|
172
|
+
|
|
173
|
+
const composites = [{ input: await backgroundLayer(rgb), top: 0, left: 0 }];
|
|
174
|
+
|
|
175
|
+
for (const group of iconData.groups || []) {
|
|
176
|
+
for (const layer of group.layers || []) {
|
|
177
|
+
const composite = await glyphLayer(iconDir, group, layer);
|
|
178
|
+
|
|
179
|
+
if (composite) {
|
|
180
|
+
composites.push(composite);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
composites.push(await edgeGlowLayer());
|
|
186
|
+
composites.push(await cornerSpecularLayer());
|
|
187
|
+
|
|
188
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
189
|
+
await sharp({
|
|
190
|
+
create: { width: SIZE, height: SIZE, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
191
|
+
})
|
|
192
|
+
.composite(composites)
|
|
193
|
+
.toFile(path.join(outputDir, 'apple-touch-icon.png'));
|
|
194
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { compensateForAppleRender, hexToDisplayP3 } from './colors.js';
|
|
7
|
+
import { generateAppleTouchIcon } from './generate-apple-touch-icon.js';
|
|
8
|
+
|
|
9
|
+
const FIXTURE_ICON_DIR = path.join(import.meta.dirname, 'test-fixtures', 'sample.icon');
|
|
10
|
+
const CONFIG = { backgroundColor: '#6A2AAC' };
|
|
11
|
+
|
|
12
|
+
let outputDir;
|
|
13
|
+
let iconDir;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-icon-'));
|
|
17
|
+
await fs.cp(FIXTURE_ICON_DIR, iconDir, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await fs.rm(iconDir, { recursive: true, force: true });
|
|
22
|
+
|
|
23
|
+
if (outputDir) {
|
|
24
|
+
await fs.rm(outputDir, { recursive: true, force: true });
|
|
25
|
+
outputDir = undefined;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('generateAppleTouchIcon', () => {
|
|
30
|
+
it(
|
|
31
|
+
'renders a 1024x1024 RGBA PNG',
|
|
32
|
+
async () => {
|
|
33
|
+
outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-apple-icon-'));
|
|
34
|
+
|
|
35
|
+
await generateAppleTouchIcon({ ...CONFIG, iconPath: iconDir }, outputDir);
|
|
36
|
+
|
|
37
|
+
const { width, height, channels } = await sharp(path.join(outputDir, 'apple-touch-icon.png')).metadata();
|
|
38
|
+
|
|
39
|
+
expect({ width, height, channels }).toEqual({ width: 1024, height: 1024, channels: 4 });
|
|
40
|
+
},
|
|
41
|
+
20000,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
it(
|
|
45
|
+
"syncs icon.json's automatic-gradient to the compensated brand color",
|
|
46
|
+
async () => {
|
|
47
|
+
outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-apple-icon-'));
|
|
48
|
+
|
|
49
|
+
await generateAppleTouchIcon({ ...CONFIG, iconPath: iconDir }, outputDir);
|
|
50
|
+
|
|
51
|
+
const iconData = JSON.parse(await fs.readFile(path.join(iconDir, 'icon.json'), 'utf-8'));
|
|
52
|
+
|
|
53
|
+
expect(iconData.fill['automatic-gradient']).toBe(hexToDisplayP3(compensateForAppleRender(CONFIG.backgroundColor)));
|
|
54
|
+
},
|
|
55
|
+
20000,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* global Buffer */
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { packIco } from './pack-ico.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_OUTPUT_DIR = 'resources/icons';
|
|
8
|
+
const CANVAS_SIZE = 1200;
|
|
9
|
+
const FAVICON_SIZES = [16, 32, 48, 64, 128, 256, 512];
|
|
10
|
+
const WHITE = '#FFFFFF';
|
|
11
|
+
|
|
12
|
+
function extractGlyphMarkup(svgMarkup) {
|
|
13
|
+
const match = svgMarkup.match(/<svg\b[^>]*>([\s\S]*)<\/svg>/i);
|
|
14
|
+
|
|
15
|
+
if (!match) {
|
|
16
|
+
throw new Error('extractGlyphMarkup: unable to parse glyph SVG');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return match[1].trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildCanvasSvg(glyphMarkup, backgroundColor) {
|
|
23
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
24
|
+
<svg viewBox="0 0 ${CANVAS_SIZE} ${CANVAS_SIZE}" xmlns="http://www.w3.org/2000/svg">
|
|
25
|
+
<rect x="0" y="0" width="${CANVAS_SIZE}" height="${CANVAS_SIZE}" fill="${backgroundColor}"/>
|
|
26
|
+
${glyphMarkup}
|
|
27
|
+
</svg>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function writeOutput(outputDir, name, contents) {
|
|
31
|
+
await fs.writeFile(path.join(outputDir, name), contents);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function renderPng(svgBuffer, size) {
|
|
35
|
+
return sharp(svgBuffer).resize(size, size).png().toBuffer();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function generateWebIcons(config, outputDir = DEFAULT_OUTPUT_DIR) {
|
|
39
|
+
const glyphSource = await fs.readFile(config.glyph, 'utf-8');
|
|
40
|
+
const glyphMarkup = extractGlyphMarkup(glyphSource);
|
|
41
|
+
|
|
42
|
+
const standardSvg = buildCanvasSvg(glyphMarkup, config.backgroundColor);
|
|
43
|
+
const onWhiteSvg = buildCanvasSvg(glyphMarkup, WHITE);
|
|
44
|
+
const standardBuffer = Buffer.from(standardSvg);
|
|
45
|
+
const onWhiteBuffer = Buffer.from(onWhiteSvg);
|
|
46
|
+
|
|
47
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const faviconFrames = await Promise.all(FAVICON_SIZES.map((size) => renderPng(standardBuffer, size)));
|
|
50
|
+
|
|
51
|
+
await Promise.all([
|
|
52
|
+
writeOutput(outputDir, 'favicon.svg', standardSvg),
|
|
53
|
+
writeOutput(outputDir, 'bloom-standard.svg', standardSvg),
|
|
54
|
+
writeOutput(outputDir, 'favicon.ico', packIco(faviconFrames)),
|
|
55
|
+
renderPng(standardBuffer, 96).then((png) => writeOutput(outputDir, 'favicon-96x96.png', png)),
|
|
56
|
+
renderPng(standardBuffer, 1200).then((png) => writeOutput(outputDir, 'bloom-standard.png', png)),
|
|
57
|
+
renderPng(onWhiteBuffer, 1200).then((png) => writeOutput(outputDir, 'bloom-on-white.png', png)),
|
|
58
|
+
renderPng(standardBuffer, 192).then((png) => writeOutput(outputDir, 'web-app-manifest-192x192.png', png)),
|
|
59
|
+
renderPng(standardBuffer, 512).then((png) => writeOutput(outputDir, 'web-app-manifest-512x512.png', png)),
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { generateWebIcons } from './generate-web-icons.js';
|
|
7
|
+
|
|
8
|
+
const FIXTURE_GLYPH = path.join(import.meta.dirname, 'test-fixtures', 'glyph.svg');
|
|
9
|
+
const CONFIG = { glyph: FIXTURE_GLYPH, backgroundColor: '#6A2AAC' };
|
|
10
|
+
|
|
11
|
+
let outputDir;
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
if (outputDir) {
|
|
15
|
+
await fs.rm(outputDir, { recursive: true, force: true });
|
|
16
|
+
outputDir = undefined;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function freshOutputDir() {
|
|
21
|
+
outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'annealer-web-icons-'));
|
|
22
|
+
|
|
23
|
+
return outputDir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('generateWebIcons', () => {
|
|
27
|
+
it('renders every PNG variant at its expected size', async () => {
|
|
28
|
+
const dir = await freshOutputDir();
|
|
29
|
+
|
|
30
|
+
await generateWebIcons(CONFIG, dir);
|
|
31
|
+
|
|
32
|
+
const expectations = {
|
|
33
|
+
'favicon-96x96.png': { width: 96, height: 96 },
|
|
34
|
+
'bloom-standard.png': { width: 1200, height: 1200 },
|
|
35
|
+
'bloom-on-white.png': { width: 1200, height: 1200 },
|
|
36
|
+
'web-app-manifest-192x192.png': { width: 192, height: 192 },
|
|
37
|
+
'web-app-manifest-512x512.png': { width: 512, height: 512 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const [name, expected] of Object.entries(expectations)) {
|
|
41
|
+
const { width, height } = await sharp(path.join(dir, name)).metadata();
|
|
42
|
+
|
|
43
|
+
expect({ width, height }).toEqual(expected);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('writes the SVG variants', async () => {
|
|
48
|
+
const dir = await freshOutputDir();
|
|
49
|
+
|
|
50
|
+
await generateWebIcons(CONFIG, dir);
|
|
51
|
+
|
|
52
|
+
const favicon = await fs.readFile(path.join(dir, 'favicon.svg'), 'utf-8');
|
|
53
|
+
const standard = await fs.readFile(path.join(dir, 'bloom-standard.svg'), 'utf-8');
|
|
54
|
+
|
|
55
|
+
expect(favicon).toBe(standard);
|
|
56
|
+
expect(favicon).toContain('fill="#6A2AAC"');
|
|
57
|
+
expect(favicon).toContain('<circle');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('renders favicon.ico with the full multi-resolution frame set', async () => {
|
|
61
|
+
const dir = await freshOutputDir();
|
|
62
|
+
|
|
63
|
+
await generateWebIcons(CONFIG, dir);
|
|
64
|
+
|
|
65
|
+
const ico = await fs.readFile(path.join(dir, 'favicon.ico'));
|
|
66
|
+
|
|
67
|
+
expect(ico.readUInt16LE(4)).toBe(7);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('fills the standard variant with the configured background color', async () => {
|
|
71
|
+
const dir = await freshOutputDir();
|
|
72
|
+
|
|
73
|
+
await generateWebIcons(CONFIG, dir);
|
|
74
|
+
|
|
75
|
+
const { data } = await sharp(path.join(dir, 'favicon-96x96.png'))
|
|
76
|
+
.raw()
|
|
77
|
+
.toBuffer({ resolveWithObject: true });
|
|
78
|
+
|
|
79
|
+
// Top-left corner sits outside the centered glyph, so it's pure background.
|
|
80
|
+
expect([data[0], data[1], data[2]]).toEqual([0x6a, 0x2a, 0xac]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('fills the "on white" variant with a white background', async () => {
|
|
84
|
+
const dir = await freshOutputDir();
|
|
85
|
+
|
|
86
|
+
await generateWebIcons(CONFIG, dir);
|
|
87
|
+
|
|
88
|
+
const { data } = await sharp(path.join(dir, 'bloom-on-white.png'))
|
|
89
|
+
.raw()
|
|
90
|
+
.toBuffer({ resolveWithObject: true });
|
|
91
|
+
|
|
92
|
+
expect([data[0], data[1], data[2]]).toEqual([0xff, 0xff, 0xff]);
|
|
93
|
+
});
|
|
94
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { compensateForAppleRender, hexToDisplayP3, hexToRgb, p3StringToAppleRgb, rgbToHex } from './colors.js';
|
|
2
|
+
export { generateSquirclePath } from './squircle.js';
|
|
3
|
+
export { packIco, readPngDimensions } from './pack-ico.js';
|
|
4
|
+
export { generateAppleTouchIcon } from './generate-apple-touch-icon.js';
|
|
5
|
+
export { generateWebIcons } from './generate-web-icons.js';
|
package/src/pack-ico.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* global Buffer */
|
|
2
|
+
|
|
3
|
+
const HEADER_SIZE = 6;
|
|
4
|
+
const DIRECTORY_ENTRY_SIZE = 16;
|
|
5
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
6
|
+
|
|
7
|
+
export function readPngDimensions(png) {
|
|
8
|
+
if (!png.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
9
|
+
throw new Error('readPngDimensions: buffer is not a PNG image');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Builds a multi-frame .ico container directly from PNG buffers — the format
|
|
16
|
+
// every modern browser and OS supports since Windows Vista. Frames at or above
|
|
17
|
+
// 256px store 0 in the (single-byte) directory width/height fields; readers
|
|
18
|
+
// fall back to the embedded PNG's own dimensions, which is exactly how
|
|
19
|
+
// ImageMagick encodes the existing favicon.ico's 512px frame.
|
|
20
|
+
export function packIco(pngBuffers) {
|
|
21
|
+
const directorySize = DIRECTORY_ENTRY_SIZE * pngBuffers.length;
|
|
22
|
+
const header = Buffer.alloc(HEADER_SIZE);
|
|
23
|
+
|
|
24
|
+
header.writeUInt16LE(0, 0); // reserved
|
|
25
|
+
header.writeUInt16LE(1, 2); // type: icon
|
|
26
|
+
header.writeUInt16LE(pngBuffers.length, 4);
|
|
27
|
+
|
|
28
|
+
const directory = Buffer.alloc(directorySize);
|
|
29
|
+
let dataOffset = HEADER_SIZE + directorySize;
|
|
30
|
+
|
|
31
|
+
pngBuffers.forEach((png, index) => {
|
|
32
|
+
const { width, height } = readPngDimensions(png);
|
|
33
|
+
const entry = index * DIRECTORY_ENTRY_SIZE;
|
|
34
|
+
|
|
35
|
+
directory.writeUInt8(width >= 256 ? 0 : width, entry);
|
|
36
|
+
directory.writeUInt8(height >= 256 ? 0 : height, entry + 1);
|
|
37
|
+
directory.writeUInt8(0, entry + 2); // palette colors (none)
|
|
38
|
+
directory.writeUInt8(0, entry + 3); // reserved
|
|
39
|
+
directory.writeUInt16LE(1, entry + 4); // color planes
|
|
40
|
+
directory.writeUInt16LE(32, entry + 6); // bits per pixel
|
|
41
|
+
directory.writeUInt32LE(png.length, entry + 8);
|
|
42
|
+
directory.writeUInt32LE(dataOffset, entry + 12);
|
|
43
|
+
|
|
44
|
+
dataOffset += png.length;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return Buffer.concat([header, directory, ...pngBuffers]);
|
|
48
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { packIco, readPngDimensions } from './pack-ico.js';
|
|
4
|
+
|
|
5
|
+
async function solidPng(size) {
|
|
6
|
+
return sharp({
|
|
7
|
+
create: {
|
|
8
|
+
width: size,
|
|
9
|
+
height: size,
|
|
10
|
+
channels: 4,
|
|
11
|
+
background: { r: 255, g: 0, b: 0, alpha: 1 },
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
.png()
|
|
15
|
+
.toBuffer();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('readPngDimensions', () => {
|
|
19
|
+
it('reads width and height from a PNG buffer', async () => {
|
|
20
|
+
const png = await solidPng(32);
|
|
21
|
+
|
|
22
|
+
expect(readPngDimensions(png)).toEqual({ width: 32, height: 32 });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('packIco', () => {
|
|
27
|
+
it('builds an ICO container with one directory entry per frame', async () => {
|
|
28
|
+
const sizes = [16, 256, 512];
|
|
29
|
+
const frames = await Promise.all(sizes.map(solidPng));
|
|
30
|
+
const ico = packIco(frames);
|
|
31
|
+
|
|
32
|
+
expect(ico.readUInt16LE(0)).toBe(0); // reserved
|
|
33
|
+
expect(ico.readUInt16LE(2)).toBe(1); // type: icon
|
|
34
|
+
expect(ico.readUInt16LE(4)).toBe(sizes.length);
|
|
35
|
+
|
|
36
|
+
let dataOffset = 6 + 16 * sizes.length;
|
|
37
|
+
|
|
38
|
+
sizes.forEach((size, index) => {
|
|
39
|
+
const entry = 6 + index * 16;
|
|
40
|
+
const expectedDimensionByte = size >= 256 ? 0 : size;
|
|
41
|
+
|
|
42
|
+
expect(ico.readUInt8(entry)).toBe(expectedDimensionByte);
|
|
43
|
+
expect(ico.readUInt8(entry + 1)).toBe(expectedDimensionByte);
|
|
44
|
+
expect(ico.readUInt16LE(entry + 4)).toBe(1); // color planes
|
|
45
|
+
expect(ico.readUInt16LE(entry + 6)).toBe(32); // bits per pixel
|
|
46
|
+
expect(ico.readUInt32LE(entry + 8)).toBe(frames[index].length);
|
|
47
|
+
expect(ico.readUInt32LE(entry + 12)).toBe(dataOffset);
|
|
48
|
+
|
|
49
|
+
const embedded = ico.subarray(dataOffset, dataOffset + frames[index].length);
|
|
50
|
+
|
|
51
|
+
expect(readPngDimensions(embedded)).toEqual({ width: size, height: size });
|
|
52
|
+
|
|
53
|
+
dataOffset += frames[index].length;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(ico.length).toBe(dataOffset);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/squircle.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Quintic superellipse (n=5 by convention here) path generator matching
|
|
2
|
+
// Apple's squircle shape, traced in one-degree steps from the rightmost point.
|
|
3
|
+
export function generateSquirclePath(size, exponent) {
|
|
4
|
+
const radius = size / 2;
|
|
5
|
+
const center = size / 2;
|
|
6
|
+
let path = `M ${radius + center},${center} `;
|
|
7
|
+
|
|
8
|
+
for (let degrees = 0; degrees <= 360; degrees += 1) {
|
|
9
|
+
const angle = (degrees * Math.PI) / 180;
|
|
10
|
+
const cos = Math.cos(angle);
|
|
11
|
+
const sin = Math.sin(angle);
|
|
12
|
+
const x = Math.abs(cos) ** (2 / exponent) * radius * Math.sign(cos) + center;
|
|
13
|
+
const y = Math.abs(sin) ** (2 / exponent) * radius * Math.sign(sin) + center;
|
|
14
|
+
|
|
15
|
+
path += `L ${x},${y} `;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return `${path}Z`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateSquirclePath } from './squircle.js';
|
|
3
|
+
|
|
4
|
+
describe('generateSquirclePath', () => {
|
|
5
|
+
it('starts at the rightmost point and closes the path', () => {
|
|
6
|
+
const path = generateSquirclePath(1024, 5);
|
|
7
|
+
|
|
8
|
+
expect(path.startsWith('M 1024,512 ')).toBe(true);
|
|
9
|
+
expect(path.endsWith('Z')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('draws one line segment per degree of the sweep', () => {
|
|
13
|
+
const path = generateSquirclePath(1024, 5);
|
|
14
|
+
|
|
15
|
+
expect(path.split('L').length - 1).toBe(361);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fill": {
|
|
3
|
+
"automatic-gradient": "display-p3:0.37790,0.12750,0.64098,1.00000"
|
|
4
|
+
},
|
|
5
|
+
"groups": [
|
|
6
|
+
{
|
|
7
|
+
"layers": [
|
|
8
|
+
{
|
|
9
|
+
"glass": true,
|
|
10
|
+
"hidden": false,
|
|
11
|
+
"image-name": "glyph.svg",
|
|
12
|
+
"name": "glyph",
|
|
13
|
+
"position": {
|
|
14
|
+
"scale": 0.77,
|
|
15
|
+
"translation-in-points": [
|
|
16
|
+
0,
|
|
17
|
+
0
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"shadow": {
|
|
23
|
+
"kind": "neutral",
|
|
24
|
+
"opacity": 0.5
|
|
25
|
+
},
|
|
26
|
+
"translucency": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"value": 0.5
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"supported-platforms": {
|
|
33
|
+
"circles": [
|
|
34
|
+
"watchOS"
|
|
35
|
+
],
|
|
36
|
+
"squares": "shared"
|
|
37
|
+
}
|
|
38
|
+
}
|