add: core (01) драфт... работает!! Генерация SVG проходит!!!

This commit is contained in:
2026-08-23 22:14:49 +03:00
parent f015351c42
commit 05bb583d8a
7 changed files with 425 additions and 13 deletions
+97
View File
@@ -0,0 +1,97 @@
from django import forms
class HalftoneGenerateForm(forms.Form):
"""
Форма валидации входных параметров для генерации гипнотического SVG.
"""
SHAPE_CHOICES = [
("circle", "Круг"),
("ring", "Кольцо"),
("triangle", "Треугольник"),
("square", "Квадрат"),
("diamond", "Ромб"),
("hexagon", "Шестиугольник"),
("star", "Звезда"),
("cross", "Крестик"),
("line", "Линия"),
("wave", "Волна"),
("heart", "Сердечко"),
]
image = forms.ImageField(
required=True,
error_messages={
"required": "Покорми Гипножабу изображением!",
"invalid_image": "Гипножаба не может переварить этот файл. Нужна картинка (PNG, JPG, WEBP).",
},
)
shape = forms.ChoiceField(
choices=SHAPE_CHOICES,
initial="circle",
required=False,
)
cols = forms.IntegerField(
min_value=20,
max_value=200,
initial=80,
required=False,
)
max_radius = forms.IntegerField(
min_value=3,
max_value=20,
initial=8,
required=False,
)
blink = forms.IntegerField(
min_value=0,
max_value=10,
initial=6,
required=False,
)
rotation = forms.IntegerField(
min_value=-15,
max_value=15,
initial=0,
required=False,
)
scale = forms.IntegerField(
min_value=800,
max_value=1040,
initial=980,
required=False,
)
angle = forms.IntegerField(
min_value=-44,
max_value=45,
initial=0,
required=False,
)
def clean_shape(self):
shape = self.cleaned_data.get("shape")
if not shape:
return "circle"
return shape
def clean_cols(self):
return self.cleaned_data.get("cols") or 80
def clean_max_radius(self):
return self.cleaned_data.get("max_radius") or 8
def clean_blink(self):
val = self.cleaned_data.get("blink")
return 6 if val is None else val
def clean_rotation(self):
val = self.cleaned_data.get("rotation")
return 0 if val is None else val
def clean_scale(self):
val = self.cleaned_data.get("scale")
return 980 if val is None else val
def clean_angle(self):
val = self.cleaned_data.get("angle")
return 0 if val is None else val
+190 -2
View File
@@ -1,3 +1,191 @@
from django.test import TestCase import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from PIL import Image
# Create your tests here. from .forms import HalftoneGenerateForm
from .services.halftone import encode_to_base36, generate_halftone_svg
class HalftoneServiceTests(TestCase):
"""Тестирование сервиса генерации SVG (services/halftone.py)."""
def setUp(self):
# Создаем простое тестовое изображение 50x50 с градиентом
self.img = Image.new("RGB", (50, 50), color="white")
for x in range(25):
for y in range(50):
self.img.putpixel((x, y), (0, 0, 0))
def test_encode_to_base36(self):
self.assertEqual(encode_to_base36(0), "a")
self.assertEqual(encode_to_base36(1), "b")
with self.assertRaises(ValueError):
encode_to_base36(-1)
def test_generate_default_svg(self):
svg = generate_halftone_svg(self.img, cols=30, max_radius=6)
self.assertIn("<svg", svg)
self.assertIn("</svg>", svg)
self.assertIn("<defs>", svg)
self.assertIn('<circle id="s', svg)
self.assertIn('<g id="hypn0-canvas">', svg)
def test_generate_shapes(self):
shapes = ["circle", "ring", "square", "diamond", "triangle", "hexagon", "star", "cross", "line", "wave", "heart"]
for shape in shapes:
svg = generate_halftone_svg(self.img, cols=20, max_radius=5, shape=shape)
self.assertIn("<svg", svg)
self.assertIn("</svg>", svg)
self.assertIn("<defs>", svg)
def test_generate_static_blink_zero(self):
svg = generate_halftone_svg(self.img, cols=20, blink=0)
self.assertIn("animation:none", svg)
self.assertNotIn("@keyframes noise", svg)
def test_generate_animated_params(self):
svg = generate_halftone_svg(
self.img,
cols=20,
blink=8,
rotation=12,
scale=920,
angle=15,
color="#ff5500",
)
self.assertIn("@keyframes noise", svg)
self.assertIn("rotate(12deg)", svg)
self.assertIn("scale(0.92)", svg)
self.assertIn("#ff5500", svg)
def test_generate_from_bytes(self):
buf = io.BytesIO()
self.img.save(buf, format="PNG")
svg = generate_halftone_svg(buf.getvalue(), cols=20)
self.assertIn("<svg", svg)
class HalftoneFormTests(TestCase):
"""Тестирование формы валидации HalftoneGenerateForm."""
def test_form_validation(self):
buf = io.BytesIO()
img = Image.new("RGB", (30, 30), color="red")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
form = HalftoneGenerateForm(
data={
"shape": "square",
"cols": 60,
"max_radius": 7,
"blink": 5,
"rotation": 10,
"scale": 950,
"angle": -10,
},
files={"image": uploaded},
)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["shape"], "square")
self.assertEqual(form.cleaned_data["cols"], 60)
class HalftoneViewTests(TestCase):
"""Тестирование view-обработчиков (index, generate)."""
def setUp(self):
self.client = Client()
def test_index_page(self):
response = self.client.get(reverse("hypn0_site:index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "logo-hypn0.svg")
self.assertContains(response, 'id="preview"')
self.assertContains(response, 'name="csrfmiddlewaretoken"')
self.assertContains(response, 'X-CSRFToken')
# Проверяем, что csrf_token не пустой в hx-headers
content = response.content.decode("utf-8")
self.assertNotIn('hx-headers=\'{"X-CSRFToken": ""}\'', content)
def test_generate_with_enforce_csrf(self):
csrf_client = Client(enforce_csrf_checks=True)
response = csrf_client.get(reverse("hypn0_site:index"))
self.assertEqual(response.status_code, 200)
buf = io.BytesIO()
img = Image.new("RGB", (40, 40), color="black")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
# Без CSRF токена вернет 403
bad_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={"shape": "circle", "image": uploaded},
)
self.assertEqual(bad_response.status_code, 403)
# С CSRF токеном в POST data вернет 200
csrf_token = response.cookies["csrftoken"].value
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
good_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={
"csrfmiddlewaretoken": csrf_token,
"shape": "circle",
"image": uploaded,
},
)
self.assertEqual(good_response.status_code, 200)
self.assertContains(good_response, "<svg")
# С CSRF токеном в заголовке X-CSRFToken (как отправляет HTMX) вернет 200
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
header_response = csrf_client.post(
reverse("hypn0_site:generate"),
data={
"shape": "circle",
"image": uploaded,
},
HTTP_X_CSRFTOKEN=csrf_token,
)
self.assertEqual(header_response.status_code, 200)
self.assertContains(header_response, "<svg")
def test_generate_get_not_allowed(self):
response = self.client.get(reverse("hypn0_site:generate"))
self.assertEqual(response.status_code, 405)
def test_generate_post_no_image(self):
response = self.client.post(reverse("hypn0_site:generate"), data={"shape": "circle"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Покорми Гипножабу")
def test_generate_post_success(self):
buf = io.BytesIO()
img = Image.new("RGB", (40, 40), color="black")
img.save(buf, format="PNG")
buf.seek(0)
uploaded = SimpleUploadedFile("test.png", buf.read(), content_type="image/png")
response = self.client.post(
reverse("hypn0_site:generate"),
data={
"shape": "diamond",
"cols": "40",
"max_radius": "6",
"blink": "6",
"colors": ["#10b981"],
"image": uploaded,
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<svg")
self.assertContains(response, 'points="0,-6')
self.assertContains(response, "like-to-gallery")
+3 -3
View File
@@ -1,14 +1,14 @@
from django.urls import path from django.urls import path
from . import views
from hypn0 import settings from hypn0 import settings
from . import views
app_name = "hypn0_site" app_name = "hypn0_site"
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("generate", views.generate, name="generate"),
] ]
if settings.DEBUG: if settings.DEBUG:
urlpatterns += [path('tmp/', views.tmp, name='web_tmp')] urlpatterns += [path("tmp/", views.tmp, name="web_tmp")]
+62 -4
View File
@@ -1,11 +1,69 @@
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.http import HttpRequest, HttpResponse, Http404 from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST
# Create your views here. from .forms import HalftoneGenerateForm
from .services.halftone import generate_halftone_svg
@ensure_csrf_cookie
def index(request: HttpRequest | None) -> HttpResponse: def index(request: HttpRequest | None) -> HttpResponse:
return render(request, 'index.html', {}) return render(request, "index.html", {})
def tmp(request: HttpRequest | None) -> HttpResponse: def tmp(request: HttpRequest | None) -> HttpResponse:
return render(request, 'tmp.html', {}) return render(request, "tmp.html", {})
@require_POST
def generate(request: HttpRequest) -> HttpResponse:
"""
HTMX-эндпоинт для генерации гипнотического полутонового SVG на лету.
Не сохраняет данные в БД (Zero-Disk / Zero-PII).
"""
form = HalftoneGenerateForm(request.POST, request.FILES)
if not form.is_valid():
error_msg = next(iter(form.errors.values()))[0] if form.errors else "Ошибка параметров генерации"
return render(request, "block/preview.html", {"error": error_msg})
image_file = form.cleaned_data["image"]
shape = form.cleaned_data["shape"]
cols = form.cleaned_data["cols"]
max_radius = form.cleaned_data["max_radius"]
blink = form.cleaned_data["blink"]
rotation = form.cleaned_data["rotation"]
scale = form.cleaned_data["scale"]
angle = form.cleaned_data["angle"]
# Извлечение основного цвета (поддержка монотонной схемы)
colors = request.POST.getlist("colors")
primary_color = colors[0] if colors and colors[0] else "#a855ff"
try:
svg_content = generate_halftone_svg(
image=image_file,
cols=cols,
max_radius=max_radius,
shape=shape,
color=primary_color,
blink=blink,
rotation=rotation,
scale=scale,
angle=angle,
)
except Exception as e:
return render(
request,
"block/preview.html",
{"error": f"Сбой в матрице гипноза: {str(e)}"},
)
return render(
request,
"block/preview.html",
{
"svg_content": svg_content,
},
)
+13 -1
View File
@@ -110,9 +110,21 @@ val=>{if(val)document.documentElement.classList.add('dark');else document.docume
{% endblock EXTRA_LD_JSON %}</script> {% endblock EXTRA_LD_JSON %}</script>
<script src="{% static 'js/alpine.min.js' %}" defer></script> <script src="{% static 'js/alpine.min.js' %}" defer></script>
<script src="{% static 'js/htmx.min.js' %}" defer></script> <script src="{% static 'js/htmx.min.js' %}" defer></script>
<script>
document.addEventListener('htmx:configRequest', function(evt) {
var token = '{{ csrf_token }}';
if (!token) {
var match = document.cookie.match(/csrftoken=([^;]+)/);
if (match) token = match[1];
}
if (token) {
evt.detail.headers['X-CSRFToken'] = token;
}
});
</script>
{% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %} {% block EXTRA_HEAD %}{% endblock EXTRA_HEAD %}
</head> </head>
<body class="bg-stone-50 text-emerald-700 dark:bg-zinc-950 dark:text-emerald-100 font-mono">{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %} <body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' class="bg-stone-50 text-emerald-700 dark:bg-zinc-950 dark:text-emerald-100 font-mono">{% block ADD_CSS1 %}{% endblock %}{% block ADD_CSS2 %}{% endblock %}{% block ADD_CSS3 %}{% endblock %}{% block BODY %}
{% include "block/header.html" %} {% include "block/header.html" %}
<main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8"> <main id="main-content" class="mx-auto min-h-screen max-w-7xl px-4 py-8">
{% block CONTENT %}{% endblock CONTENT %} {% block CONTENT %}{% endblock CONTENT %}
+26
View File
@@ -0,0 +1,26 @@
{% if error %}
<div class="text-rose-500 flex flex-col items-center gap-4 text-center px-6">
<svg class="w-16 h-16 text-rose-500 opacity-80 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p class="font-mono text-sm font-bold uppercase tracking-wider text-rose-600 dark:text-rose-400">
{{ error }}
</p>
</div>
{% else %}
<div class="w-full h-full flex items-center justify-center p-4 select-none overflow-hidden" id="svg-canvas-container">
{{ svg_content|safe }}
</div>
<!-- Плавающие кнопки действий -->
<div class="absolute bottom-6 right-6 flex items-center gap-3 translate-y-20 group-hover:translate-y-0 transition-transform duration-300 pointer-events-none">
<button id="like-to-gallery" type="button" title="Нравится!"
class="pointer-events-auto flex items-center gap-2 bg-emerald-600/90 hover:bg-emerald-600 text-white text-xs font-bold uppercase tracking-wide backdrop-blur-md px-4 py-2.5 rounded-full shadow-xl transition-all hover:scale-105 active:scale-95 cursor-pointer">
<span>Кандидат в галерею транса</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
</button>
</div>
{% endif %}
+34 -3
View File
@@ -23,6 +23,7 @@
hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */ hasChanges: true, /* Флаг наличия правок в форме для активности кнопки */
hasImage: false, /* Флаг загрузки пользовательского файла */ hasImage: false, /* Флаг загрузки пользовательского файла */
timer: null, /* Ссылка на таймер удержания анимации размышления */ timer: null, /* Ссылка на таймер удержания анимации размышления */
copied: false, /* Флаг успешного копирования в буфер */
/* Метод запуска состояния размышления (8 секунд) */ /* Метод запуска состояния размышления (8 секунд) */
startThinking() { startThinking() {
@@ -32,6 +33,29 @@
this.isLoading = false; this.isLoading = false;
this.hasChanges = false; this.hasChanges = false;
}, 8000); }, 8000);
},
copySvg() {
const svg = document.querySelector('#preview svg');
if (!svg) return;
navigator.clipboard.writeText(svg.outerHTML).then(() => {
this.copied = true;
setTimeout(() => this.copied = false, 2000);
});
},
downloadSvg() {
const svg = document.querySelector('#preview svg');
if (!svg) return;
const blob = new Blob([svg.outerHTML], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'hypn0-art.svg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} }
}"> }">
@@ -45,7 +69,9 @@
@change="hasChanges = true" @change="hasChanges = true"
@input="hasChanges = true" @input="hasChanges = true"
@htmx:before-request="startThinking()" @htmx:before-request="startThinking()"
@htmx:after-request="isLoading = false; hasChanges = false"
class="relative bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-stone-200 dark:border-zinc-800 overflow-hidden"> class="relative bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-stone-200 dark:border-zinc-800 overflow-hidden">
{% csrf_token %}
<!-- Фоновая спираль размышления при генерации (прижата снизу на всю левую панель) --> <!-- Фоновая спираль размышления при генерации (прижата снизу на всю левую панель) -->
<div x-show="isLoading" <div x-show="isLoading"
@@ -65,6 +91,7 @@
<label class="block text-sm font-bold mb-2 uppercase tracking-wider text-stone-500 dark:text-zinc-400">Источник</label> <label class="block text-sm font-bold mb-2 uppercase tracking-wider text-stone-500 dark:text-zinc-400">Источник</label>
<div class="relative group"> <div class="relative group">
<input type="file" name="image" accept="image/*" <input type="file" name="image" accept="image/*"
@change="hasImage = $el.files.length > 0; hasChanges = true"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"> class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div <div
class="border-2 border-dashed border-stone-300 dark:border-zinc-700 group-hover:border-emerald-500 rounded-xl p-8 flex flex-col items-center justify-center text-center transition-colors"> class="border-2 border-dashed border-stone-300 dark:border-zinc-700 group-hover:border-emerald-500 rounded-xl p-8 flex flex-col items-center justify-center text-center transition-colors">
@@ -410,13 +437,17 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<button <button
class="bg-stone-800 text-white dark:bg-zinc-800 py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-stone-700 transition-colors"> type="button"
@click="copySvg()"
class="bg-stone-800 text-white dark:bg-zinc-800 py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-stone-700 transition-colors cursor-pointer active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg> <path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
КОПИРОВАТЬ SVG <span x-text="copied ? 'СКОПИРОВАНО В БУФЕР!' : 'КОПИРОВАТЬ SVG'"></span>
</button> </button>
<button <button
class="bg-emerald-600 text-white py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-emerald-500 transition-colors shadow-lg shadow-emerald-900/20"> type="button"
@click="downloadSvg()"
class="bg-emerald-600 text-white py-3.5 rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-emerald-500 transition-colors shadow-lg shadow-emerald-900/20 cursor-pointer active:scale-95">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
СКАЧАТЬ СКАЧАТЬ
</button> </button>