Files
svg-sprites/src/transforms.ts
S.Gromov aad1c97f50 feat: инициализировать пакет генерации SVG-спрайтов
- создан NPM-пакет @gromlab/svg-sprites (ESM, TypeScript)
- реализован CLI через citty и программный API
- добавлена компиляция SVG в спрайты (stack/symbol) через svg-sprite
- добавлена генерация TypeScript union-типов имён иконок
- реализованы SVG-трансформации: замена цветов на CSS-переменные,
  удаление width/height, добавление transition к элементам с цветом
- добавлен генератор HTML-превью с color picker-ами, авто-темой,
  синхронизацией currentColor с темой и поиском по иконкам
- добавлены тестовые SVG-файлы (icons, logos)
2026-04-21 23:07:34 +03:00

232 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SVG-трансформации для обработки иконок перед сборкой спрайта.
*
* - Удаление width/height с корневого <svg>
* - Замена цветов на CSS-переменные (моно/мульти)
* - Добавление transition к элементам с цветом
*/
import type { TransformOptions } from './types.js'
/** Элементы, которые могут содержать цвет (fill/stroke). */
const COLORABLE_TAGS = [
'path',
'circle',
'ellipse',
'rect',
'line',
'polyline',
'polygon',
'text',
'tspan',
'use',
]
/** Цвета, которые не считаются «реальными» и не заменяются. */
const IGNORED_COLORS = new Set(['none', 'transparent', 'inherit', 'unset', 'initial'])
/** Нормализует значение цвета для сравнения. */
function normalizeColor(value: string): string {
return value.trim().toLowerCase()
}
/** Проверяет, является ли значение реальным цветом (не none/transparent/etc). */
function isRealColor(value: string): boolean {
const normalized = normalizeColor(value)
return normalized !== '' && !IGNORED_COLORS.has(normalized)
}
/**
* Извлекает все уникальные цвета из SVG-строки.
*
* Ищет значения fill="..." и stroke="..." на дочерних элементах,
* а также currentColor. Игнорирует none, transparent и т.п.
*/
function extractColors(svg: string): string[] {
const colors = new Set<string>()
// fill="..." и stroke="..." на элементах (не на корневом <svg>)
const attrRegex = /(?:fill|stroke)="([^"]+)"/g
let match: RegExpExecArray | null
while ((match = attrRegex.exec(svg)) !== null) {
const value = match[1]
if (isRealColor(value)) {
colors.add(normalizeColor(value))
}
}
// fill:... и stroke:... в атрибуте style="..."
const styleAttrRegex = /style="([^"]*)"/g
while ((match = styleAttrRegex.exec(svg)) !== null) {
const styleContent = match[1]
const stylePropRegex = /(?:fill|stroke)\s*:\s*([^;"\s]+)/g
let propMatch: RegExpExecArray | null
while ((propMatch = stylePropRegex.exec(styleContent)) !== null) {
const value = propMatch[1]
if (isRealColor(value)) {
colors.add(normalizeColor(value))
}
}
}
return [...colors]
}
/**
* Удаляет атрибуты width и height с корневого элемента <svg>.
*/
function removeWidthHeight(svg: string): string {
return svg.replace(
/(<svg\b[^>]*?)\s+(?:width|height)="[^"]*"/g,
'$1',
)
}
/**
* Формирует CSS-переменную для цвета.
*
* Моно (1 цвет): var(--icon-color-1, currentColor)
* Мульти (N цветов): var(--icon-color-N, #original)
*/
function makeColorVar(index: number, originalColor: string, isMono: boolean): string {
const fallback = isMono ? 'currentColor' : originalColor
return `var(--icon-color-${index}, ${fallback})`
}
/**
* Заменяет цвета в fill/stroke атрибутах на CSS-переменные.
*/
function replaceColors(svg: string, colorMap: Map<string, string>): string {
// Замена в атрибутах fill="..." и stroke="..."
let result = svg.replace(
/((?:fill|stroke))="([^"]+)"/g,
(full, attr: string, value: string) => {
const normalized = normalizeColor(value)
const replacement = colorMap.get(normalized)
if (replacement) {
return `${attr}="${replacement}"`
}
return full
},
)
// Замена в style="...fill:...;stroke:..."
result = result.replace(
/style="([^"]*)"/g,
(full, styleContent: string) => {
const replaced = styleContent.replace(
/((?:fill|stroke))\s*:\s*([^;"]+)/g,
(styleFull, prop: string, value: string) => {
const normalized = normalizeColor(value)
const replacement = colorMap.get(normalized)
if (replacement) {
return `${prop}:${replacement}`
}
return styleFull
},
)
return `style="${replaced}"`
},
)
return result
}
/**
* Добавляет inline transition к элементам, которые содержат fill или stroke с цветом.
*
* Добавляет style="transition:fill 0.3s,stroke 0.3s;" к элементам из COLORABLE_TAGS,
* которые имеют fill или stroke с реальным цветом (или CSS-переменной).
*/
function addTransitions(svg: string): string {
const tagPattern = new RegExp(
`(<(?:${COLORABLE_TAGS.join('|')})\\b)([^>]*?)(/?>)`,
'g',
)
return svg.replace(tagPattern, (full, open: string, attrs: string, close: string) => {
// Проверяем, есть ли fill или stroke с реальным цветом (или var(...))
const hasColor = /(?:fill|stroke)="(?!none|transparent)[^"]*"/.test(attrs)
|| /style="[^"]*(?:fill|stroke)\s*:[^"]*"/.test(attrs)
if (!hasColor) {
return full
}
// Если уже есть style="...", добавляем transition в него
if (/style="/.test(attrs)) {
const newAttrs = attrs.replace(
/style="([^"]*)"/,
(_, existing: string) => {
// Не добавляем дубль, если transition уже есть
if (existing.includes('transition')) return `style="${existing}"`
const sep = existing.endsWith(';') || existing === '' ? '' : ';'
return `style="${existing}${sep}transition:fill 0.3s,stroke 0.3s;"`
},
)
return `${open}${newAttrs}${close}`
}
// Иначе добавляем новый атрибут style
return `${open}${attrs} style="transition:fill 0.3s,stroke 0.3s;"${close}`
})
}
/**
* Shape transform для svg-sprite.
*
* Применяет трансформации в зависимости от опций:
* - removeSize: удаление width/height
* - replaceColors: замена цветов на CSS-переменные
* - addTransition: добавление transition к элементам с цветом
*/
export function createShapeTransform(options: TransformOptions = {}): (
shape: { getSVG: (inline: boolean) => string; setSVG: (svg: string) => void },
spriter: unknown,
callback: (error: Error | null) => void,
) => void {
const {
removeSize = true,
replaceColors: doReplaceColors = true,
addTransition = true,
} = options
return (shape, _spriter, callback) => {
try {
let svg = shape.getSVG(false)
// 1. Удаляем width/height
if (removeSize) {
svg = removeWidthHeight(svg)
}
// 2. Извлекаем уникальные цвета и заменяем на CSS-переменные
if (doReplaceColors) {
const colors = extractColors(svg)
if (colors.length > 0) {
const isMono = colors.length === 1
const colorMap = new Map<string, string>()
colors.forEach((color, i) => {
colorMap.set(color, makeColorVar(i + 1, color, isMono))
})
svg = replaceColors(svg, colorMap)
}
}
// 3. Добавляем transition к элементам с цветом
if (addTransition) {
svg = addTransitions(svg)
}
shape.setSVG(svg)
callback(null)
} catch (error) {
callback(error instanceof Error ? error : new Error(String(error)))
}
}
}