531 lines
25 KiB
Python
531 lines
25 KiB
Python
import io
|
||
import math
|
||
import random
|
||
import re
|
||
from collections import defaultdict
|
||
from typing import BinaryIO, Optional, Union
|
||
|
||
from PIL import Image
|
||
|
||
|
||
def encode_to_base36(num: int) -> str:
|
||
"""Кодирует неотрицательное число в компактный строковый идентификатор."""
|
||
if num < 0:
|
||
raise ValueError("Число должно быть неотрицательным")
|
||
|
||
charset = "HYPN0xyzabcdefghijklmnopqrstuvwABCDEFGIJKLMOQRSTUVWXZ123456789"
|
||
if num < len(charset):
|
||
return charset[num]
|
||
|
||
result = []
|
||
while num > 0:
|
||
result.append(charset[num % len(charset)])
|
||
num //= len(charset)
|
||
return "".join(reversed(result))
|
||
|
||
|
||
def generate_shape_def(
|
||
shape: str, radius: int, shape_id: str, max_radius: int = 8, char: Optional[str] = None
|
||
) -> str:
|
||
"""Генерирует SVG-элемент для тега <defs> в зависимости от типа фигуры."""
|
||
r = radius
|
||
match shape:
|
||
case "ring":
|
||
inner_r = max(1, round(r * 0.55))
|
||
if inner_r >= r:
|
||
inner_r = max(0, r - 1)
|
||
if inner_r == 0:
|
||
return f'<circle id="{shape_id}" r="{r}"/>'
|
||
return (
|
||
f'<path id="{shape_id}" fill-rule="evenodd" '
|
||
f'd="M0,{-r}A{r},{r} 0 100,{r}A{r},{r} 0 100,{-r}'
|
||
f'M0,{-inner_r} A{inner_r},{inner_r} 0 100,{inner_r}A{inner_r},{inner_r} 0 100,{-inner_r}Z"/>'
|
||
)
|
||
|
||
case "square":
|
||
size = r * 2
|
||
rx = max(0.5, r * 0.1)
|
||
return f'<rect id="{shape_id}" x="{-r}" y="{-r}" width="{size}" height="{size}" rx="{rx:.1f}"/>'
|
||
|
||
case "diamond":
|
||
return f'<polygon id="{shape_id}" points="0,{-r} {-r},0 0,{r} {r},0"/>'
|
||
|
||
case "triangle":
|
||
return f'<polygon id="{shape_id}" points="0,{-r} {-r},{r} {r},{r}"/>'
|
||
|
||
case "hexagon":
|
||
w = round(r * 0.866)
|
||
h_half = round(r * 0.5)
|
||
return f'<polygon id="{shape_id}" points="0,{-r} {w},{-h_half} {w},{h_half} 0,{r} {-w},{h_half} {-w},{-h_half}"/>'
|
||
|
||
case "star":
|
||
points = []
|
||
inner_r = r * 0.45
|
||
for i in range(10):
|
||
angle = i * math.pi / 5 - math.pi / 2
|
||
curr_r = r if i % 2 == 0 else inner_r
|
||
px = round(curr_r * math.cos(angle), 1)
|
||
py = round(curr_r * math.sin(angle), 1)
|
||
points.append(f"{px},{py}")
|
||
pts_str = " ".join(points)
|
||
return f'<polygon id="{shape_id}" points="{pts_str}"/>'
|
||
|
||
case "cross":
|
||
arm = max(1, round(r * 0.35))
|
||
return f'<path id="{shape_id}" d="M{-arm},{-r}H{arm}V{-arm}H{r}V{arm}H{arm}V{r}H{-arm}V{arm}H{-r}V{-arm}H{-arm}Z"/>'
|
||
|
||
case "line":
|
||
th = max(1, round(r * 0.25))
|
||
return f'<rect id="{shape_id}" x="{-r}" y="{-th}" width="{2*r}" height="{2*th}" rx="1"/>'
|
||
|
||
case "snowflake":
|
||
points = []
|
||
inner_r = max(0.5, r * 0.25)
|
||
for i in range(16):
|
||
angle = i * math.pi / 8 - math.pi / 2
|
||
curr_r = r if i % 2 == 0 else inner_r
|
||
px = round(curr_r * math.cos(angle), 1)
|
||
py = round(curr_r * math.sin(angle), 1)
|
||
points.append(f"{px},{py}")
|
||
pts_str = " ".join(points)
|
||
return f'<polygon id="{shape_id}" points="{pts_str}"/>'
|
||
|
||
case "binary":
|
||
if char is None:
|
||
# Вероятностная функция: вероятность "0" пропорциональна оптической плотности
|
||
# В тенях преобладают '0' (~85%), но с шансом ~15% выскакивают '1'
|
||
# В светах преобладают '1' (~85%), но с шансом ~15% выскакивают '0'
|
||
prob_zero = min(0.85, max(0.15, (r / max_radius) if max_radius else 0.5))
|
||
char = "0" if random.random() < prob_zero else "1"
|
||
font_size = max(4, round(r * 2.0))
|
||
return (
|
||
f'<text id="{shape_id}" font-family="ui-monospace,monospace" font-weight="900" font-size="{font_size}">{char}</text>'
|
||
)
|
||
|
||
case "heart":
|
||
r_top = round(r * 0.3)
|
||
r_mid = round(r * 0.4)
|
||
return (
|
||
f'<path id="{shape_id}" '
|
||
f'd="M0,{r}C{-r},{r_mid} {-r},{-r} 0,{-r_top}C{r},{-r} {r},{r_mid} 0,{r}Z"/>'
|
||
)
|
||
|
||
case "circle" | _:
|
||
return f'<circle id="{shape_id}" r="{r}"/>'
|
||
|
||
|
||
def generate_halftone_svg(
|
||
image: Union[Image.Image, BinaryIO, bytes, str],
|
||
*,
|
||
cols: int = 35,
|
||
max_radius: int = 8,
|
||
shape: str = "circle",
|
||
color: str = "#a855ff",
|
||
opacity: float = 0.9,
|
||
blink: int = 6,
|
||
rotation: int = 0,
|
||
scale: int = 980,
|
||
angle: int = 0,
|
||
animation_variants: int = 12,
|
||
seed: int = 42,
|
||
) -> str:
|
||
"""
|
||
Генерирует чистый оптимизированный SVG-халфтон из растрового изображения.
|
||
|
||
Параметры:
|
||
- image: PIL Image, файловый объект (BytesIO), байты или путь к файлу
|
||
- cols: число точек по наибольшей стороне сетки (10-200)
|
||
- max_radius: максимальный радиус/размер точки (3-20)
|
||
- shape: тип фигуры ('circle', 'ring', 'square', 'diamond', 'triangle', 'hexagon', 'star', 'cross', 'line', 'snowflake', 'heart')
|
||
- color: основной HEX цвет (например '#a855ff')
|
||
- opacity: коэффициент непрозрачности (0.0 - 1.0)
|
||
- blink: интенсивность мерцания (0 - выключено/статичный, 1-10 - скорость)
|
||
- rotation: угол покачивания в градусах (-15 .. +15)
|
||
- scale: масштаб пульсации (800 .. 1040, где 980 = 0.98)
|
||
- angle: наклон растровой сетки (-44 .. +45)
|
||
- animation_variants: число вариантов CSS-задержек
|
||
- seed: сид для детерминированного распределения анимаций
|
||
|
||
TODO ПОЧИНИТЬ-УЛУЧШИТЬ:
|
||
- angle: сейчас это просто поворот картинки. Нужно чтобы:
|
||
- нужно чтобы это был поворт картинки (т.е. ряды-столбцы были со смещение-поворотом, как при полиграфии)
|
||
- blink: что-то страннное. пока не понял
|
||
|
||
-- ПРОЧЕЕ:
|
||
-- C "кольцами" что-то не так (они кружочки)
|
||
-- Проверить: scale и rotation кажется не вокруг центра.
|
||
-- ЕСЛИ ЦВЕТ ЧЕРЕЗ ИНТЕРФЕЙС НЕ МЕНЯЛСЯ (ДЕФОЛТНЫЙ), ТО ЛУЧШЕ (???) НАЗНАЧАТЬ СЛУЧАНЫЙ (или из набора).
|
||
А то слишком однообразные цвета герераций в галерее
|
||
-- Если задан большой размер точки, rotation или scale -- то нужно делать "паспорту", иначе точки "вылезают"
|
||
за пределы ViewPort
|
||
"""
|
||
rng = random.Random(seed)
|
||
|
||
# 1. Загрузка и подготовка изображения в градациях серого
|
||
if isinstance(image, (bytes, bytearray)):
|
||
pil_img = Image.open(io.BytesIO(image))
|
||
elif hasattr(image, "read"):
|
||
if hasattr(image, "seek"):
|
||
image.seek(0)
|
||
pil_img = Image.open(image)
|
||
elif isinstance(image, str):
|
||
pil_img = Image.open(image)
|
||
elif isinstance(image, Image.Image):
|
||
pil_img = image
|
||
else:
|
||
raise ValueError("Неподдерживаемый тип входного изображения")
|
||
|
||
# Конвертируем в Grayscale
|
||
img = pil_img.convert("L")
|
||
|
||
# Обработка наклона сетки (angle): поворачиваем изображение при необходимости
|
||
if angle != 0:
|
||
# Поворачиваем с сохранением пропорций и белым фоном (255 = прозрачно/пусто)
|
||
img = img.rotate(-angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=255)
|
||
|
||
# Вычисляем размеры сетки: значение `cols` задает число точек по наибольшей стороне (ширине или высоте)
|
||
if img.width >= img.height:
|
||
grid_width = max(1, cols)
|
||
grid_height = max(1, round(cols * (img.height / img.width)))
|
||
else:
|
||
grid_height = max(1, cols)
|
||
grid_width = max(1, round(cols * (img.width / img.height)))
|
||
|
||
img_resized = img.resize((grid_width, grid_height), Image.Resampling.LANCZOS)
|
||
|
||
# Базовый шаг ячейки сетки (номинальный радиус касания точек R0 = 10, диаметр D0 = 20)
|
||
STEP = 20
|
||
|
||
# Расчет запаса/паспарту (margin) по краям холста:
|
||
# Учитывает увеличенный радиус точек (при наползании) и масштабирование при пульсации/повороте
|
||
scale_val = max(0.5, min(1.5, scale / 1000.0))
|
||
max_effective_radius = max_radius * max(1.0, scale_val)
|
||
max_visual_extent = math.ceil(max_effective_radius * 1.42) + 2
|
||
margin = max(4, max_visual_extent - STEP // 2 + 2)
|
||
|
||
width = grid_width * STEP + 2 * margin
|
||
height = grid_height * STEP + 2 * margin
|
||
|
||
# 2. Обход пикселей и группировка
|
||
elements_by_class = defaultdict(list)
|
||
unique_shape_keys = set()
|
||
is_animated = blink > 0
|
||
|
||
for y in range(grid_height):
|
||
for x in range(grid_width):
|
||
brightness = img_resized.getpixel((x, y))
|
||
factor = (255 - brightness) / 255.0
|
||
|
||
# Отсекаем слишком светлые участки (шум фона)
|
||
if factor < 0.12:
|
||
continue
|
||
|
||
radius = int(factor * max_radius)
|
||
if radius == 0:
|
||
continue
|
||
|
||
cx = margin + x * STEP + STEP // 2
|
||
cy = margin + y * STEP + STEP // 2
|
||
|
||
anim_index = rng.randint(0, animation_variants - 1) if is_animated else 0
|
||
encoded_class = encode_to_base36(anim_index)
|
||
|
||
if shape == "binary":
|
||
# Вероятность выпадения '0' плавно растет с оптической плотностью
|
||
# В тенях преобладают '0' (~85%), но с шансом ~15% выскакивают '1'
|
||
# В светах преобладают '1' (~85%), но с шансом ~15% выскакивают '0'
|
||
prob_zero = 0.15 + 0.70 * (radius / max_radius if max_radius else factor)
|
||
char = "0" if rng.random() < prob_zero else "1"
|
||
shape_id = f"s{encode_to_base36(radius)}{char}"
|
||
unique_shape_keys.add((radius, char, shape_id))
|
||
else:
|
||
shape_id = f"s{encode_to_base36(radius)}"
|
||
unique_shape_keys.add((radius, None, shape_id))
|
||
|
||
elements_by_class[encoded_class].append((shape_id, cx, cy, anim_index))
|
||
|
||
# 3. Формирование <defs>
|
||
defs_list = ["<defs>"]
|
||
for radius, char, shape_id in sorted(unique_shape_keys, key=lambda k: (k[0], k[1] or "")):
|
||
defs_list.append(generate_shape_def(shape, radius, shape_id, max_radius=max_radius, char=char))
|
||
defs_list.append("</defs>")
|
||
defs_html = "".join(defs_list)
|
||
|
||
# 4. Формирование групп <g class="a..."> и CSS классов задержек
|
||
# Список простых чисел для создания богатой апериодической полиритмии мерцания
|
||
PRIME_DELAYS = [0, 0.1, 0.3, 0.5, 0.7, 1.1, 1.3, 1.7, 1.9, 2.3, 2.9, 3.1]
|
||
duration = max(0.4, 2.5 - (blink * 0.19)) if is_animated else 1.0
|
||
groups = []
|
||
animation_classes = []
|
||
|
||
# Сортируем группы по индексу анимации для детерминированного порядка
|
||
for encoded_class in sorted(elements_by_class.keys(), key=lambda c: elements_by_class[c][0][3]):
|
||
group_elements = elements_by_class[encoded_class]
|
||
if not group_elements:
|
||
continue
|
||
|
||
anim_index = group_elements[0][3]
|
||
if is_animated:
|
||
prime_base = PRIME_DELAYS[anim_index % len(PRIME_DELAYS)]
|
||
# Масштабируем задержку относительно длительности анимации с округлением до десятых
|
||
delay = round(prime_base * (duration / 2.0), 1)
|
||
|
||
# Вычисляем уникальный центр трансформации для каждой группы анимации.
|
||
# Центры группируются вокруг центра холста (50% 50%) с органическим разбросом,
|
||
# благодаря чему группы дышат и покачиваются вокруг слегка смещенных фокусов.
|
||
angle_jitter = (anim_index * (2 * math.pi / max(1, animation_variants))) + (seed % 7)
|
||
spread_factor = 0.02 + 0.13 * abs((scale - 1000) / 200.0) # от 2% до 15%
|
||
orig_x = round(50.0 + math.cos(angle_jitter) * spread_factor * 100)
|
||
orig_y = round(50.0 + math.sin(angle_jitter) * spread_factor * 100)
|
||
origin_rule = f";transform-origin:{orig_x}% {orig_y}%"
|
||
else:
|
||
delay = 0.0
|
||
origin_rule = ""
|
||
|
||
# Генерируем правило задержки и центра в CSS
|
||
if is_animated:
|
||
delay_str = f"{int(delay)}" if delay == int(delay) else f"{delay:.1f}".rstrip("0").rstrip(".")
|
||
animation_classes.append(f".a{encoded_class}{{--d:{delay_str}s{origin_rule}}}")
|
||
|
||
uses = "".join(
|
||
f'<use href="#{shape_id}" x="{cx}" y="{cy}"/>'
|
||
for shape_id, cx, cy, _ in group_elements
|
||
)
|
||
if uses:
|
||
groups.append(f'<g class="a{encoded_class}">{uses}</g>')
|
||
|
||
animation_css = "".join(animation_classes)
|
||
groups_html = "".join(groups)
|
||
|
||
# 6. Стилизация и Keyframes
|
||
scale_val = max(0.5, min(1.5, scale / 1000.0))
|
||
scale_str = f"{scale_val:.3f}".rstrip("0").rstrip(".")
|
||
has_scale = scale_val != 1.0
|
||
has_rotation = rotation != 0
|
||
|
||
transform_0 = []
|
||
transform_100 = []
|
||
if has_scale:
|
||
transform_0.append("scale(1)")
|
||
transform_100.append(f"scale({scale_str})")
|
||
if has_rotation:
|
||
transform_0.append("rotate(0deg)")
|
||
transform_100.append(f"rotate({rotation}deg)")
|
||
|
||
tf_0_rule = f";transform:{' '.join(transform_0)}" if transform_0 else ""
|
||
tf_100_rule = f";transform:{' '.join(transform_100)}" if transform_100 else ""
|
||
|
||
# Корректный цвет и прозрачность
|
||
clean_color = color.strip() if color else "#a855ff"
|
||
if not clean_color.startswith("#"):
|
||
clean_color = f"#{clean_color}"
|
||
|
||
has_custom_opacity = round(opacity, 2) < 1.0
|
||
opacity_rule = f"opacity:{opacity:.1f};" if has_custom_opacity else ""
|
||
fill_style = f"fill:{clean_color};stroke:{clean_color};{opacity_rule}"
|
||
|
||
if is_animated:
|
||
# Управление паузой/запуском через CSS Custom Property --hypn0-play.
|
||
# По умолчанию running (для живого превью и детального просмотра).
|
||
# В галерее значение переопределяется на paused для 0% нагрузки на процессор.
|
||
anim_rule = (
|
||
f"animation:noise {duration:.1f}s ease-in-out infinite alternate;"
|
||
f"animation-delay:var(--d,0s);"
|
||
f"animation-play-state:var(--hypn0-play,running);"
|
||
)
|
||
op_0 = f"{max(0.2, opacity * 0.7):.1f}".rstrip("0").rstrip(".")
|
||
op_50 = f"{opacity:.1f}".rstrip("0").rstrip(".")
|
||
op_100 = f"{max(0.1, opacity * 0.5):.1f}".rstrip("0").rstrip(".")
|
||
|
||
keyframes_rule = (
|
||
f"@keyframes noise{{"
|
||
f"0%{{opacity:{op_0}{tf_0_rule}}}"
|
||
f"50%{{opacity:{op_50}}}"
|
||
f"100%{{opacity:{op_100}{tf_100_rule}}}"
|
||
f"}}"
|
||
)
|
||
else:
|
||
anim_rule = "animation:none;"
|
||
keyframes_rule = ""
|
||
|
||
frozen_opacity = f";opacity:{opacity:.1f}!important" if has_custom_opacity else ""
|
||
svg_css = (
|
||
f"<style>"
|
||
f"svg{{background:transparent}}"
|
||
f"circle,rect,polygon,path,text{{{fill_style}}}"
|
||
f"g[class^=\"a\"]{{{anim_rule}transition:all .5s ease-out}}"
|
||
f"{keyframes_rule}"
|
||
f".frozen g[class^=\"a\"],.frozen circle,.frozen rect,.frozen polygon,.frozen path,.frozen text{{animation:none!important{frozen_opacity};transform:none!important}}"
|
||
f"{animation_css}"
|
||
f"</style>"
|
||
)
|
||
|
||
svg_template = (
|
||
f'<!-- Generated by hypn0.xyz -->\n'
|
||
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" '
|
||
f'data-author="hypn0.xyz" data-generator="https://hypn0.xyz">'
|
||
f'{svg_css}'
|
||
f'<desc>Generated by Hypn0 (https://hypn0.xyz)</desc>'
|
||
f'{defs_html}'
|
||
f'<g id="hypn0-xyz" data-source="https://hypn0.xyz">{groups_html}</g>'
|
||
f'</svg>'
|
||
)
|
||
|
||
return svg_template
|
||
|
||
|
||
def prepare_gallery_svg(svg_content: str) -> str:
|
||
"""
|
||
Модифицирует SVG для долговременного хранения в галерее:
|
||
внедряет CSS Custom Property (--hypn0-play: paused), которое свободно наследуется
|
||
внутрь элементов и теневых деревьев <use>.
|
||
|
||
Логика работы:
|
||
1. В покое (--hypn0-play: paused): 0% потребления CPU и GPU браузера в ленте галереи.
|
||
2. При наведении курсора (:host(:hover) в Shadow DOM или svg:hover при открытии файла):
|
||
значение переключается на running, и анимация оживает.
|
||
"""
|
||
if not svg_content:
|
||
return svg_content
|
||
|
||
# Наследуемая переменная паузы и оверрайд стилей фигур
|
||
# Важно: селектор :host(:hover) работает при вставке в Shadow DOM карточки,
|
||
# а svg:hover работает, если файл открыт напрямую в браузере.
|
||
hover_css = (
|
||
"svg{--hypn0-play:paused}"
|
||
":host(:hover) svg,svg:hover{--hypn0-play:running!important}"
|
||
"g[class^=\"a\"],circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}"
|
||
)
|
||
|
||
if "</style>" in svg_content:
|
||
return svg_content.replace("</style>", f"{hover_css}</style>", 1)
|
||
|
||
return svg_content
|
||
|
||
|
||
def prepare_active_svg(svg_content: str) -> str:
|
||
"""
|
||
Возвращает полностью активный SVG без паузы анимации вне ховера
|
||
для страницы детального просмотра (/gallery/<hash_id>) и скачивания пользователем.
|
||
|
||
Удаляет оверрайды пауз (--hypn0-play: paused), восстанавливая оригинальные
|
||
авторские цвета, прозрачности и непрерывную живую анимацию на полной мощности.
|
||
"""
|
||
if not svg_content:
|
||
return svg_content
|
||
|
||
# Удаляем внедренные правила паузы через CSS-переменные и старые форматы
|
||
gallery_css_variants = [
|
||
"svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}g[class^=\"a\"],circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}",
|
||
"svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
|
||
"svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
|
||
"svg{--hypn0-play:paused}:host(:hover) svg,svg:hover{--hypn0-play:running!important}circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}",
|
||
"svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}.shape,circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
|
||
"svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}circle,rect,polygon,path{animation-play-state:var(--hypn0-play,paused)!important}",
|
||
"svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}",
|
||
"svg{--hypn0-play:paused}svg:hover{--hypn0-play:running}g[class^=\"a\"],circle,rect,polygon,path,text{animation-play-state:var(--hypn0-play,paused)!important}",
|
||
]
|
||
cleaned = svg_content
|
||
for gcss in gallery_css_variants:
|
||
cleaned = cleaned.replace(gcss, "")
|
||
|
||
cleaned = re.sub(r"svg\s*\{[^}]*--hypn0-play:\s*paused[^}]*\}", "", cleaned)
|
||
cleaned = re.sub(r"(:host\(:hover\)\s*svg\s*,\s*)?svg:hover\s*\{[^}]*--hypn0-play:[^}]*\}", "", cleaned)
|
||
cleaned = re.sub(
|
||
r"(g\[class\^=\"a\"\],)?(\.shape,)?circle,rect,polygon,path(,text)?\s*\{animation-play-state:\s*var\(--hypn0-play,\s*paused\)!important\}",
|
||
"",
|
||
cleaned,
|
||
)
|
||
cleaned = re.sub(r"@media\s*\(\s*hover\s*:\s*hover\s*\)\s*\{[^}]*:[^}]*\}", "", cleaned)
|
||
cleaned = re.sub(r"svg:not\(:hover\)[^{]*\{[^}]*\}", "", cleaned)
|
||
old_hover_css = (
|
||
"svg:not(:hover) .shape,svg:not(:hover) circle,svg:not(:hover) rect,"
|
||
"svg:not(:hover) polygon,svg:not(:hover) path{animation-play-state:paused!important}"
|
||
)
|
||
cleaned = cleaned.replace(old_hover_css, "")
|
||
return cleaned
|
||
|
||
|
||
def analyze_svg_structure(svg_content: str) -> dict:
|
||
"""
|
||
Анализирует разметку SVG и возвращает наукообразную детальную статистику
|
||
по геометрическим примитивам, классам, анимациям и энтропии данных.
|
||
"""
|
||
if not svg_content:
|
||
return {
|
||
"total_bytes": 0,
|
||
"total_kb": 0.0,
|
||
"total_oscillators": 0,
|
||
"use_count": 0,
|
||
"circle_count": 0,
|
||
"rect_count": 0,
|
||
"polygon_count": 0,
|
||
"path_count": 0,
|
||
"defs_count": 0,
|
||
"groups_count": 0,
|
||
"keyframes_count": 0,
|
||
"classes_count": 0,
|
||
"viewbox": "0 0 1000 1000",
|
||
"width": 1000,
|
||
"height": 1000,
|
||
"aspect_ratio": "1:1",
|
||
"density": 0.0,
|
||
"bytes_per_quantum": 0.0,
|
||
}
|
||
|
||
total_bytes = len(svg_content.encode("utf-8"))
|
||
|
||
# Подсчет векторных сущностей
|
||
use_count = len(re.findall(r"<use\b", svg_content))
|
||
circle_count = len(re.findall(r"<circle\b", svg_content))
|
||
rect_count = len(re.findall(r"<rect\b", svg_content))
|
||
polygon_count = len(re.findall(r"<polygon\b", svg_content))
|
||
path_count = len(re.findall(r"<path\b", svg_content))
|
||
text_count = len(re.findall(r"<text\b", svg_content))
|
||
defs_count = circle_count + rect_count + polygon_count + path_count + text_count
|
||
|
||
# Группы и классы
|
||
groups_count = len(re.findall(r"<g\b", svg_content))
|
||
keyframes_count = len(re.findall(r"@keyframes\b", svg_content))
|
||
classes = set(re.findall(r"\.([a-zA-Z0-9_-]+)\s*\{", svg_content))
|
||
classes_count = len(classes)
|
||
|
||
# ViewBox и геометрия
|
||
vb_match = re.search(r'viewBox=["\']([^"\']+)["\']', svg_content)
|
||
viewbox = vb_match.group(1) if vb_match else "0 0 1000 1000"
|
||
vb_parts = viewbox.split()
|
||
try:
|
||
width = int(float(vb_parts[2])) if len(vb_parts) >= 3 else 1000
|
||
height = int(float(vb_parts[3])) if len(vb_parts) >= 4 else 1000
|
||
except (ValueError, IndexError):
|
||
width, height = 1000, 1000
|
||
|
||
gcd_val = math.gcd(width, height)
|
||
aspect_ratio = f"{width // gcd_val}:{height // gcd_val}" if gcd_val > 0 else "1:1"
|
||
|
||
# Наукообразные показатели
|
||
total_oscillators = use_count if use_count > 0 else defs_count
|
||
bytes_per_quantum = round(total_bytes / max(total_oscillators, 1), 1)
|
||
density = round(total_oscillators / max((width * height) / 1048576.0, 1.0), 2)
|
||
|
||
return {
|
||
"total_bytes": total_bytes,
|
||
"total_kb": round(total_bytes / 1024, 2),
|
||
"total_oscillators": total_oscillators,
|
||
"use_count": use_count,
|
||
"circle_count": circle_count,
|
||
"rect_count": rect_count,
|
||
"polygon_count": polygon_count,
|
||
"path_count": path_count,
|
||
"defs_count": defs_count,
|
||
"groups_count": groups_count,
|
||
"keyframes_count": keyframes_count,
|
||
"classes_count": classes_count,
|
||
"viewbox": viewbox,
|
||
"width": width,
|
||
"height": height,
|
||
"aspect_ratio": aspect_ratio,
|
||
"density": density,
|
||
"bytes_per_quantum": bytes_per_quantum,
|
||
}
|