@leftium/logo 0.0.1 → 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/dist/AppLogo.svelte +137 -0
- package/dist/AppLogo.svelte.d.ts +4 -0
- package/dist/LeftiumLogo.svelte +269 -187
- package/dist/LeftiumLogo.svelte.d.ts +1 -0
- package/dist/app-logo/color-transform.d.ts +36 -0
- package/dist/app-logo/color-transform.js +136 -0
- package/dist/app-logo/defaults.d.ts +16 -0
- package/dist/app-logo/defaults.js +38 -0
- package/dist/app-logo/generate-favicon-set.d.ts +44 -0
- package/dist/app-logo/generate-favicon-set.js +97 -0
- package/dist/app-logo/generate-ico.d.ts +18 -0
- package/dist/app-logo/generate-ico.js +63 -0
- package/dist/app-logo/generate-png.d.ts +16 -0
- package/dist/app-logo/generate-png.js +60 -0
- package/dist/app-logo/generate-svg.d.ts +9 -0
- package/dist/app-logo/generate-svg.js +158 -0
- package/dist/app-logo/iconify.d.ts +21 -0
- package/dist/app-logo/iconify.js +134 -0
- package/dist/app-logo/squircle.d.ts +43 -0
- package/dist/app-logo/squircle.js +213 -0
- package/dist/app-logo/types.d.ts +37 -0
- package/dist/app-logo/types.js +1 -0
- package/dist/assets/logo-parts/glow-squircle.svg +44 -0
- package/dist/index.d.ts +8 -3
- package/dist/index.js +9 -3
- package/dist/webgl-ripples/webgl-ripples.d.ts +1 -5
- package/dist/webgl-ripples/webgl-ripples.js +1 -1
- package/package.json +39 -20
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKLCH-based color transforms for icon color modes.
|
|
3
|
+
*
|
|
4
|
+
* Uses culori for perceptually uniform color manipulation.
|
|
5
|
+
* All transforms operate on individual CSS color strings.
|
|
6
|
+
*/
|
|
7
|
+
import { parse, converter, formatHex, clampChroma, displayable } from 'culori';
|
|
8
|
+
const toOklch = converter('oklch');
|
|
9
|
+
/**
|
|
10
|
+
* Parse a CSS color string to OKLCH components.
|
|
11
|
+
* Returns null if the color can't be parsed.
|
|
12
|
+
*/
|
|
13
|
+
function parseToOklch(color) {
|
|
14
|
+
const parsed = parse(color);
|
|
15
|
+
if (!parsed)
|
|
16
|
+
return null;
|
|
17
|
+
const oklch = toOklch(parsed);
|
|
18
|
+
return {
|
|
19
|
+
l: oklch.l ?? 0,
|
|
20
|
+
c: oklch.c ?? 0,
|
|
21
|
+
h: oklch.h ?? 0,
|
|
22
|
+
alpha: oklch.alpha
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Convert OKLCH components back to a hex color string.
|
|
27
|
+
* Clamps to sRGB gamut.
|
|
28
|
+
*/
|
|
29
|
+
function oklchToHex(l, c, h, alpha) {
|
|
30
|
+
let color = { mode: 'oklch', l, c, h, alpha };
|
|
31
|
+
// Clamp to displayable sRGB gamut
|
|
32
|
+
if (!displayable(color)) {
|
|
33
|
+
color = clampChroma(color, 'oklch');
|
|
34
|
+
}
|
|
35
|
+
return formatHex(color);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Transform a single CSS color to grayscale using OKLCH.
|
|
39
|
+
* Sets chroma to 0, preserving perceptual lightness.
|
|
40
|
+
*/
|
|
41
|
+
export function toGrayscale(color) {
|
|
42
|
+
const oklch = parseToOklch(color);
|
|
43
|
+
if (!oklch)
|
|
44
|
+
return color;
|
|
45
|
+
return oklchToHex(oklch.l, 0, 0, oklch.alpha);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Transform a single CSS color to grayscale, then tint toward a target color.
|
|
49
|
+
*
|
|
50
|
+
* The tint color's hue and chroma influence the result, while the original
|
|
51
|
+
* color's lightness is preserved. This unifies a palette while keeping contrast.
|
|
52
|
+
*/
|
|
53
|
+
export function toGrayscaleTint(color, tintColor) {
|
|
54
|
+
const oklch = parseToOklch(color);
|
|
55
|
+
if (!oklch)
|
|
56
|
+
return color;
|
|
57
|
+
const tint = parseToOklch(tintColor);
|
|
58
|
+
if (!tint)
|
|
59
|
+
return color;
|
|
60
|
+
// Use original lightness, tint's hue, and a fraction of tint's chroma
|
|
61
|
+
// Scale chroma by the ratio of original chroma to max, to preserve some variation
|
|
62
|
+
const tintChroma = tint.c * 0.7; // 70% of tint chroma for a softer effect
|
|
63
|
+
return oklchToHex(oklch.l, tintChroma, tint.h, oklch.alpha);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Remap a single CSS color to a target hue, optionally overriding saturation.
|
|
67
|
+
*
|
|
68
|
+
* Preserves the original lightness. If saturation is provided, it's used as
|
|
69
|
+
* a percentage of the maximum chroma; otherwise, the original chroma is preserved.
|
|
70
|
+
*/
|
|
71
|
+
export function remapHue(color, targetHue, targetSaturation) {
|
|
72
|
+
const oklch = parseToOklch(color);
|
|
73
|
+
if (!oklch)
|
|
74
|
+
return color;
|
|
75
|
+
const c = targetSaturation !== undefined
|
|
76
|
+
? (targetSaturation / 100) * 0.4 // Map 0-100 to 0-0.4 OKLCH chroma range
|
|
77
|
+
: oklch.c;
|
|
78
|
+
return oklchToHex(oklch.l, c, targetHue, oklch.alpha);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Apply a full IconColorMode transformation to SVG content.
|
|
82
|
+
*
|
|
83
|
+
* Handles all modes: 'auto', 'original', 'monochrome', 'grayscale',
|
|
84
|
+
* 'grayscale-tint', and { hue, saturation? }.
|
|
85
|
+
*
|
|
86
|
+
* Color transforms operate on fill/stroke attribute values and style properties,
|
|
87
|
+
* replacing each color with its transformed equivalent.
|
|
88
|
+
*/
|
|
89
|
+
export function applyColorMode(svgContent, isMonochrome, colorMode, iconColor) {
|
|
90
|
+
// Handle simple string modes first
|
|
91
|
+
if (colorMode === 'original') {
|
|
92
|
+
return svgContent;
|
|
93
|
+
}
|
|
94
|
+
if (colorMode === 'auto') {
|
|
95
|
+
if (isMonochrome) {
|
|
96
|
+
return svgContent.replace(/currentColor/g, iconColor);
|
|
97
|
+
}
|
|
98
|
+
return svgContent;
|
|
99
|
+
}
|
|
100
|
+
if (colorMode === 'monochrome') {
|
|
101
|
+
return svgContent
|
|
102
|
+
.replace(/(fill=["'])(?!none)([^"']+)(["'])/gi, `$1${iconColor}$3`)
|
|
103
|
+
.replace(/(stroke=["'])(?!none)([^"']+)(["'])/gi, `$1${iconColor}$3`)
|
|
104
|
+
.replace(/currentColor/g, iconColor);
|
|
105
|
+
}
|
|
106
|
+
// For grayscale, grayscale-tint, and hue remap: transform each color value
|
|
107
|
+
const transformColor = (color) => {
|
|
108
|
+
if (color === 'none' || color === 'inherit' || color === 'transparent') {
|
|
109
|
+
return color;
|
|
110
|
+
}
|
|
111
|
+
// Replace currentColor with iconColor before transforming
|
|
112
|
+
const resolvedColor = color === 'currentColor' ? iconColor : color;
|
|
113
|
+
if (colorMode === 'grayscale') {
|
|
114
|
+
return toGrayscale(resolvedColor);
|
|
115
|
+
}
|
|
116
|
+
if (colorMode === 'grayscale-tint') {
|
|
117
|
+
return toGrayscaleTint(resolvedColor, iconColor);
|
|
118
|
+
}
|
|
119
|
+
// Object mode: { hue, saturation? }
|
|
120
|
+
if (typeof colorMode === 'object') {
|
|
121
|
+
return remapHue(resolvedColor, colorMode.hue, colorMode.saturation);
|
|
122
|
+
}
|
|
123
|
+
return color;
|
|
124
|
+
};
|
|
125
|
+
// Transform fill/stroke attribute values
|
|
126
|
+
let result = svgContent.replace(/((?:fill|stroke)=["'])([^"']+)(["'])/gi, (_match, prefix, value, suffix) => {
|
|
127
|
+
return `${prefix}${transformColor(value.trim())}${suffix}`;
|
|
128
|
+
});
|
|
129
|
+
// Transform fill/stroke in style attributes
|
|
130
|
+
result = result.replace(/((?:fill|stroke)\s*:\s*)([^;"']+)/gi, (_match, prefix, value) => {
|
|
131
|
+
return `${prefix}${transformColor(value.trim())}`;
|
|
132
|
+
});
|
|
133
|
+
// Also replace any remaining currentColor references
|
|
134
|
+
result = result.replace(/currentColor/g, transformColor('currentColor'));
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { GradientConfig, AppLogoProps, IconSourceType } from './types.js';
|
|
2
|
+
/** The Leftium brand gradient: 45deg diagonal, dark blue -> bright blue -> dark blue */
|
|
3
|
+
export declare const LEFTIUM_GRADIENT: GradientConfig;
|
|
4
|
+
/** Default values for all AppLogo props */
|
|
5
|
+
export declare const APP_LOGO_DEFAULTS: Required<Pick<AppLogoProps, 'icon' | 'iconColor' | 'iconColorMode' | 'iconSize' | 'iconOffsetX' | 'iconOffsetY' | 'iconRotation' | 'cornerRadius' | 'cornerShape' | 'size'>> & {
|
|
6
|
+
background: GradientConfig;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Detect icon source type from the icon prop string.
|
|
10
|
+
*
|
|
11
|
+
* - Starts with `<svg` or `<SVG` -> inline SVG
|
|
12
|
+
* - Starts with `data:` -> data URL
|
|
13
|
+
* - Contains `:` (but not `data:`) -> Iconify ID
|
|
14
|
+
* - Otherwise -> Unicode/emoji text
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectIconSource(icon: string): IconSourceType;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The Leftium brand gradient: 45deg diagonal, dark blue -> bright blue -> dark blue */
|
|
2
|
+
export const LEFTIUM_GRADIENT = {
|
|
3
|
+
colors: ['#0029c1', '#3973ff', '#0029c1'],
|
|
4
|
+
stops: [0, 0.29, 1],
|
|
5
|
+
angle: 45 // bottom-left -> top-right
|
|
6
|
+
};
|
|
7
|
+
/** Default values for all AppLogo props */
|
|
8
|
+
export const APP_LOGO_DEFAULTS = {
|
|
9
|
+
icon: 'fxemoji:rocket',
|
|
10
|
+
iconColor: '#ffffff',
|
|
11
|
+
iconColorMode: 'auto',
|
|
12
|
+
iconSize: 60,
|
|
13
|
+
iconOffsetX: 0,
|
|
14
|
+
iconOffsetY: 0,
|
|
15
|
+
iconRotation: 0,
|
|
16
|
+
cornerRadius: 0,
|
|
17
|
+
cornerShape: 'round',
|
|
18
|
+
background: LEFTIUM_GRADIENT,
|
|
19
|
+
size: 512
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Detect icon source type from the icon prop string.
|
|
23
|
+
*
|
|
24
|
+
* - Starts with `<svg` or `<SVG` -> inline SVG
|
|
25
|
+
* - Starts with `data:` -> data URL
|
|
26
|
+
* - Contains `:` (but not `data:`) -> Iconify ID
|
|
27
|
+
* - Otherwise -> Unicode/emoji text
|
|
28
|
+
*/
|
|
29
|
+
export function detectIconSource(icon) {
|
|
30
|
+
const trimmed = icon.trim();
|
|
31
|
+
if (trimmed.startsWith('<svg') || trimmed.startsWith('<SVG'))
|
|
32
|
+
return 'svg';
|
|
33
|
+
if (trimmed.startsWith('data:'))
|
|
34
|
+
return 'data-url';
|
|
35
|
+
if (trimmed.includes(':'))
|
|
36
|
+
return 'iconify';
|
|
37
|
+
return 'emoji';
|
|
38
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { AppLogoConfig } from './types.js';
|
|
2
|
+
export interface FaviconSetResult {
|
|
3
|
+
svg: string;
|
|
4
|
+
ico: Blob;
|
|
5
|
+
appleTouchIcon: Blob;
|
|
6
|
+
icon192: Blob;
|
|
7
|
+
icon512: Blob;
|
|
8
|
+
}
|
|
9
|
+
export interface AppInfo {
|
|
10
|
+
name?: string;
|
|
11
|
+
shortName?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Generate the full favicon file set from an AppLogo configuration.
|
|
15
|
+
*
|
|
16
|
+
* Returns SVG, ICO, and PNG blobs for all standard favicon sizes.
|
|
17
|
+
* Browser-only (requires canvas).
|
|
18
|
+
*/
|
|
19
|
+
export declare function generateFaviconSet(config: AppLogoConfig): Promise<FaviconSetResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Generate a manifest.webmanifest JSON string.
|
|
22
|
+
*/
|
|
23
|
+
export declare function generateManifest(appInfo: AppInfo): string;
|
|
24
|
+
/**
|
|
25
|
+
* Generate the HTML snippet for pasting into app.html.
|
|
26
|
+
*/
|
|
27
|
+
export declare function generateFaviconHtml(appInfo: AppInfo): string;
|
|
28
|
+
/**
|
|
29
|
+
* Build the full zip kit as a Blob.
|
|
30
|
+
*
|
|
31
|
+
* Zip structure mirrors a SvelteKit project root:
|
|
32
|
+
* static/favicon.ico
|
|
33
|
+
* static/icon.svg
|
|
34
|
+
* static/apple-touch-icon.png
|
|
35
|
+
* static/icon-192.png
|
|
36
|
+
* static/icon-512.png
|
|
37
|
+
* static/logo.png
|
|
38
|
+
* static/logo.webp
|
|
39
|
+
* static/logo.svg
|
|
40
|
+
* static/manifest.webmanifest
|
|
41
|
+
* static/_app-logo/config.json
|
|
42
|
+
* _snippets/favicon-html.html
|
|
43
|
+
*/
|
|
44
|
+
export declare function generateZipKit(config: AppLogoConfig, appInfo: AppInfo): Promise<Blob>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import JSZip from 'jszip';
|
|
2
|
+
import { generateAppLogoSvg } from './generate-svg.js';
|
|
3
|
+
import { generateAppLogoPng } from './generate-png.js';
|
|
4
|
+
import { pngToIco } from './generate-ico.js';
|
|
5
|
+
/**
|
|
6
|
+
* Generate the full favicon file set from an AppLogo configuration.
|
|
7
|
+
*
|
|
8
|
+
* Returns SVG, ICO, and PNG blobs for all standard favicon sizes.
|
|
9
|
+
* Browser-only (requires canvas).
|
|
10
|
+
*/
|
|
11
|
+
export async function generateFaviconSet(config) {
|
|
12
|
+
const [svg, png32, appleTouchIcon, icon192, icon512] = await Promise.all([
|
|
13
|
+
generateAppLogoSvg(config, 'favicon'),
|
|
14
|
+
generateAppLogoPng(config, { variant: 'favicon', size: 32 }),
|
|
15
|
+
generateAppLogoPng(config, { variant: 'favicon', size: 180 }),
|
|
16
|
+
generateAppLogoPng(config, { variant: 'favicon', size: 192 }),
|
|
17
|
+
generateAppLogoPng(config, { variant: 'favicon', size: 512 })
|
|
18
|
+
]);
|
|
19
|
+
const ico = await pngToIco(png32);
|
|
20
|
+
return { svg, ico, appleTouchIcon, icon192, icon512 };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Generate a manifest.webmanifest JSON string.
|
|
24
|
+
*/
|
|
25
|
+
export function generateManifest(appInfo) {
|
|
26
|
+
const manifest = {
|
|
27
|
+
name: appInfo.name || 'My App',
|
|
28
|
+
short_name: appInfo.shortName || appInfo.name || 'App',
|
|
29
|
+
icons: [
|
|
30
|
+
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
|
31
|
+
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
|
32
|
+
{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml' }
|
|
33
|
+
],
|
|
34
|
+
theme_color: '#ffffff',
|
|
35
|
+
background_color: '#ffffff',
|
|
36
|
+
display: 'standalone'
|
|
37
|
+
};
|
|
38
|
+
return JSON.stringify(manifest, null, '\t');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generate the HTML snippet for pasting into app.html.
|
|
42
|
+
*/
|
|
43
|
+
export function generateFaviconHtml(appInfo) {
|
|
44
|
+
const name = appInfo.name || 'My App';
|
|
45
|
+
return [
|
|
46
|
+
`\t\t<title>${name}</title>`,
|
|
47
|
+
`\t\t<link rel="icon" href="/favicon.ico" sizes="32x32">`,
|
|
48
|
+
`\t\t<link rel="icon" href="/icon.svg" type="image/svg+xml">`,
|
|
49
|
+
`\t\t<link rel="apple-touch-icon" href="/apple-touch-icon.png">`,
|
|
50
|
+
`\t\t<link rel="manifest" href="/manifest.webmanifest">`
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build the full zip kit as a Blob.
|
|
55
|
+
*
|
|
56
|
+
* Zip structure mirrors a SvelteKit project root:
|
|
57
|
+
* static/favicon.ico
|
|
58
|
+
* static/icon.svg
|
|
59
|
+
* static/apple-touch-icon.png
|
|
60
|
+
* static/icon-192.png
|
|
61
|
+
* static/icon-512.png
|
|
62
|
+
* static/logo.png
|
|
63
|
+
* static/logo.webp
|
|
64
|
+
* static/logo.svg
|
|
65
|
+
* static/manifest.webmanifest
|
|
66
|
+
* static/_app-logo/config.json
|
|
67
|
+
* _snippets/favicon-html.html
|
|
68
|
+
*/
|
|
69
|
+
export async function generateZipKit(config, appInfo) {
|
|
70
|
+
const [faviconSet, logoPng, logoWebp, logoSvg] = await Promise.all([
|
|
71
|
+
generateFaviconSet(config),
|
|
72
|
+
generateAppLogoPng(config, { variant: 'logo', size: 512 }),
|
|
73
|
+
generateAppLogoPng(config, { variant: 'logo', size: 512, format: 'webp' }),
|
|
74
|
+
generateAppLogoSvg(config, 'logo')
|
|
75
|
+
]);
|
|
76
|
+
const zip = new JSZip();
|
|
77
|
+
const staticDir = zip.folder('static');
|
|
78
|
+
const appLogoDir = staticDir.folder('_app-logo');
|
|
79
|
+
const snippetsDir = zip.folder('_snippets');
|
|
80
|
+
// Favicon files
|
|
81
|
+
staticDir.file('favicon.ico', faviconSet.ico);
|
|
82
|
+
staticDir.file('icon.svg', faviconSet.svg);
|
|
83
|
+
staticDir.file('apple-touch-icon.png', faviconSet.appleTouchIcon);
|
|
84
|
+
staticDir.file('icon-192.png', faviconSet.icon192);
|
|
85
|
+
staticDir.file('icon-512.png', faviconSet.icon512);
|
|
86
|
+
// Logo files
|
|
87
|
+
staticDir.file('logo.png', logoPng);
|
|
88
|
+
staticDir.file('logo.webp', logoWebp);
|
|
89
|
+
staticDir.file('logo.svg', logoSvg);
|
|
90
|
+
// Manifest
|
|
91
|
+
staticDir.file('manifest.webmanifest', generateManifest(appInfo));
|
|
92
|
+
// Config for regeneration
|
|
93
|
+
appLogoDir.file('config.json', JSON.stringify(config, null, '\t'));
|
|
94
|
+
// HTML snippet
|
|
95
|
+
snippetsDir.file('favicon-html.html', generateFaviconHtml(appInfo));
|
|
96
|
+
return zip.generateAsync({ type: 'blob' });
|
|
97
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICO binary container generator.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a single PNG blob in the ICO container format.
|
|
5
|
+
* The ICO format is a simple container; modern browsers accept a single 32x32 PNG inside.
|
|
6
|
+
*
|
|
7
|
+
* ICO file structure (single image):
|
|
8
|
+
* ICONDIR header (6 bytes)
|
|
9
|
+
* ICONDIRENTRY (16 bytes)
|
|
10
|
+
* PNG image data (variable)
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Wrap a PNG Blob in an ICO container.
|
|
14
|
+
*
|
|
15
|
+
* @param pngBlob - A PNG image blob (should be 32x32 for favicon.ico)
|
|
16
|
+
* @returns ICO file as a Blob
|
|
17
|
+
*/
|
|
18
|
+
export declare function pngToIco(pngBlob: Blob): Promise<Blob>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICO binary container generator.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a single PNG blob in the ICO container format.
|
|
5
|
+
* The ICO format is a simple container; modern browsers accept a single 32x32 PNG inside.
|
|
6
|
+
*
|
|
7
|
+
* ICO file structure (single image):
|
|
8
|
+
* ICONDIR header (6 bytes)
|
|
9
|
+
* ICONDIRENTRY (16 bytes)
|
|
10
|
+
* PNG image data (variable)
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Wrap a PNG Blob in an ICO container.
|
|
14
|
+
*
|
|
15
|
+
* @param pngBlob - A PNG image blob (should be 32x32 for favicon.ico)
|
|
16
|
+
* @returns ICO file as a Blob
|
|
17
|
+
*/
|
|
18
|
+
export async function pngToIco(pngBlob) {
|
|
19
|
+
const pngBytes = new Uint8Array(await pngBlob.arrayBuffer());
|
|
20
|
+
const pngSize = pngBytes.byteLength;
|
|
21
|
+
// ICO header: 6 bytes
|
|
22
|
+
// reserved (2) = 0
|
|
23
|
+
// type (2) = 1 (icon)
|
|
24
|
+
// count (2) = 1 (number of images)
|
|
25
|
+
const HEADER_SIZE = 6;
|
|
26
|
+
const ENTRY_SIZE = 16;
|
|
27
|
+
const imageOffset = HEADER_SIZE + ENTRY_SIZE;
|
|
28
|
+
const buffer = new ArrayBuffer(imageOffset + pngSize);
|
|
29
|
+
const view = new DataView(buffer);
|
|
30
|
+
const bytes = new Uint8Array(buffer);
|
|
31
|
+
// ICONDIR header
|
|
32
|
+
view.setUint16(0, 0, true); // reserved
|
|
33
|
+
view.setUint16(2, 1, true); // type: 1 = ICO
|
|
34
|
+
view.setUint16(4, 1, true); // image count: 1
|
|
35
|
+
// ICONDIRENTRY (16 bytes at offset 6)
|
|
36
|
+
// width (1 byte): 0 means 256; use actual size for <=255
|
|
37
|
+
// height (1 byte): same
|
|
38
|
+
// We read actual dimensions from the PNG IHDR chunk (bytes 16-23)
|
|
39
|
+
const width = readPngDimension(pngBytes, 16);
|
|
40
|
+
const height = readPngDimension(pngBytes, 20);
|
|
41
|
+
bytes[6] = width >= 256 ? 0 : width; // width (0 = 256)
|
|
42
|
+
bytes[7] = height >= 256 ? 0 : height; // height (0 = 256)
|
|
43
|
+
bytes[8] = 0; // color count (0 = no palette)
|
|
44
|
+
bytes[9] = 0; // reserved
|
|
45
|
+
view.setUint16(10, 1, true); // color planes
|
|
46
|
+
view.setUint16(12, 32, true); // bits per pixel
|
|
47
|
+
view.setUint32(14, pngSize, true); // size of image data
|
|
48
|
+
view.setUint32(18, imageOffset, true); // offset of image data
|
|
49
|
+
// PNG image data
|
|
50
|
+
bytes.set(pngBytes, imageOffset);
|
|
51
|
+
return new Blob([buffer], { type: 'image/x-icon' });
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Read a 4-byte big-endian uint32 from a Uint8Array at the given offset.
|
|
55
|
+
* Used to extract width/height from the PNG IHDR chunk.
|
|
56
|
+
*/
|
|
57
|
+
function readPngDimension(bytes, offset) {
|
|
58
|
+
return (((bytes[offset] << 24) |
|
|
59
|
+
(bytes[offset + 1] << 16) |
|
|
60
|
+
(bytes[offset + 2] << 8) |
|
|
61
|
+
bytes[offset + 3]) >>>
|
|
62
|
+
0);
|
|
63
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AppLogoConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a PNG or WebP Blob from an AppLogo configuration.
|
|
4
|
+
*
|
|
5
|
+
* Renders the SVG onto an offscreen canvas and exports as PNG or WebP.
|
|
6
|
+
* Browser-only (requires canvas and Image).
|
|
7
|
+
*
|
|
8
|
+
* @param config - The AppLogo configuration
|
|
9
|
+
* @param options - Export options
|
|
10
|
+
* @returns PNG or WebP Blob
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateAppLogoPng(config: AppLogoConfig, options?: {
|
|
13
|
+
variant?: 'logo' | 'favicon';
|
|
14
|
+
size?: number;
|
|
15
|
+
format?: 'png' | 'webp';
|
|
16
|
+
}): Promise<Blob>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { generateAppLogoSvg } from './generate-svg.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a PNG or WebP Blob from an AppLogo configuration.
|
|
4
|
+
*
|
|
5
|
+
* Renders the SVG onto an offscreen canvas and exports as PNG or WebP.
|
|
6
|
+
* Browser-only (requires canvas and Image).
|
|
7
|
+
*
|
|
8
|
+
* @param config - The AppLogo configuration
|
|
9
|
+
* @param options - Export options
|
|
10
|
+
* @returns PNG or WebP Blob
|
|
11
|
+
*/
|
|
12
|
+
export async function generateAppLogoPng(config, options) {
|
|
13
|
+
const size = options?.size ?? 512;
|
|
14
|
+
const mimeType = options?.format === 'webp' ? 'image/webp' : 'image/png';
|
|
15
|
+
// Override size in config for SVG generation
|
|
16
|
+
const sizedConfig = {
|
|
17
|
+
...config,
|
|
18
|
+
[options?.variant === 'favicon' ? 'favicon' : 'logo']: {
|
|
19
|
+
...(options?.variant === 'favicon' ? config.favicon : config.logo),
|
|
20
|
+
size
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const svg = await generateAppLogoSvg(sizedConfig, options?.variant);
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const img = new Image();
|
|
26
|
+
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
|
27
|
+
const url = URL.createObjectURL(blob);
|
|
28
|
+
img.onload = () => {
|
|
29
|
+
try {
|
|
30
|
+
const canvas = document.createElement('canvas');
|
|
31
|
+
canvas.width = size;
|
|
32
|
+
canvas.height = size;
|
|
33
|
+
const ctx = canvas.getContext('2d');
|
|
34
|
+
if (!ctx) {
|
|
35
|
+
reject(new Error('Failed to get canvas 2d context'));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ctx.drawImage(img, 0, 0, size, size);
|
|
39
|
+
canvas.toBlob((outBlob) => {
|
|
40
|
+
URL.revokeObjectURL(url);
|
|
41
|
+
if (outBlob) {
|
|
42
|
+
resolve(outBlob);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
reject(new Error('Canvas toBlob returned null'));
|
|
46
|
+
}
|
|
47
|
+
}, mimeType);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
URL.revokeObjectURL(url);
|
|
51
|
+
reject(err);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
img.onerror = () => {
|
|
55
|
+
URL.revokeObjectURL(url);
|
|
56
|
+
reject(new Error('Failed to load SVG into Image element'));
|
|
57
|
+
};
|
|
58
|
+
img.src = url;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AppLogoConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate a complete SVG string for an AppLogo configuration.
|
|
4
|
+
*
|
|
5
|
+
* @param config - The AppLogo configuration
|
|
6
|
+
* @param variant - 'logo' or 'favicon' to use per-output overrides
|
|
7
|
+
* @returns Complete SVG string
|
|
8
|
+
*/
|
|
9
|
+
export declare function generateAppLogoSvg(config: AppLogoConfig, variant?: 'logo' | 'favicon'): Promise<string>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { APP_LOGO_DEFAULTS } from './defaults.js';
|
|
2
|
+
import { resolveIcon } from './iconify.js';
|
|
3
|
+
import { applyColorMode } from './color-transform.js';
|
|
4
|
+
import { generateCornerPath } from './squircle.js';
|
|
5
|
+
/**
|
|
6
|
+
* Resolve merged props from an AppLogoConfig + variant.
|
|
7
|
+
*/
|
|
8
|
+
function resolveProps(config, variant) {
|
|
9
|
+
const overrides = variant === 'favicon' ? config.favicon : config.logo;
|
|
10
|
+
return {
|
|
11
|
+
icon: overrides?.icon ?? config.icon ?? APP_LOGO_DEFAULTS.icon,
|
|
12
|
+
iconColor: overrides?.iconColor ?? config.iconColor ?? APP_LOGO_DEFAULTS.iconColor,
|
|
13
|
+
iconColorMode: overrides?.iconColorMode ?? config.iconColorMode ?? APP_LOGO_DEFAULTS.iconColorMode,
|
|
14
|
+
iconSize: overrides?.iconSize ?? APP_LOGO_DEFAULTS.iconSize,
|
|
15
|
+
iconOffsetX: overrides?.iconOffsetX ?? APP_LOGO_DEFAULTS.iconOffsetX,
|
|
16
|
+
iconOffsetY: overrides?.iconOffsetY ?? APP_LOGO_DEFAULTS.iconOffsetY,
|
|
17
|
+
iconRotation: overrides?.iconRotation ?? APP_LOGO_DEFAULTS.iconRotation,
|
|
18
|
+
cornerRadius: overrides?.cornerRadius ?? config.cornerRadius ?? APP_LOGO_DEFAULTS.cornerRadius,
|
|
19
|
+
cornerShape: overrides?.cornerShape ?? config.cornerShape ?? APP_LOGO_DEFAULTS.cornerShape,
|
|
20
|
+
background: overrides?.background ?? config.background ?? APP_LOGO_DEFAULTS.background,
|
|
21
|
+
size: overrides?.size ?? APP_LOGO_DEFAULTS.size
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Convert a CSS gradient angle (degrees, clockwise from top) to SVG linearGradient coordinates.
|
|
26
|
+
*
|
|
27
|
+
* CSS: 0deg = bottom-to-top, 90deg = left-to-right, 180deg = top-to-bottom
|
|
28
|
+
* SVG: x1,y1 = start point, x2,y2 = end point (as percentages)
|
|
29
|
+
*
|
|
30
|
+
* @param angleDeg - CSS gradient angle in degrees
|
|
31
|
+
* @param position - Shift gradient along its axis as % (-100 to 100), default 0
|
|
32
|
+
* @param scale - Scale gradient length (1 = full span, 0.5 = half, 2 = double), default 1
|
|
33
|
+
*/
|
|
34
|
+
function angleToGradientCoords(angleDeg, position = 0, scale = 1) {
|
|
35
|
+
// CSS gradient angles: 0deg = to top, 90deg = to right, clockwise.
|
|
36
|
+
// The gradient line runs FROM the opposite side TO the angle direction.
|
|
37
|
+
// In SVG coords (Y-axis down): convert CSS angle to SVG start/end points.
|
|
38
|
+
const rad = (angleDeg * Math.PI) / 180;
|
|
39
|
+
// Direction vector: CSS 0deg = up (dx=0, dy=-1 in SVG), rotates clockwise
|
|
40
|
+
const dx = Math.sin(rad);
|
|
41
|
+
const dy = -Math.cos(rad);
|
|
42
|
+
// Half-length of the gradient vector, scaled
|
|
43
|
+
const halfLen = 50 * scale;
|
|
44
|
+
// Position shift along the gradient axis (% of full span)
|
|
45
|
+
const shiftX = dx * (position / 100) * 50;
|
|
46
|
+
const shiftY = dy * (position / 100) * 50;
|
|
47
|
+
// Center point, shifted by position
|
|
48
|
+
const cx = 50 + shiftX;
|
|
49
|
+
const cy = 50 + shiftY;
|
|
50
|
+
// Start and end points
|
|
51
|
+
const x1 = Math.round(cx - dx * halfLen);
|
|
52
|
+
const y1 = Math.round(cy - dy * halfLen);
|
|
53
|
+
const x2 = Math.round(cx + dx * halfLen);
|
|
54
|
+
const y2 = Math.round(cy + dy * halfLen);
|
|
55
|
+
return { x1: `${x1}%`, y1: `${y1}%`, x2: `${x2}%`, y2: `${y2}%` };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build a <linearGradient> element for the gradient background.
|
|
59
|
+
* Returns the element without wrapping <defs>.
|
|
60
|
+
*/
|
|
61
|
+
function buildGradientElement(gradient, id) {
|
|
62
|
+
const angle = gradient.angle ?? 45;
|
|
63
|
+
const position = gradient.position ?? 0;
|
|
64
|
+
const scale = gradient.scale ?? 1;
|
|
65
|
+
const coords = angleToGradientCoords(angle, position, scale);
|
|
66
|
+
const stops = gradient.colors
|
|
67
|
+
.map((color, i) => {
|
|
68
|
+
const offset = gradient.stops ? gradient.stops[i] : i / (gradient.colors.length - 1);
|
|
69
|
+
return `<stop offset="${(offset * 100).toFixed(1)}%" stop-color="${color}"/>`;
|
|
70
|
+
})
|
|
71
|
+
.join('\n ');
|
|
72
|
+
return `<linearGradient id="${id}" x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}">
|
|
73
|
+
${stops}
|
|
74
|
+
</linearGradient>`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the background element (solid color or gradient fill).
|
|
78
|
+
*
|
|
79
|
+
* For 'round' corners, uses a standard <rect> with rx/ry.
|
|
80
|
+
* For other corner shapes, uses a <path> with the superellipse curve.
|
|
81
|
+
*/
|
|
82
|
+
function buildBackground(background, size, cornerRadius, cornerShape, gradientId) {
|
|
83
|
+
const fill = typeof background === 'string' ? background : `url(#${gradientId})`;
|
|
84
|
+
// For 'round' shape, use standard rect with rx/ry (cleaner SVG)
|
|
85
|
+
if (cornerShape === 'round') {
|
|
86
|
+
const rx = cornerRadius > 0
|
|
87
|
+
? ` rx="${(cornerRadius / 100) * size}" ry="${(cornerRadius / 100) * size}"`
|
|
88
|
+
: '';
|
|
89
|
+
return {
|
|
90
|
+
defs: '',
|
|
91
|
+
element: `<rect width="${size}" height="${size}"${rx} fill="${fill}"/>`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// For other shapes, generate the superellipse path
|
|
95
|
+
const pathD = generateCornerPath(size, cornerRadius, cornerShape);
|
|
96
|
+
const clipId = 'app-logo-clip';
|
|
97
|
+
return {
|
|
98
|
+
defs: `<clipPath id="${clipId}"><path d="${pathD}"/></clipPath>`,
|
|
99
|
+
element: `<rect width="${size}" height="${size}" fill="${fill}" clip-path="url(#${clipId})"/>`
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build the icon layer as an SVG group, positioned and scaled within the square.
|
|
104
|
+
*/
|
|
105
|
+
function buildIconLayer(svgContent, viewBox, size, iconSize, iconOffsetX, iconOffsetY, iconRotation) {
|
|
106
|
+
// Parse viewBox
|
|
107
|
+
const [vbX, vbY, vbW, vbH] = viewBox.split(' ').map(Number);
|
|
108
|
+
// Icon pixel size
|
|
109
|
+
const iconPx = (iconSize / 100) * size;
|
|
110
|
+
// Scale factor to fit icon into iconPx
|
|
111
|
+
const scale = iconPx / Math.max(vbW, vbH);
|
|
112
|
+
// Actual rendered dimensions (preserving aspect ratio)
|
|
113
|
+
const renderedW = vbW * scale;
|
|
114
|
+
const renderedH = vbH * scale;
|
|
115
|
+
// Center position with offset
|
|
116
|
+
const offsetXPx = (iconOffsetX / 100) * size;
|
|
117
|
+
const offsetYPx = (iconOffsetY / 100) * size;
|
|
118
|
+
const tx = (size - renderedW) / 2 + offsetXPx;
|
|
119
|
+
const ty = (size - renderedH) / 2 + offsetYPx;
|
|
120
|
+
// Rotation around the icon's center
|
|
121
|
+
const rotateAttr = iconRotation !== 0
|
|
122
|
+
? ` rotate(${iconRotation}, ${(renderedW / 2).toFixed(2)}, ${(renderedH / 2).toFixed(2)})`
|
|
123
|
+
: '';
|
|
124
|
+
return `<g transform="translate(${tx.toFixed(2)}, ${ty.toFixed(2)})${rotateAttr} scale(${scale.toFixed(4)}) translate(${-vbX}, ${-vbY})">
|
|
125
|
+
${svgContent}
|
|
126
|
+
</g>`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Generate a complete SVG string for an AppLogo configuration.
|
|
130
|
+
*
|
|
131
|
+
* @param config - The AppLogo configuration
|
|
132
|
+
* @param variant - 'logo' or 'favicon' to use per-output overrides
|
|
133
|
+
* @returns Complete SVG string
|
|
134
|
+
*/
|
|
135
|
+
export async function generateAppLogoSvg(config, variant) {
|
|
136
|
+
const props = resolveProps(config, variant);
|
|
137
|
+
const size = props.size;
|
|
138
|
+
const gradientId = 'app-logo-bg';
|
|
139
|
+
// Resolve icon
|
|
140
|
+
const resolved = await resolveIcon(props.icon);
|
|
141
|
+
// Apply color mode (supports all IconColorMode values)
|
|
142
|
+
const coloredContent = applyColorMode(resolved.svgContent, resolved.isMonochrome, props.iconColorMode, props.iconColor);
|
|
143
|
+
// Build SVG parts
|
|
144
|
+
const isGradient = typeof props.background !== 'string';
|
|
145
|
+
const gradientEl = isGradient
|
|
146
|
+
? buildGradientElement(props.background, gradientId)
|
|
147
|
+
: '';
|
|
148
|
+
const bg = buildBackground(props.background, size, props.cornerRadius, props.cornerShape, gradientId);
|
|
149
|
+
const iconLayer = buildIconLayer(coloredContent, resolved.viewBox, size, props.iconSize, props.iconOffsetX, props.iconOffsetY, props.iconRotation);
|
|
150
|
+
// Combine all defs (gradient + clip path)
|
|
151
|
+
const allDefs = [gradientEl, bg.defs].filter(Boolean).join('\n ');
|
|
152
|
+
const defsBlock = allDefs ? `<defs>\n ${allDefs}\n </defs>` : '';
|
|
153
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
|
154
|
+
${defsBlock}
|
|
155
|
+
${bg.element}
|
|
156
|
+
${iconLayer}
|
|
157
|
+
</svg>`;
|
|
158
|
+
}
|