@goodandready/dsh-image-gen 0.10.4 → 0.10.6
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/README.md +7 -0
- package/README.ru.md +4 -0
- package/lib/frontend-assets.js +436 -0
- package/lib/index.js +566 -5
- package/lib/providers.js +73 -2
- package/lib/resolve-image.js +243 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
## 🛠️ Complete Tools Reference
|
|
46
46
|
|
|
47
47
|
* **`generate_image`**: Generate images with pluggable providers, seeds, aspect ratios, and style presets.
|
|
48
|
+
* **`edit_image`**: Targeted inpainting and modification with automatic session reference resolution (#142, #144, #145).
|
|
49
|
+
* **`vary_image`**: Controlled variation generation preserving composition (#143, #144).
|
|
48
50
|
* **`remove_background`**: Extract subject with transparent PNG output (FAL BiRefNet / Rembg).
|
|
49
51
|
* **`upscale_image`**: 2x / 4x super-resolution with clarity reconstruction.
|
|
50
52
|
* **`vectorize_image`**: Convert raster graphics to clean scalable SVG vectors with palette quantization.
|
|
@@ -52,6 +54,11 @@
|
|
|
52
54
|
* **`generate_image_pack`**: Simultaneous multi-aspect ratio rendering with graceful partial recovery.
|
|
53
55
|
* **`compare_images`**: Pixel-level visual difference ratio comparison.
|
|
54
56
|
* **`inspect_image_quality`**: Automated visual audit, Laplacian sharpness scoring, and defect detection.
|
|
57
|
+
* **`extract_design_tokens`**: Extract CSS Variables, Tailwind color palettes, and W3C Design Tokens from concept art (#172).
|
|
58
|
+
* **`image_to_css_gradient`**: Generate lightweight pure CSS Mesh / Radial / Linear gradients (< 1KB) from image colors (#174).
|
|
59
|
+
* **`check_image_contrast`**: Evaluate background luminance and WCAG 2.1 AA/AAA contrast for text with scrim suggestions (#175).
|
|
60
|
+
* **`optimize_vector_svg`**: Clean and sanitize SVG, normalize viewBox, and export ready-to-use React TSX components (#176).
|
|
61
|
+
* **`generate_pwa_icon_suite`**: Generate full PWA icon sets, HTML meta tags, and web app manifest.json (#190).
|
|
55
62
|
|
|
56
63
|
---
|
|
57
64
|
|
package/README.ru.md
CHANGED
|
@@ -45,6 +45,8 @@ graph LR
|
|
|
45
45
|
T6[generate_image_pack]
|
|
46
46
|
T7[compare_images]
|
|
47
47
|
T8[inspect_image_quality]
|
|
48
|
+
T9[edit_image]
|
|
49
|
+
T10[vary_image]
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
subgraph Core [🛡️ Ядро надежности и кэша]
|
|
@@ -90,6 +92,8 @@ graph LR
|
|
|
90
92
|
| Инструмент | Назначение | Ключевые параметры |
|
|
91
93
|
|---|---|---|
|
|
92
94
|
| **`generate_image`** | Генерация изображения по тексту | `prompt`, `image_size`, `seed`, `style`, `negative_prompt`, `count` |
|
|
95
|
+
| **`edit_image`** | Направленное редактирование / inpainting с авто-поиском оригинала (#142, #144, #145) | `prompt`, `image` (по умолч. `latest`), `mask`, `strength` |
|
|
96
|
+
| **`vary_image`** | Генерация вариаций существующей картинки (#143, #144) | `image` (по умолч. `latest`), `prompt`, `variation_strength` (0.1–0.9), `count` |
|
|
93
97
|
| **`remove_background`** | Удаление фона с сохранением прозрачного PNG | `image`, `model`, `output_name` |
|
|
94
98
|
| **`upscale_image`** | Увеличение разрешения в 2x / 4x с детализацией | `image`, `scale` (2/4), `prompt`, `creativity` |
|
|
95
99
|
| **`vectorize_image`** | Векторизация растра в чистый SVG с квантованием палитры | `image`, `color_mode`, `paletteSize` |
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert RGB to Hex string
|
|
7
|
+
* @param {number} r
|
|
8
|
+
* @param {number} g
|
|
9
|
+
* @param {number} b
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function rgbToHex(r, g, b) {
|
|
13
|
+
const toHex = (c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, '0')
|
|
14
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toLowerCase()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse Hex string to RGB
|
|
19
|
+
* @param {string} hex
|
|
20
|
+
* @returns {{ r: number, g: number, b: number }}
|
|
21
|
+
*/
|
|
22
|
+
export function hexToRgb(hex) {
|
|
23
|
+
let clean = hex.replace(/^#/, '').trim()
|
|
24
|
+
if (clean.length === 3) {
|
|
25
|
+
clean = clean.split('').map((c) => c + c).join('')
|
|
26
|
+
}
|
|
27
|
+
const num = parseInt(clean, 16)
|
|
28
|
+
if (Number.isNaN(num) || clean.length !== 6) {
|
|
29
|
+
return { r: 0, g: 0, b: 0 }
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
r: (num >> 16) & 255,
|
|
33
|
+
g: (num >> 8) & 255,
|
|
34
|
+
b: num & 255,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Calculates WCAG 2.1 relative luminance for sRGB color.
|
|
40
|
+
* @param {{ r: number, g: number, b: number }} rgb
|
|
41
|
+
* @returns {number}
|
|
42
|
+
*/
|
|
43
|
+
export function getRelativeLuminance({ r, g, b }) {
|
|
44
|
+
const a = [r, g, b].map((v) => {
|
|
45
|
+
const s = v / 255
|
|
46
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
47
|
+
})
|
|
48
|
+
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Calculates WCAG 2.1 contrast ratio between two colors (1.0 to 21.0).
|
|
53
|
+
* @param {string} hex1
|
|
54
|
+
* @param {string} hex2
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
export function getContrastRatio(hex1, hex2) {
|
|
58
|
+
const lum1 = getRelativeLuminance(hexToRgb(hex1))
|
|
59
|
+
const lum2 = getRelativeLuminance(hexToRgb(hex2))
|
|
60
|
+
const brightest = Math.max(lum1, lum2)
|
|
61
|
+
const darkest = Math.min(lum1, lum2)
|
|
62
|
+
const ratio = (brightest + 0.05) / (darkest + 0.05)
|
|
63
|
+
return Math.round(ratio * 100) / 100
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract palette sample colors from raw image bytes.
|
|
68
|
+
* Scans byte frequencies and samples color anchors across buffer.
|
|
69
|
+
* @param {Buffer} buffer
|
|
70
|
+
* @returns {string[]} Hex colors
|
|
71
|
+
*/
|
|
72
|
+
export function extractSampleColorsFromBuffer(buffer) {
|
|
73
|
+
if (!buffer || buffer.length < 16) {
|
|
74
|
+
return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if buffer is SVG (UTF-8 text)
|
|
78
|
+
const head = buffer.slice(0, 256).toString('utf8').toLowerCase()
|
|
79
|
+
if (head.includes('<svg') || head.includes('<?xml')) {
|
|
80
|
+
const text = buffer.toString('utf8')
|
|
81
|
+
const hexMatches = text.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g)
|
|
82
|
+
if (hexMatches && hexMatches.length > 0) {
|
|
83
|
+
const counts = {}
|
|
84
|
+
for (const h of hexMatches) {
|
|
85
|
+
let full = h.toLowerCase()
|
|
86
|
+
if (full.length === 4) {
|
|
87
|
+
full = '#' + full[1] + full[1] + full[2] + full[2] + full[3] + full[3]
|
|
88
|
+
}
|
|
89
|
+
counts[full] = (counts[full] || 0) + 1
|
|
90
|
+
}
|
|
91
|
+
return Object.entries(counts)
|
|
92
|
+
.sort((a, b) => b[1] - a[1])
|
|
93
|
+
.slice(0, 6)
|
|
94
|
+
.map(([c]) => c)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Sample bytes from buffer chunks
|
|
99
|
+
const step = Math.max(4, Math.floor(buffer.length / 500))
|
|
100
|
+
const samples = []
|
|
101
|
+
for (let i = 64; i < buffer.length - 8; i += step) {
|
|
102
|
+
const r = buffer[i]
|
|
103
|
+
const g = buffer[i + 1]
|
|
104
|
+
const b = buffer[i + 2]
|
|
105
|
+
samples.push({ r, g, b })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (samples.length === 0) {
|
|
109
|
+
return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
samples.sort((a, b) => (a.r * 299 + a.g * 587 + a.b * 114) - (b.r * 299 + b.g * 587 + b.b * 114))
|
|
113
|
+
|
|
114
|
+
const anchors = []
|
|
115
|
+
const bucketSize = Math.floor(samples.length / 6)
|
|
116
|
+
for (let b = 0; b < 6; b++) {
|
|
117
|
+
const start = b * bucketSize
|
|
118
|
+
const end = b === 5 ? samples.length : start + bucketSize
|
|
119
|
+
let sumR = 0, sumG = 0, sumB = 0
|
|
120
|
+
for (let j = start; j < end; j++) {
|
|
121
|
+
sumR += samples[j].r
|
|
122
|
+
sumG += samples[j].g
|
|
123
|
+
sumB += samples[j].b
|
|
124
|
+
}
|
|
125
|
+
const count = Math.max(1, end - start)
|
|
126
|
+
anchors.push(rgbToHex(sumR / count, sumG / count, sumB / count))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const unique = Array.from(new Set(anchors))
|
|
130
|
+
while (unique.length < 5) {
|
|
131
|
+
unique.push('#3b82f6')
|
|
132
|
+
}
|
|
133
|
+
return unique
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Issue #172: extract_design_tokens
|
|
138
|
+
* Generates CSS Variables, Tailwind color palette config, and W3C Design Tokens JSON.
|
|
139
|
+
*/
|
|
140
|
+
export function extractDesignTokens(colors, options = {}) {
|
|
141
|
+
const palette = colors && colors.length > 0 ? colors : ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
142
|
+
const prefix = options.prefix || 'color'
|
|
143
|
+
|
|
144
|
+
const bg = palette[0]
|
|
145
|
+
const primary = palette[1] || palette[0]
|
|
146
|
+
const secondary = palette[2] || '#64748b'
|
|
147
|
+
const accent = palette[3] || '#f59e0b'
|
|
148
|
+
const text = palette[palette.length - 1]
|
|
149
|
+
|
|
150
|
+
const tokens = {
|
|
151
|
+
background: bg,
|
|
152
|
+
primary,
|
|
153
|
+
secondary,
|
|
154
|
+
accent,
|
|
155
|
+
text,
|
|
156
|
+
palette,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let cssVariables = ':root {\n'
|
|
160
|
+
cssVariables += ` --${prefix}-background: ${bg};\n`
|
|
161
|
+
cssVariables += ` --${prefix}-primary: ${primary};\n`
|
|
162
|
+
cssVariables += ` --${prefix}-secondary: ${secondary};\n`
|
|
163
|
+
cssVariables += ` --${prefix}-accent: ${accent};\n`
|
|
164
|
+
cssVariables += ` --${prefix}-text: ${text};\n`
|
|
165
|
+
palette.forEach((col, idx) => {
|
|
166
|
+
cssVariables += ` --${prefix}-palette-${idx + 1}: ${col};\n`
|
|
167
|
+
})
|
|
168
|
+
cssVariables += '}'
|
|
169
|
+
|
|
170
|
+
const tailwindColors = {
|
|
171
|
+
[prefix]: {
|
|
172
|
+
background: bg,
|
|
173
|
+
primary,
|
|
174
|
+
secondary,
|
|
175
|
+
accent,
|
|
176
|
+
text,
|
|
177
|
+
...palette.reduce((acc, col, idx) => {
|
|
178
|
+
acc[`shade-${(idx + 1) * 100}`] = col
|
|
179
|
+
return acc
|
|
180
|
+
}, {}),
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
const tailwindSnippet = `// tailwind.config.js\nmodule.exports = {\n theme: {\n extend: {\n colors: ${JSON.stringify(tailwindColors, null, 2).replace(/\\n/g, '\n ')}\n }\n }\n}`
|
|
184
|
+
|
|
185
|
+
const w3cTokens = {
|
|
186
|
+
color: {
|
|
187
|
+
background: { $value: bg, $type: 'color' },
|
|
188
|
+
primary: { $value: primary, $type: 'color' },
|
|
189
|
+
secondary: { $value: secondary, $type: 'color' },
|
|
190
|
+
accent: { $value: accent, $type: 'color' },
|
|
191
|
+
text: { $value: text, $type: 'color' },
|
|
192
|
+
palette: palette.map((c, i) => ({
|
|
193
|
+
[`step-${i + 1}`]: { $value: c, $type: 'color' },
|
|
194
|
+
})),
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
tokens,
|
|
200
|
+
cssVariables,
|
|
201
|
+
tailwindSnippet,
|
|
202
|
+
w3cTokens,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Issue #174: image_to_css_gradient
|
|
208
|
+
* Converts image dominant colors into pure CSS mesh / radial / linear gradient (< 1KB).
|
|
209
|
+
*/
|
|
210
|
+
export function generateCssGradient(colors, type = 'mesh') {
|
|
211
|
+
const palette = colors && colors.length >= 2 ? colors : ['#0f172a', '#1e293b', '#3b82f6', '#06b6d4']
|
|
212
|
+
const c1 = palette[0]
|
|
213
|
+
const c2 = palette[1]
|
|
214
|
+
const c3 = palette[2] || palette[0]
|
|
215
|
+
const c4 = palette[3] || palette[1]
|
|
216
|
+
|
|
217
|
+
let css = ''
|
|
218
|
+
let fallbackColor = c1
|
|
219
|
+
|
|
220
|
+
if (type === 'linear') {
|
|
221
|
+
css = `linear-gradient(135deg, ${c1} 0%, ${c2} 50%, ${c3} 100%)`
|
|
222
|
+
} else if (type === 'radial') {
|
|
223
|
+
css = `radial-gradient(circle at 30% 20%, ${c2} 0%, ${c1} 70%)`
|
|
224
|
+
} else {
|
|
225
|
+
css = [
|
|
226
|
+
`radial-gradient(at 0% 0%, ${c1} 0px, transparent 50%)`,
|
|
227
|
+
`radial-gradient(at 100% 0%, ${c2} 0px, transparent 50%)`,
|
|
228
|
+
`radial-gradient(at 100% 100%, ${c3} 0px, transparent 50%)`,
|
|
229
|
+
`radial-gradient(at 0% 100%, ${c4} 0px, transparent 50%)`,
|
|
230
|
+
`radial-gradient(at 50% 50%, ${c2}88 0px, transparent 50%)`,
|
|
231
|
+
fallbackColor,
|
|
232
|
+
].join(',\n ')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const completeStyle = `background-color: ${fallbackColor};\nbackground-image:\n ${css};`
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
type,
|
|
239
|
+
fallbackColor,
|
|
240
|
+
gradientCss: css,
|
|
241
|
+
completeStyle,
|
|
242
|
+
byteSize: Buffer.byteLength(completeStyle, 'utf8'),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Issue #175: check_image_contrast
|
|
248
|
+
* Calculates WCAG 2.1 contrast ratio and suggests overlay / scrim.
|
|
249
|
+
*/
|
|
250
|
+
export function checkWcagContrast(colors, textHex = '#ffffff') {
|
|
251
|
+
const palette = colors && colors.length > 0 ? colors : ['#0f172a']
|
|
252
|
+
const targetText = textHex.startsWith('#') ? textHex : `#${textHex}`
|
|
253
|
+
|
|
254
|
+
const ratios = palette.map((bgHex) => {
|
|
255
|
+
const ratio = getContrastRatio(bgHex, targetText)
|
|
256
|
+
const passAA = ratio >= 4.5
|
|
257
|
+
const passAALarge = ratio >= 3.0
|
|
258
|
+
const passAAA = ratio >= 7.0
|
|
259
|
+
return {
|
|
260
|
+
backgroundColor: bgHex,
|
|
261
|
+
contrastRatio: ratio,
|
|
262
|
+
passAA,
|
|
263
|
+
passAALarge,
|
|
264
|
+
passAAA,
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const minRatio = Math.min(...ratios.map((r) => r.contrastRatio))
|
|
269
|
+
const passedAA = ratios.every((r) => r.passAA)
|
|
270
|
+
const passedAALarge = ratios.every((r) => r.passAALarge)
|
|
271
|
+
const passedAAA = ratios.every((r) => r.passAAA)
|
|
272
|
+
|
|
273
|
+
let recommendation = 'Contrast satisfies WCAG 2.1 AA standards.'
|
|
274
|
+
let scrimCss = ''
|
|
275
|
+
|
|
276
|
+
if (!passedAA) {
|
|
277
|
+
const lum = getRelativeLuminance(hexToRgb(targetText))
|
|
278
|
+
if (lum > 0.5) {
|
|
279
|
+
scrimCss = 'background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.7) 100%); backdrop-filter: blur(4px);'
|
|
280
|
+
recommendation = `Contrast ratio (${minRatio}:1) is below WCAG 2.1 AA (4.5:1). Apply dark overlay scrim or increase text weight/shadow.`
|
|
281
|
+
} else {
|
|
282
|
+
scrimCss = 'background: linear-gradient(180deg, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0.85) 100%); backdrop-filter: blur(4px);'
|
|
283
|
+
recommendation = `Contrast ratio (${minRatio}:1) is below WCAG 2.1 AA (4.5:1). Apply light overlay scrim.`
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
textHex: targetText,
|
|
289
|
+
minContrastRatio: minRatio,
|
|
290
|
+
passedAA,
|
|
291
|
+
passedAALarge,
|
|
292
|
+
passedAAA,
|
|
293
|
+
recommendation,
|
|
294
|
+
suggestedScrimCss: scrimCss,
|
|
295
|
+
details: ratios,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Issue #176: optimize_vector_svg
|
|
301
|
+
* Cleans, sanitizes, and minifies SVG content, normalizes viewBox, and exports React TSX component.
|
|
302
|
+
*/
|
|
303
|
+
export function optimizeSvgContent(svgString, options = {}) {
|
|
304
|
+
if (!svgString || typeof svgString !== 'string') {
|
|
305
|
+
throw new Error('Invalid SVG string provided')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let cleaned = svgString.trim()
|
|
309
|
+
|
|
310
|
+
cleaned = cleaned.replace(/<\?xml[\s\S]*?\?>/gi, '')
|
|
311
|
+
cleaned = cleaned.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
|
|
312
|
+
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, '')
|
|
313
|
+
|
|
314
|
+
cleaned = cleaned.replace(/\s*(?:xmlns:inkscape|xmlns:sodipodi|xmlns:sketch|inkscape:[a-z-]+|sodipodi:[a-z-]+|sketch:[a-z-]+)="[^"]*"/gi, '')
|
|
315
|
+
cleaned = cleaned.replace(/<(?:metadata|sodipodi:namedview)[\s\S]*?<\/(?:metadata|sodipodi:namedview)>/gi, '')
|
|
316
|
+
|
|
317
|
+
cleaned = cleaned.replace(/\s{2,}/g, ' ')
|
|
318
|
+
cleaned = cleaned.replace(/>\s+</g, '><')
|
|
319
|
+
|
|
320
|
+
const viewBoxMatch = cleaned.match(/viewBox="([^"]+)"/i)
|
|
321
|
+
let viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24'
|
|
322
|
+
|
|
323
|
+
if (!viewBoxMatch) {
|
|
324
|
+
const widthMatch = cleaned.match(/width="([0-9.]+)(?:px)?"/i)
|
|
325
|
+
const heightMatch = cleaned.match(/height="([0-9.]+)(?:px)?"/i)
|
|
326
|
+
if (widthMatch && heightMatch) {
|
|
327
|
+
viewBox = `0 0 ${widthMatch[1]} ${heightMatch[1]}`
|
|
328
|
+
cleaned = cleaned.replace(/<svg\b/i, `<svg viewBox="${viewBox}"`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const originalSize = Buffer.byteLength(svgString, 'utf8')
|
|
333
|
+
const optimizedSize = Buffer.byteLength(cleaned, 'utf8')
|
|
334
|
+
const savedBytes = Math.max(0, originalSize - optimizedSize)
|
|
335
|
+
const reductionPercent = Math.round((savedBytes / (originalSize || 1)) * 100)
|
|
336
|
+
|
|
337
|
+
const componentName = options.componentName || 'VectorIcon'
|
|
338
|
+
let jsxBody = cleaned
|
|
339
|
+
.replace(/<svg\b[^>]*>/i, '')
|
|
340
|
+
.replace(/<\/svg>/i, '')
|
|
341
|
+
.replace(/class=/g, 'className=')
|
|
342
|
+
.replace(/clip-rule=/g, 'clipRule=')
|
|
343
|
+
.replace(/fill-rule=/g, 'fillRule=')
|
|
344
|
+
.replace(/stroke-width=/g, 'strokeWidth=')
|
|
345
|
+
.replace(/stroke-linecap=/g, 'strokeLinecap=')
|
|
346
|
+
.replace(/stroke-linejoin=/g, 'strokeLinejoin=')
|
|
347
|
+
.replace(/stroke-miterlimit=/g, 'strokeMiterlimit=')
|
|
348
|
+
|
|
349
|
+
const reactTsx = `import React from 'react';
|
|
350
|
+
|
|
351
|
+
export interface ${componentName}Props extends React.SVGProps<SVGSVGElement> {
|
|
352
|
+
size?: number | string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export const ${componentName}: React.FC<${componentName}Props> = ({ size = 24, className, ...props }) => (
|
|
356
|
+
<svg
|
|
357
|
+
viewBox="${viewBox}"
|
|
358
|
+
width={size}
|
|
359
|
+
height={size}
|
|
360
|
+
fill="currentColor"
|
|
361
|
+
className={className}
|
|
362
|
+
{...props}
|
|
363
|
+
>
|
|
364
|
+
${jsxBody.trim()}
|
|
365
|
+
</svg>
|
|
366
|
+
);
|
|
367
|
+
`
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
svg: cleaned,
|
|
371
|
+
viewBox,
|
|
372
|
+
originalSize,
|
|
373
|
+
optimizedSize,
|
|
374
|
+
savedBytes,
|
|
375
|
+
reductionPercent,
|
|
376
|
+
reactTsx,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Issue #190: generate_pwa_icon_suite
|
|
382
|
+
* Generates standard PWA icon suite metadata, web app manifest.json, and icon configuration.
|
|
383
|
+
*/
|
|
384
|
+
export async function generatePwaIconSuite({
|
|
385
|
+
name = 'App',
|
|
386
|
+
shortName = 'App',
|
|
387
|
+
themeColor = '#0f172a',
|
|
388
|
+
backgroundColor = '#ffffff',
|
|
389
|
+
iconsDir = 'icons',
|
|
390
|
+
sourcePath = null,
|
|
391
|
+
} = {}) {
|
|
392
|
+
const iconSizes = [
|
|
393
|
+
{ size: 16, rel: 'icon', filename: `${iconsDir}/favicon-16x16.png`, purpose: 'any' },
|
|
394
|
+
{ size: 32, rel: 'icon', filename: `${iconsDir}/favicon-32x32.png`, purpose: 'any' },
|
|
395
|
+
{ size: 48, rel: 'icon', filename: `${iconsDir}/favicon-48x48.png`, purpose: 'any' },
|
|
396
|
+
{ size: 180, rel: 'apple-touch-icon', filename: `${iconsDir}/apple-touch-icon.png`, purpose: 'any' },
|
|
397
|
+
{ size: 192, rel: 'manifest', filename: `${iconsDir}/icon-192x192.png`, purpose: 'any' },
|
|
398
|
+
{ size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512.png`, purpose: 'any' },
|
|
399
|
+
{ size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512-maskable.png`, purpose: 'maskable' },
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
const manifest = {
|
|
403
|
+
name,
|
|
404
|
+
short_name: shortName,
|
|
405
|
+
start_url: '/',
|
|
406
|
+
display: 'standalone',
|
|
407
|
+
background_color: backgroundColor,
|
|
408
|
+
theme_color: themeColor,
|
|
409
|
+
icons: iconSizes
|
|
410
|
+
.filter((i) => i.rel === 'manifest')
|
|
411
|
+
.map((i) => ({
|
|
412
|
+
src: i.filename,
|
|
413
|
+
sizes: `${i.size}x${i.size}`,
|
|
414
|
+
type: 'image/png',
|
|
415
|
+
purpose: i.purpose,
|
|
416
|
+
})),
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const htmlHeadSnippet = [
|
|
420
|
+
`<link rel="icon" type="image/png" sizes="16x16" href="/${iconsDir}/favicon-16x16.png">`,
|
|
421
|
+
`<link rel="icon" type="image/png" sizes="32x32" href="/${iconsDir}/favicon-32x32.png">`,
|
|
422
|
+
`<link rel="apple-touch-icon" sizes="180x180" href="/${iconsDir}/apple-touch-icon.png">`,
|
|
423
|
+
`<link rel="manifest" href="/manifest.json">`,
|
|
424
|
+
`<meta name="theme-color" content="${themeColor}">`,
|
|
425
|
+
].join('\n')
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
name,
|
|
429
|
+
shortName,
|
|
430
|
+
themeColor,
|
|
431
|
+
backgroundColor,
|
|
432
|
+
icons: iconSizes,
|
|
433
|
+
manifestJson: JSON.stringify(manifest, null, 2),
|
|
434
|
+
htmlHeadSnippet,
|
|
435
|
+
}
|
|
436
|
+
}
|